Skip to content

Commit 0eec958

Browse files
authored
Merge pull request #7345 from Countly/ar2rsawseen/master2
Web Client Hint support
2 parents 3871788 + eba78b1 commit 0eec958

4 files changed

Lines changed: 567 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Version 25.03.xx
2+
Fixes:
3+
- [web] Use Client Hints
4+
15
## Version 25.03.37
26
Fixes:
37
- [core] Update home page download notification text

api/utils/common.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,9 +1308,13 @@ common.returnRaw = function(params, returnCode, body, heads) {
13081308
}
13091309
return;
13101310
}
1311-
const defaultHeaders = {};
1311+
const defaultHeaders = {
1312+
'Accept-CH': 'Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version, Sec-CH-UA-Model',
1313+
'Critical-CH': 'Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version',
1314+
'X-Countly': 'api'
1315+
};
13121316
//set provided in configuration headers
1313-
let headers = {};
1317+
let headers = { ...defaultHeaders };
13141318
if (heads) {
13151319
for (var i in heads) {
13161320
headers[i] = heads[i];
@@ -1356,7 +1360,10 @@ common.returnMessage = function(params, returnCode, message, heads, noResult = f
13561360
}
13571361
//set provided in configuration headers
13581362
const defaultHeaders = {
1359-
'Content-Type': 'application/json; charset=utf-8'
1363+
'Content-Type': 'application/json; charset=utf-8',
1364+
'Accept-CH': 'Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version, Sec-CH-UA-Model',
1365+
'Critical-CH': 'Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version',
1366+
'X-Countly': 'api'
13601367
};
13611368
let headers = { ...defaultHeaders };
13621369
var add_headers = (plugins.getConfig("security").api_additional_headers || "").replace(/\r\n|\r|\n/g, "\n").split("\n");
@@ -1431,7 +1438,10 @@ common.returnOutput = function(params, output, noescape, heads) {
14311438
}
14321439
//set provided in configuration headers
14331440
const defaultHeaders = {
1434-
'Content-Type': 'application/json; charset=utf-8'
1441+
'Content-Type': 'application/json; charset=utf-8',
1442+
'Accept-CH': 'Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version, Sec-CH-UA-Model',
1443+
'Critical-CH': 'Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version',
1444+
'X-Countly': 'api'
14351445
};
14361446
let headers = { ...defaultHeaders };
14371447
var add_headers = (plugins.getConfig("security").api_additional_headers || "").replace(/\r\n|\r|\n/g, "\n").split("\n");

plugins/web/api/api.js

Lines changed: 235 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,251 @@ var pluginOb = {},
22
parser = require('ua-parser-js'),
33
plugins = require('../../pluginManager.js');
44

5+
/**
6+
* Normalize client hints OS version to user-facing values where needed
7+
* @param {string} os - OS name
8+
* @param {string} version - OS version from client hints
9+
* @returns {string|null} normalized OS version
10+
*/
11+
function normalizeClientHintsOsVersion(os, version) {
12+
if (!version) {
13+
return version;
14+
}
15+
16+
if (os === 'Windows') {
17+
var versionParts = (version + '').split('.');
18+
var major = parseInt(versionParts[0], 10);
19+
var minor = parseInt(versionParts[1], 10);
20+
if (!isNaN(major)) {
21+
if (major >= 13) {
22+
return '11';
23+
}
24+
if (major >= 10) {
25+
return '10';
26+
}
27+
if (major === 6) {
28+
if (minor >= 3) {
29+
return '8.1';
30+
}
31+
if (minor === 2) {
32+
return '8';
33+
}
34+
if (minor === 1) {
35+
return '7';
36+
}
37+
if (minor === 0) {
38+
return 'Vista';
39+
}
40+
}
41+
if (major === 5) {
42+
if (minor >= 1) {
43+
return 'XP';
44+
}
45+
return '2000';
46+
}
47+
}
48+
}
49+
50+
return version;
51+
}
52+
53+
/**
54+
* Parse client hints headers to extract browser, OS, and device information
55+
* @param {object} headers - HTTP request headers
56+
* @returns {object} Parsed client hints data
57+
*/
58+
function parseClientHints(headers) {
59+
var hints = {
60+
browser: null,
61+
browserVersion: null,
62+
os: null,
63+
osVersion: null,
64+
mobile: null,
65+
device: null
66+
};
67+
68+
// Parse Sec-CH-UA header for browser info
69+
// Format: "Chromium";v="110", "Google Chrome";v="110", "Not=A?Brand";v="99"
70+
var secChUa = headers['sec-ch-ua'];
71+
if (secChUa) {
72+
var brands = secChUa.split(',').map(function(brand) {
73+
var match = brand.trim().match(/"([^"]+)";v="([^"]+)"/);
74+
if (match) {
75+
return { name: match[1], version: match[2] };
76+
}
77+
return null;
78+
}).filter(Boolean);
79+
80+
// Filter out placeholder brands
81+
var validBrands = brands.filter(function(brand) {
82+
return !brand.name.includes('Not') && !brand.name.includes('?');
83+
});
84+
85+
if (validBrands.length > 0) {
86+
// Prefer specific browser over Chromium
87+
var preferredBrand = validBrands.find(function(b) {
88+
return b.name !== 'Chromium';
89+
}) || validBrands[0];
90+
91+
hints.browser = preferredBrand.name;
92+
hints.browserVersion = preferredBrand.version;
93+
}
94+
}
95+
96+
// Parse full version if available.
97+
// `sec-ch-ua-full-version` is a single quoted version string (e.g. "120.0.6099.230").
98+
var secChUaFullVersion = headers['sec-ch-ua-full-version'];
99+
if (secChUaFullVersion && secChUaFullVersion.startsWith('"')) {
100+
var fullVersionMatch = secChUaFullVersion.match(/"([^"]+)"/);
101+
if (fullVersionMatch) {
102+
hints.browserVersion = fullVersionMatch[1];
103+
}
104+
}
105+
106+
// `sec-ch-ua-full-version-list` is a brand list (e.g. "Chromium";v="120", "Google Chrome";v="120").
107+
// Parse it like sec-ch-ua and pick the version for the preferred browser brand.
108+
var secChUaFullVersionList = headers['sec-ch-ua-full-version-list'];
109+
if (secChUaFullVersionList) {
110+
var fullVersionBrands = secChUaFullVersionList.split(',').map(function(brand) {
111+
var match = brand.trim().match(/"([^"]+)";v="([^"]+)"/);
112+
if (match) {
113+
return { name: match[1], version: match[2] };
114+
}
115+
return null;
116+
}).filter(Boolean).filter(function(brand) {
117+
return !brand.name.includes('Not') && !brand.name.includes('?');
118+
});
119+
120+
if (fullVersionBrands.length > 0) {
121+
var preferredFullVersionBrand = null;
122+
123+
if (hints.browser) {
124+
preferredFullVersionBrand = fullVersionBrands.find(function(b) {
125+
return b.name === hints.browser;
126+
});
127+
}
128+
129+
if (!preferredFullVersionBrand) {
130+
preferredFullVersionBrand = fullVersionBrands.find(function(b) {
131+
return b.name !== 'Chromium';
132+
}) || fullVersionBrands[0];
133+
}
134+
135+
hints.browserVersion = preferredFullVersionBrand.version;
136+
}
137+
}
138+
139+
// Parse platform (OS)
140+
var secChUaPlatform = headers['sec-ch-ua-platform'];
141+
if (secChUaPlatform) {
142+
hints.os = secChUaPlatform.replace(/"/g, '');
143+
144+
// Normalize platform names to match ua-parser-js format
145+
if (hints.os === 'macOS') {
146+
hints.os = 'Mac OS';
147+
}
148+
else if (hints.os === 'Windows') {
149+
hints.os = 'Windows';
150+
}
151+
else if (hints.os === 'Linux') {
152+
hints.os = 'Linux';
153+
}
154+
else if (hints.os === 'Chrome OS') {
155+
hints.os = 'Chrome OS';
156+
}
157+
}
158+
159+
// Parse platform version
160+
var secChUaPlatformVersion = headers['sec-ch-ua-platform-version'];
161+
if (secChUaPlatformVersion) {
162+
hints.osVersion = normalizeClientHintsOsVersion(hints.os, secChUaPlatformVersion.replace(/"/g, ''));
163+
}
164+
165+
// Parse mobile indicator
166+
var secChUaMobile = headers['sec-ch-ua-mobile'];
167+
if (secChUaMobile) {
168+
hints.mobile = secChUaMobile === '?1';
169+
}
170+
171+
// Parse device model
172+
var secChUaModel = headers['sec-ch-ua-model'];
173+
if (secChUaModel) {
174+
hints.device = secChUaModel.replace(/"/g, '');
175+
}
176+
177+
return hints;
178+
}
179+
5180
(function() {
6181
plugins.appTypes.push("web");
7182

8183
plugins.register("/sdk/pre", function(ob) {
9184
var params = ob.params;
10185

186+
// Parse client hints first (modern approach)
187+
var clientHints = parseClientHints(params.req.headers);
188+
189+
// Parse user agent as fallback
11190
var agent = parser((params.qstring.metrics && params.qstring.metrics._ua) ? params.qstring.metrics._ua : params.req.headers['user-agent']);
12-
var data = { os: agent.os.name, os_version: agent.os.version };
13191

192+
// Merge client hints with user agent data (client hints take priority)
193+
var data = {
194+
os: clientHints.os || agent.os.name,
195+
os_version: clientHints.osVersion || agent.os.version,
196+
browser: clientHints.browser || agent.browser.name,
197+
browser_version: clientHints.browserVersion || agent.browser.version,
198+
mobile: clientHints.mobile,
199+
device: clientHints.device
200+
};
201+
202+
// Normalize OS name
14203
if (data.os === "Mac OS") {
15204
data.os = "Mac";
16205
}
17-
else if (data.os === "iOS" || data.os === "Android") {
18-
if (agent.browser.name === "Firefox") {
19-
agent.browser.name = "Firefox Mobile";
206+
207+
// Detect mobile browsers based on OS and mobile flag
208+
var isMobile = data.mobile !== null ? data.mobile : (data.os === "iOS" || data.os === "Android");
209+
210+
if (isMobile || data.os === "iOS" || data.os === "Android") {
211+
if (data.browser === "Firefox") {
212+
data.browser = "Firefox Mobile";
20213
}
21-
else if (agent.browser.name === "Chrome") {
22-
agent.browser.name = "Chrome Mobile";
214+
else if (data.browser === "Chrome" || data.browser === "Google Chrome") {
215+
data.browser = "Chrome Mobile";
23216
}
24-
else if (agent.browser.name === "Edge") {
25-
agent.browser.name = "Edge Mobile";
217+
else if (data.browser === "Edge" || data.browser === "Microsoft Edge") {
218+
data.browser = "Edge Mobile";
26219
}
27220
}
28221

29-
if (agent.browser.name === "Edge") {
30-
if (agent.engine.name === "WebKit") {
31-
agent.browser.name = "Edge Chromium";
222+
// Detect Edge Chromium
223+
if (data.browser === "Edge" || data.browser === "Microsoft Edge" || agent.browser.name === "Edge") {
224+
if (agent.engine.name === "WebKit" || agent.engine.name === "Blink") {
225+
data.browser = "Edge Chromium";
32226
}
33227
}
34228

229+
// Normalize browser names from client hints
230+
if (data.browser === "Google Chrome") {
231+
data.browser = "Chrome";
232+
}
233+
else if (data.browser === "Microsoft Edge") {
234+
data.browser = "Edge";
235+
}
236+
35237
if (params.qstring.begin_session) {
36-
//try to add metrics based on user agent
238+
//try to add metrics based on user agent and client hints
37239
if (!params.qstring.metrics) {
38240
params.qstring.metrics = {};
39241
}
40242

41-
//if some metrics are not provided, parse them from user agent
243+
//if some metrics are not provided, parse them from client hints or user agent
42244
if (!params.qstring.metrics._browser) {
43-
params.qstring.metrics._browser = agent.browser.name;
245+
params.qstring.metrics._browser = data.browser;
44246
}
45247

46248
if (!params.qstring.metrics._browser_version) {
47-
params.qstring.metrics._browser_version = agent.browser.version;
249+
params.qstring.metrics._browser_version = data.browser_version;
48250
}
49251

50252
if (params.qstring.metrics._browser && params.qstring.metrics._browser_version && !params.qstring.metrics._browser_version.startsWith("[" + params.qstring.metrics._browser.toLowerCase() + "]_")) {
@@ -60,7 +262,11 @@ var pluginOb = {},
60262
}
61263

62264
if (!params.qstring.metrics._device) {
63-
if (typeof agent.device.model !== "undefined") {
265+
// Prioritize client hints device model
266+
if (data.device) {
267+
params.qstring.metrics._device = data.device;
268+
}
269+
else if (typeof agent.device.model !== "undefined") {
64270
params.qstring.metrics._device = agent.device.model;
65271
}
66272
else {
@@ -69,7 +275,13 @@ var pluginOb = {},
69275
}
70276

71277
if (!params.qstring.metrics._device_type) {
72-
params.qstring.metrics._device_type = agent.device.type;
278+
// Determine device type from client hints mobile flag or user agent
279+
if (data.mobile === true) {
280+
params.qstring.metrics._device_type = "mobile";
281+
}
282+
else {
283+
params.qstring.metrics._device_type = agent.device.type;
284+
}
73285

74286
//if still undefined and app is web then it must be desktop
75287
if (!params.qstring.metrics._device_type && params.app.type === "web") {
@@ -118,11 +330,15 @@ var pluginOb = {},
118330
}
119331

120332
if (!params.qstring.crash._browser) {
121-
params.qstring.crash._browser = agent.browser.name;
333+
params.qstring.crash._browser = data.browser;
122334
}
123335

124336
if (!params.qstring.crash._device) {
125-
if (typeof agent.device.model !== "undefined") {
337+
// Prioritize client hints device model
338+
if (data.device) {
339+
params.qstring.crash._device = data.device;
340+
}
341+
else if (typeof agent.device.model !== "undefined") {
126342
params.qstring.crash._device = agent.device.model;
127343
}
128344
else {

0 commit comments

Comments
 (0)