Skip to content

Commit 5d3186f

Browse files
SikoraKamBrtqKr
andauthored
feat/check-limits (#53)
* verify click limit before universal link return * Universal links analytics handler for sdk (#56) * feat:universal links analytics handler for sdk * feat:add check flag * fix:listener condition check * fix:remove import * refactor: removed unused function --------- Co-authored-by: Kamil Sikora <kamil.sikora@swmansion.com> * Extend universal links with analytics (#58) * feature:extend with analytics * refactor:cleanup --------- Co-authored-by: Bartek Krasoń <45288762+BrtqKr@users.noreply.github.com>
1 parent 67fcadf commit 5d3186f

3 files changed

Lines changed: 161 additions & 25 deletions

File tree

packages/react-native-detour/src/expo-router/nativeIntent.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { OPENED_VIA_UNIVERSAL_LINK } from "../analytics/const/definedEvents";
2-
import { analyticsEmitter } from "../analytics/utils/analyticsEmitter";
31
import { resolveShortLink } from "../links/api/resolveShortLink";
2+
import { sendUniversalLinkClick } from "../links/api/sendUniversalLinkClick";
43
import type { Config } from "../links/types";
54
import { getRouteFromDeepLink } from "../links/utils/urlHelpers";
65

@@ -288,12 +287,24 @@ export const createDetourNativeIntentHandler = (
288287
return path;
289288
}
290289

291-
analyticsEmitter.emit({
292-
eventName: OPENED_VIA_UNIVERSAL_LINK,
293-
data: { url: path },
290+
if (!options.config) {
291+
return fallbackPath;
292+
}
293+
294+
const clickResult = await sendUniversalLinkClick({
295+
apiKey: options.config.apiKey,
296+
appID: options.config.appID,
297+
url: url.toString(),
294298
});
295299

296-
if (!options.config) {
300+
if (!clickResult.allowed) {
301+
console.error("🔗[Detour:CLICK_LIMIT_ERROR] Native-intent routing blocked:", {
302+
path,
303+
error: clickResult.error,
304+
code: clickResult.code,
305+
clicksInPeriod: clickResult.clicksInPeriod,
306+
effectiveLimit: clickResult.effectiveLimit,
307+
});
297308
return fallbackPath;
298309
}
299310

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Platform } from "react-native";
2+
3+
import Constants from "expo-constants";
4+
import * as Device from "expo-device";
5+
6+
import { SDK_HEADER_VALUE } from "../../version";
7+
import type { RequiredConfig } from "../types";
8+
9+
const API_URL = "https://godetour.dev/api/link/universal-link-click";
10+
11+
type UniversalLinkClickResponseBody = {
12+
allowed?: boolean;
13+
error?: string;
14+
code?: string;
15+
clicksInPeriod?: number;
16+
effectiveLimit?: number;
17+
remainingClicks?: number;
18+
clickId?: string | null;
19+
};
20+
21+
export type UniversalLinkClickResult =
22+
| { allowed: true; clickId: string | null }
23+
| {
24+
allowed: false;
25+
error: string;
26+
code?: string;
27+
clicksInPeriod?: number;
28+
effectiveLimit?: number;
29+
};
30+
31+
type ClickPlatform = "ios" | "android" | "unknown";
32+
33+
const resolvePlatform = (): ClickPlatform =>
34+
Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "unknown";
35+
36+
const extractParams = (url: string): Record<string, string> | undefined => {
37+
try {
38+
const { searchParams } = new URL(url);
39+
const params: Record<string, string> = {};
40+
searchParams.forEach((value, key) => {
41+
params[key] = value;
42+
});
43+
return Object.keys(params).length > 0 ? params : undefined;
44+
} catch {
45+
return undefined;
46+
}
47+
};
48+
49+
const buildMetadata = (): Record<string, string> => {
50+
const raw: Record<string, string | null | undefined> = {
51+
os_version: Device.osVersion,
52+
app_version: Constants.nativeAppVersion,
53+
device_model: Device.modelName,
54+
};
55+
return Object.fromEntries(
56+
Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
57+
);
58+
};
59+
60+
export const sendUniversalLinkClick = async ({
61+
apiKey: API_KEY,
62+
appID,
63+
url,
64+
}: Pick<RequiredConfig, "apiKey" | "appID"> & {
65+
url: string;
66+
}): Promise<UniversalLinkClickResult> => {
67+
try {
68+
const params = extractParams(url);
69+
const metadata = buildMetadata();
70+
71+
const response = await fetch(API_URL, {
72+
method: "POST",
73+
headers: {
74+
"Content-Type": "application/json",
75+
Authorization: `Bearer ${API_KEY}`,
76+
"X-App-ID": appID,
77+
"X-SDK": SDK_HEADER_VALUE,
78+
},
79+
body: JSON.stringify({
80+
url,
81+
timestamp: Date.now(),
82+
platform: resolvePlatform(),
83+
...(params !== undefined && { params }),
84+
...(Object.keys(metadata).length > 0 && { metadata }),
85+
}),
86+
});
87+
88+
let body: UniversalLinkClickResponseBody | null = null;
89+
try {
90+
body = (await response.json()) as UniversalLinkClickResponseBody;
91+
} catch {
92+
body = null;
93+
}
94+
95+
const isExplicitDeny = body?.allowed === false || response.status === 402;
96+
if (isExplicitDeny) {
97+
return {
98+
allowed: false,
99+
error: body?.error ?? "Click limit exceeded",
100+
code: body?.code,
101+
clicksInPeriod: body?.clicksInPeriod,
102+
effectiveLimit: body?.effectiveLimit,
103+
};
104+
}
105+
106+
if (!response.ok) {
107+
// Fail-open for temporary backend/network issues so apps keep working.
108+
return { allowed: true, clickId: null };
109+
}
110+
111+
return { allowed: true, clickId: body?.clickId ?? null };
112+
} catch {
113+
// Fail-open on transport errors; limit enforcement only happens on explicit deny.
114+
return { allowed: true, clickId: null };
115+
}
116+
};

packages/react-native-detour/src/links/hooks/useDetour.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import { useCallback, useEffect, useState } from "react";
22

33
import { Linking } from "react-native";
44

5-
import { OPENED_VIA_UNIVERSAL_LINK } from "../../analytics/const/definedEvents";
6-
import { analyticsEmitter } from "../../analytics/utils/analyticsEmitter";
75
import { getDeferredLink } from "../api/getDeferredLink";
86
import { resolveShortLink } from "../api/resolveShortLink";
7+
import { sendUniversalLinkClick } from "../api/sendUniversalLinkClick";
98
import type { DetourContextType, DetourLink, LinkType, RequiredConfig } from "../types";
109
import { checkIsFirstEntrance, markFirstEntrance } from "../utils/appEntrance";
1110
import {
@@ -40,7 +39,15 @@ export const useDetour = ({
4039
}, []);
4140

4241
const resolveLink = useCallback(
43-
async (rawLink: string, typeOverride?: LinkType): Promise<DetourLink> => {
42+
async ({
43+
rawLink,
44+
typeOverride,
45+
skipClickLimitCheck,
46+
}: {
47+
rawLink: string;
48+
typeOverride?: LinkType;
49+
skipClickLimitCheck?: boolean;
50+
}): Promise<DetourLink> => {
4451
if (isInfrastructureUrl(rawLink)) {
4552
console.log("🔗[Detour] Ignored infrastructure URL:", rawLink);
4653
return null;
@@ -76,6 +83,20 @@ export const useDetour = ({
7683
const detectedType: LinkType = isWeb ? "verified" : "scheme";
7784
const type = typeOverride ?? detectedType;
7885

86+
if (!skipClickLimitCheck && isWeb && type !== "deferred") {
87+
const clickResult = await sendUniversalLinkClick({ apiKey, appID, url: rawLink });
88+
if (!clickResult.allowed) {
89+
console.error("🔗[Detour:CLICK_LIMIT_ERROR] Universal/App link blocked:", {
90+
url: rawLink,
91+
error: clickResult.error,
92+
code: clickResult.code,
93+
clicksInPeriod: clickResult.clicksInPeriod,
94+
effectiveLimit: clickResult.effectiveLimit,
95+
});
96+
return null;
97+
}
98+
}
99+
79100
if (isWeb) {
80101
const pathSegments = urlObj.pathname.split("/").filter(Boolean);
81102
const isSingleSegmentPath =
@@ -89,7 +110,7 @@ export const useDetour = ({
89110
url: rawLink,
90111
});
91112
if (resolved?.link) {
92-
return resolveLink(resolved.link);
113+
return resolveLink({ rawLink: resolved.link, skipClickLimitCheck: true });
93114
}
94115
console.log("🔗[Detour] Not resolved, using original URL");
95116
}
@@ -143,14 +164,8 @@ export const useDetour = ({
143164
}
144165

145166
const subscription = Linking.addEventListener("url", async ({ url }) => {
146-
const resolved = await resolveLink(url);
167+
const resolved = await resolveLink({ rawLink: url });
147168
if (resolved) {
148-
if (resolved.type !== "scheme") {
149-
analyticsEmitter.emit({
150-
eventName: OPENED_VIA_UNIVERSAL_LINK,
151-
data: { url },
152-
});
153-
}
154169
setLink(resolved);
155170
}
156171
});
@@ -174,14 +189,8 @@ export const useDetour = ({
174189
const initialUrl = await Linking.getInitialURL();
175190
if (initialUrl && !isInfrastructureUrl(initialUrl)) {
176191
await markFirstEntrance(storage);
177-
const resolved = await resolveLink(initialUrl);
192+
const resolved = await resolveLink({ rawLink: initialUrl });
178193
if (resolved) {
179-
if (resolved.type !== "scheme") {
180-
analyticsEmitter.emit({
181-
eventName: OPENED_VIA_UNIVERSAL_LINK,
182-
data: { url: initialUrl },
183-
});
184-
}
185194
setLink(resolved);
186195
}
187196
return;
@@ -201,7 +210,7 @@ export const useDetour = ({
201210
});
202211

203212
if (apiLink) {
204-
const resolved = await resolveLink(apiLink, "deferred");
213+
const resolved = await resolveLink({ rawLink: apiLink, typeOverride: "deferred" });
205214
if (resolved) setLink(resolved);
206215
}
207216
} catch (error) {

0 commit comments

Comments
 (0)