@harshankur/viewcounter 3.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable durations for configuration: "30m", "12h", "7d".
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small: whole numbers of minutes, hours, or days, nothing else,
|
|
5
|
+
* so a value like "30 min" or "1.5h" is refused rather than read as something
|
|
6
|
+
* the operator did not mean. For a session timeout that difference matters.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const UNIT_MS = { m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
|
|
10
|
+
const DURATION = /^(\d{1,6})([mhd])$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} raw e.g. "7d"
|
|
14
|
+
* @returns {number|null} milliseconds, or null when the value is not a duration
|
|
15
|
+
*/
|
|
16
|
+
function parseDuration(raw) {
|
|
17
|
+
const match = DURATION.exec(String(raw ?? '').trim().toLowerCase());
|
|
18
|
+
return match ? Number(match[1]) * UNIT_MS[match[2]] : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The largest whole unit that expresses `ms` exactly: 604800000 -> "7d".
|
|
23
|
+
* @param {number} ms
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function formatDuration(ms) {
|
|
27
|
+
for (const unit of ['d', 'h', 'm']) {
|
|
28
|
+
if (ms % UNIT_MS[unit] === 0) return `${ms / UNIT_MS[unit]}${unit}`;
|
|
29
|
+
}
|
|
30
|
+
return `${ms}ms`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { parseDuration, formatDuration };
|
package/utils/errorUtils.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* identified by an enum member, its message text lives in exactly one table
|
|
7
7
|
* here, and callers branch on `err.code` rather than parsing message strings.
|
|
8
8
|
*
|
|
9
|
-
* `getError` builds and reports but deliberately does NOT throw
|
|
9
|
+
* `getError` builds and reports but deliberately does NOT throw: the `throw`
|
|
10
10
|
* stays visible at the call site and under the caller's control.
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -23,6 +23,8 @@ const ErrorType = {
|
|
|
23
23
|
INVALID_APP_ID: 'INVALID_APP_ID',
|
|
24
24
|
SECRET_PERSIST_FAILED: 'SECRET_PERSIST_FAILED',
|
|
25
25
|
SECRET_UNAVAILABLE: 'SECRET_UNAVAILABLE',
|
|
26
|
+
MIGRATION_FAILED: 'MIGRATION_FAILED',
|
|
27
|
+
FIELD_NOT_WRITABLE: 'FIELD_NOT_WRITABLE',
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
/** Non-fatal conditions worth surfacing but not worth stopping for. */
|
|
@@ -36,6 +38,16 @@ const WarningType = {
|
|
|
36
38
|
API_KEY_EMPTY_SCOPE: 'API_KEY_EMPTY_SCOPE',
|
|
37
39
|
APP_ALREADY_REGISTERED: 'APP_ALREADY_REGISTERED',
|
|
38
40
|
FIELD_TRUNCATED: 'FIELD_TRUNCATED',
|
|
41
|
+
ADMIN_UI_DISABLED: 'ADMIN_UI_DISABLED',
|
|
42
|
+
ADMIN_PASSWORD_REUSED: 'ADMIN_PASSWORD_REUSED',
|
|
43
|
+
ADMIN_INSECURE_TRANSPORT: 'ADMIN_INSECURE_TRANSPORT',
|
|
44
|
+
ADMIN_ORIGIN_REJECTED: 'ADMIN_ORIGIN_REJECTED',
|
|
45
|
+
VIEW_LOG_PRUNE_FAILED: 'VIEW_LOG_PRUNE_FAILED',
|
|
46
|
+
MIGRATION_TABLE_MISSING: 'MIGRATION_TABLE_MISSING',
|
|
47
|
+
VIEW_LOG_WRITE_FAILED: 'VIEW_LOG_WRITE_FAILED',
|
|
48
|
+
TRACKING_LOG_WRITE_FAILED: 'TRACKING_LOG_WRITE_FAILED',
|
|
49
|
+
ADMIN_LOG_WRITE_FAILED: 'ADMIN_LOG_WRITE_FAILED',
|
|
50
|
+
TRASH_PURGE_FAILED: 'TRASH_PURGE_FAILED',
|
|
39
51
|
};
|
|
40
52
|
|
|
41
53
|
/**
|
|
@@ -62,6 +74,10 @@ const ERROR_MESSAGES = {
|
|
|
62
74
|
`Could not persist the visitor-hash secret to ${info?.path}. ` +
|
|
63
75
|
'Without a stable secret, visitor hashes are not reversible-resistant across restarts.',
|
|
64
76
|
[ErrorType.SECRET_UNAVAILABLE]: 'Visitor-hash secret has not been initialized',
|
|
77
|
+
[ErrorType.MIGRATION_FAILED]: (info) =>
|
|
78
|
+
`Schema migration failed for table '${info?.table}': ${info?.cause}`,
|
|
79
|
+
[ErrorType.FIELD_NOT_WRITABLE]: (info) =>
|
|
80
|
+
`Refusing to write column '${info?.column}': it is not an admin-editable field.`,
|
|
65
81
|
};
|
|
66
82
|
|
|
67
83
|
/** @type {Record<string, string | ((info: any) => string)>} */
|
|
@@ -86,6 +102,28 @@ const WARNING_MESSAGES = {
|
|
|
86
102
|
`App '${info?.appId}' is already registered; leaving it as-is.`,
|
|
87
103
|
[WarningType.FIELD_TRUNCATED]: (info) =>
|
|
88
104
|
`Field '${info?.field}' exceeded ${info?.max} characters and was truncated before storage.`,
|
|
105
|
+
[WarningType.ADMIN_UI_DISABLED]: 'ADMIN_PASSWORD is not set; the admin UI and its API are disabled.',
|
|
106
|
+
[WarningType.ADMIN_PASSWORD_REUSED]:
|
|
107
|
+
'ADMIN_PASSWORD is identical to a configured API key. Use an independent secret so ' +
|
|
108
|
+
'leaking one credential tier never unlocks another.',
|
|
109
|
+
[WarningType.ADMIN_INSECURE_TRANSPORT]:
|
|
110
|
+
'An admin login arrived over plain HTTP. The session cookie is not marked Secure on ' +
|
|
111
|
+
'such a request; serve the admin UI over HTTPS.',
|
|
112
|
+
[WarningType.ADMIN_ORIGIN_REJECTED]: (info) =>
|
|
113
|
+
`Refused an admin request from origin '${info?.presented}'; this server expected '${info?.expected}'. ` +
|
|
114
|
+
'Behind a TLS-terminating proxy, set TRUST_PROXY and have the proxy pass X-Forwarded-Proto and the original Host.',
|
|
115
|
+
[WarningType.MIGRATION_TABLE_MISSING]: (info) =>
|
|
116
|
+
`Table '${info?.table}' does not exist; skipping its schema migration.`,
|
|
117
|
+
[WarningType.TRACKING_LOG_WRITE_FAILED]: (info) =>
|
|
118
|
+
`Could not write counted tracking rejections: ${info?.cause}`,
|
|
119
|
+
[WarningType.VIEW_LOG_WRITE_FAILED]: (info) =>
|
|
120
|
+
`Could not write the view register log entry for '${info?.appId}': ${info?.cause}`,
|
|
121
|
+
[WarningType.ADMIN_LOG_WRITE_FAILED]: (info) =>
|
|
122
|
+
`Could not write the admin operation log entry '${info?.action}': ${info?.cause}`,
|
|
123
|
+
[WarningType.VIEW_LOG_PRUNE_FAILED]: (info) =>
|
|
124
|
+
`Automatic view log pruning failed: ${info?.cause}`,
|
|
125
|
+
[WarningType.TRASH_PURGE_FAILED]: (info) =>
|
|
126
|
+
`Automatic trash purge failed for '${info?.appId}': ${info?.cause}`,
|
|
89
127
|
};
|
|
90
128
|
|
|
91
129
|
/**
|
package/utils/geoCity.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional region and city for a view, from a city database the operator
|
|
3
|
+
* provides (GEOIP_CITY_DB, an .mmdb file).
|
|
4
|
+
*
|
|
5
|
+
* Off unless configured. The IP is looked up and discarded exactly as for the
|
|
6
|
+
* country: only the region and city names are kept, never the address or any
|
|
7
|
+
* coordinates. Both common free databases are understood, and each carries a
|
|
8
|
+
* licence that asks for a credit, which the admin UI shows:
|
|
9
|
+
* - DB-IP IP to City Lite (CC BY 4.0), https://db-ip.com/db/lite.php
|
|
10
|
+
* - MaxMind GeoLite2 City, https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
|
11
|
+
*
|
|
12
|
+
* The file is re-read when it is replaced, so a monthly update needs no restart.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const maxmind = require('maxmind');
|
|
16
|
+
|
|
17
|
+
const { FIELD_MAX_LENGTH } = require('../constants');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The country of every view comes from geoip-country, which bundles MaxMind's
|
|
21
|
+
* GeoLite2 Country data; its licence asks for this credit.
|
|
22
|
+
*/
|
|
23
|
+
const COUNTRY_ATTRIBUTION = Object.freeze({
|
|
24
|
+
text: 'This product includes GeoLite2 data created by MaxMind',
|
|
25
|
+
url: 'https://www.maxmind.com',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The credit each database's licence asks for, by its metadata type. */
|
|
29
|
+
const ATTRIBUTIONS = [
|
|
30
|
+
{ match: /dbip|db-ip/i, text: 'IP geolocation by DB-IP', url: 'https://db-ip.com' },
|
|
31
|
+
{ match: /geolite2|geoip2/i, text: 'This product includes GeoLite2 data created by MaxMind', url: 'https://www.maxmind.com' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const englishName = (record) => {
|
|
35
|
+
const name = record?.names?.en;
|
|
36
|
+
return typeof name === 'string' && name.trim() ? name.trim().slice(0, FIELD_MAX_LENGTH.CITY) : null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{ get: (ip: string) => object|null, metadata?: { databaseType?: string } }} reader an opened .mmdb reader
|
|
41
|
+
* @returns {{ lookup: (ip: string) => ({ region: string|null, city: string|null }), attribution: object|null, databaseType: string }}
|
|
42
|
+
*/
|
|
43
|
+
function createCityLookup(reader) {
|
|
44
|
+
const databaseType = String(reader.metadata?.databaseType || 'unknown');
|
|
45
|
+
const credit = ATTRIBUTIONS.find((candidate) => candidate.match.test(databaseType));
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
databaseType,
|
|
49
|
+
attribution: credit ? { text: credit.text, url: credit.url } : { text: `Location data: ${databaseType}`, url: null },
|
|
50
|
+
|
|
51
|
+
lookup(ip) {
|
|
52
|
+
let record = null;
|
|
53
|
+
try {
|
|
54
|
+
record = reader.get(ip);
|
|
55
|
+
} catch {
|
|
56
|
+
return { region: null, city: null };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
region: englishName(record?.subdivisions?.[0]),
|
|
60
|
+
city: englishName(record?.city),
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open the configured city database, or return null when none is configured.
|
|
68
|
+
* @param {string|undefined} file
|
|
69
|
+
* @returns {Promise<ReturnType<typeof createCityLookup>|null>}
|
|
70
|
+
*/
|
|
71
|
+
async function openCityLookup(file) {
|
|
72
|
+
if (!file) return null;
|
|
73
|
+
const reader = await maxmind.open(file, { watchForUpdates: true, watchForUpdatesNonPersistent: true });
|
|
74
|
+
return createCityLookup(reader);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every credit the location data in use asks for, each once.
|
|
79
|
+
* @param {{ attribution: object }|null} cityLookup
|
|
80
|
+
* @returns {{ text: string, url: string|null }[]}
|
|
81
|
+
*/
|
|
82
|
+
function attributions(cityLookup) {
|
|
83
|
+
const all = [COUNTRY_ATTRIBUTION, ...(cityLookup ? [cityLookup.attribution] : [])];
|
|
84
|
+
return all.filter((credit, index) => all.findIndex((other) => other.text === credit.text) === index);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { createCityLookup, openCityLookup, attributions, ATTRIBUTIONS, COUNTRY_ATTRIBUTION };
|
package/utils/ipUtils.js
CHANGED
|
@@ -29,7 +29,7 @@ function isValidIP(ip) {
|
|
|
29
29
|
* Deliberately reads ONLY `req.ip`, which Express derives according to the
|
|
30
30
|
* app's `trust proxy` setting. The previous implementation read `x-real-ip`
|
|
31
31
|
* and `x-forwarded-for` straight off the request, so any caller could name
|
|
32
|
-
* their own address
|
|
32
|
+
* their own address: forging geolocation, inflating unique-visitor counts,
|
|
33
33
|
* and rotating the rate-limiter key to bypass it entirely.
|
|
34
34
|
*
|
|
35
35
|
* If a reverse proxy in front of this service sets only `X-Real-IP`, configure
|
package/utils/privacyUtils.js
CHANGED
|
@@ -69,8 +69,8 @@ class PrivacyUtils {
|
|
|
69
69
|
* Generate the transient visitor identifier.
|
|
70
70
|
*
|
|
71
71
|
* This is a keyed HMAC, not a bare digest. Every non-secret input is
|
|
72
|
-
* public or guessable
|
|
73
|
-
* population, and IPv4 is only 2^32
|
|
72
|
+
* public or guessable (the date is known, user agents come from a small
|
|
73
|
+
* population, and IPv4 is only 2^32), so an unkeyed SHA-256 of them is
|
|
74
74
|
* reversible by exhaustive search in about an hour on one CPU core. The
|
|
75
75
|
* server secret is what makes that search infeasible; the window id is
|
|
76
76
|
* what stops hashes being linkable over time.
|
package/utils/referrerParser.js
CHANGED
|
@@ -7,12 +7,27 @@ const { truncate } = require('./stringUtils');
|
|
|
7
7
|
* Parse referrer URLs to extract domain and source type
|
|
8
8
|
*/
|
|
9
9
|
class ReferrerParser {
|
|
10
|
+
/**
|
|
11
|
+
* A referrer as it may be stored: without its query string or fragment,
|
|
12
|
+
* which can carry tokens, email addresses, or IDs from the page before
|
|
13
|
+
* (a password-reset link, say). The source is read from the whole URL,
|
|
14
|
+
* in memory, before this.
|
|
15
|
+
* @param {string} referrer
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
static withoutQuery(referrer) {
|
|
19
|
+
return String(referrer).split(/[?#]/, 1)[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
/**
|
|
11
23
|
* Parse referrer URL
|
|
12
|
-
* @param {string} referrer -
|
|
24
|
+
* @param {string} referrer - the referrer the page reported (never the request's Referer header)
|
|
25
|
+
* @param {string|null} [siteHostname] - the hostname of the page viewed; a
|
|
26
|
+
* referrer on the same site (www. or not) is internal navigation, which
|
|
27
|
+
* would otherwise count every click within a site as a referral from it
|
|
13
28
|
* @returns {object} Parsed referrer data
|
|
14
29
|
*/
|
|
15
|
-
static parse(referrer) {
|
|
30
|
+
static parse(referrer, siteHostname = null) {
|
|
16
31
|
if (!referrer || referrer === '') {
|
|
17
32
|
return {
|
|
18
33
|
referrer: null,
|
|
@@ -24,16 +39,19 @@ class ReferrerParser {
|
|
|
24
39
|
try {
|
|
25
40
|
const url = new URL(referrer);
|
|
26
41
|
const domain = url.hostname || null;
|
|
27
|
-
const
|
|
42
|
+
const sameSite = (host) => String(host || '').toLowerCase().replace(/^www\./, '');
|
|
43
|
+
const sourceType = domain && siteHostname && sameSite(domain) === sameSite(siteHostname)
|
|
44
|
+
? SOURCE_TYPE.INTERNAL
|
|
45
|
+
: this.getSourceType(domain, referrer);
|
|
28
46
|
|
|
29
47
|
return {
|
|
30
|
-
referrer: truncate(referrer, FIELD_MAX_LENGTH.REFERRER),
|
|
48
|
+
referrer: truncate(this.withoutQuery(referrer), FIELD_MAX_LENGTH.REFERRER),
|
|
31
49
|
referrerDomain: truncate(domain, FIELD_MAX_LENGTH.REFERRER_DOMAIN),
|
|
32
50
|
sourceType
|
|
33
51
|
};
|
|
34
52
|
} catch (error) {
|
|
35
53
|
return {
|
|
36
|
-
referrer: truncate(referrer, FIELD_MAX_LENGTH.REFERRER),
|
|
54
|
+
referrer: truncate(this.withoutQuery(referrer), FIELD_MAX_LENGTH.REFERRER),
|
|
37
55
|
referrerDomain: null,
|
|
38
56
|
sourceType: SOURCE_TYPE.UNKNOWN
|
|
39
57
|
};
|
package/utils/secretStore.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Persisted server secret for visitor hashing.
|
|
3
3
|
*
|
|
4
4
|
* agent-instructions SECURITY.md §1: never fall back to a weak, guessable
|
|
5
|
-
* default for a security-relevant value
|
|
5
|
+
* default for a security-relevant value: generate one cryptographically and
|
|
6
6
|
* persist it. This module is that generator.
|
|
7
7
|
*/
|
|
8
8
|
|
package/utils/userAgentParser.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
const UAParser = require('ua-parser-js');
|
|
2
|
+
const { isbot, isbotMatch } = require('isbot');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ua-parser-js 1.x is used because 2.x is licensed AGPL-3.0, which an MIT
|
|
6
|
+
* package cannot pass on to the people who install it. 1.x names a few things
|
|
7
|
+
* differently from the 2.x that recorded the data before 3.2, so its output is
|
|
8
|
+
* mapped to the 2.x names: otherwise one browser would split into two rows of
|
|
9
|
+
* every breakdown, depending on when a view was recorded.
|
|
10
|
+
*/
|
|
11
|
+
const OS_NAMES = { 'Mac OS': 'macOS', 'Chromium OS': 'Chrome OS' };
|
|
12
|
+
/** 2.x prefixes these with "Mobile " on phones (not on tablets). */
|
|
13
|
+
const MOBILE_PREFIXED_BROWSERS = new Set(['Chrome', 'Firefox']);
|
|
2
14
|
|
|
3
15
|
/**
|
|
4
16
|
* Parse user agent string to extract browser, OS, and device information
|
|
@@ -23,15 +35,52 @@ class UserAgentParser {
|
|
|
23
35
|
const parser = new UAParser(userAgent);
|
|
24
36
|
const result = parser.getResult();
|
|
25
37
|
|
|
38
|
+
// 2.x also recognises a bare "iPad" token that 1.x needs more of the string for.
|
|
39
|
+
const deviceType = result.device.type || (/\biPad\b/.test(userAgent) ? 'tablet' : undefined);
|
|
40
|
+
const onPhone = deviceType === 'mobile';
|
|
41
|
+
const browser = result.browser.name && onPhone && MOBILE_PREFIXED_BROWSERS.has(result.browser.name)
|
|
42
|
+
? `Mobile ${result.browser.name}`
|
|
43
|
+
: result.browser.name;
|
|
44
|
+
|
|
26
45
|
return {
|
|
27
|
-
browser:
|
|
46
|
+
browser: browser || null,
|
|
28
47
|
browserVersion: result.browser.version || null,
|
|
29
|
-
os: result.os.name || null,
|
|
48
|
+
os: OS_NAMES[result.os.name] || result.os.name || null,
|
|
30
49
|
osVersion: result.os.version || null,
|
|
31
|
-
deviceType: this.getDeviceType(
|
|
50
|
+
deviceType: this.getDeviceType(deviceType)
|
|
32
51
|
};
|
|
33
52
|
}
|
|
34
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Whether the user agent is a crawler, link previewer, headless browser, or
|
|
56
|
+
* command-line client rather than a person. Such requests are counted in
|
|
57
|
+
* the tracking log but never stored as views.
|
|
58
|
+
* @param {string} userAgent
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
static isBot(userAgent) {
|
|
62
|
+
return Boolean(userAgent) && isbot(userAgent);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A short name for the bot, for the tracking log: the part of the user
|
|
67
|
+
* agent that identified it, without its version ("Googlebot", "curl").
|
|
68
|
+
* @param {string} userAgent
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
static botName(userAgent) {
|
|
72
|
+
if (!userAgent) return '';
|
|
73
|
+
// Where bots conventionally name themselves: "compatible; Googlebot/2.1",
|
|
74
|
+
// Chrome's headless build, or a leading product token ("curl/8.4.0").
|
|
75
|
+
const compatible = /compatible;\s*([A-Za-z][\w.-]*)/i.exec(userAgent);
|
|
76
|
+
if (compatible) return compatible[1].slice(0, 64);
|
|
77
|
+
if (/HeadlessChrome/.test(userAgent)) return 'HeadlessChrome';
|
|
78
|
+
const product = /^([A-Za-z][\w.-]*)/.exec(userAgent);
|
|
79
|
+
if (product && product[1] !== 'Mozilla') return product[1].slice(0, 64);
|
|
80
|
+
const match = isbotMatch(userAgent);
|
|
81
|
+
return match ? match.trim().split(/[\s/;(]/)[0].slice(0, 64) : '';
|
|
82
|
+
}
|
|
83
|
+
|
|
35
84
|
/**
|
|
36
85
|
* Normalize device type
|
|
37
86
|
* @param {string} type - Device type from ua-parser-js
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coarse, identifier-free context about a view, read from what the request
|
|
3
|
+
* already carries. Each value is reduced to the least that answers an
|
|
4
|
+
* analytics question, so none of them narrows a visitor down on its own:
|
|
5
|
+
*
|
|
6
|
+
* - language: the primary subtag of the first Accept-Language entry ("en",
|
|
7
|
+
* never "en-GB;q=0.9,de;q=0.8", whose full list is a fingerprinting signal);
|
|
8
|
+
* - hostname: which of the app's registered sites was visited;
|
|
9
|
+
* - UTM tags: the five campaign parameters, and only those. The rest of a
|
|
10
|
+
* landing URL's query string can carry emails, tokens, or IDs, so the
|
|
11
|
+
* tracker never sends it and the server never reads it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { FIELD_MAX_LENGTH } = require('../constants');
|
|
15
|
+
|
|
16
|
+
/** The query parameters a campaign link carries, in the order they are shown. */
|
|
17
|
+
const UTM_PARAMETERS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
18
|
+
|
|
19
|
+
const LANGUAGE_SUBTAG = /^[a-z]{2,8}$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string|undefined} acceptLanguage the Accept-Language header
|
|
23
|
+
* @returns {string|null} e.g. "en"
|
|
24
|
+
*/
|
|
25
|
+
function primaryLanguage(acceptLanguage) {
|
|
26
|
+
if (typeof acceptLanguage !== 'string') return null;
|
|
27
|
+
const first = acceptLanguage.split(',')[0].split(';')[0].trim();
|
|
28
|
+
const subtag = first.split('-')[0].toLowerCase();
|
|
29
|
+
return LANGUAGE_SUBTAG.test(subtag) && subtag.length <= FIELD_MAX_LENGTH.LANGUAGE ? subtag : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string|null|undefined} origin e.g. "https://www.example.com"
|
|
34
|
+
* @returns {string|null} e.g. "www.example.com"
|
|
35
|
+
*/
|
|
36
|
+
function hostnameOf(origin) {
|
|
37
|
+
if (typeof origin !== 'string' || origin === '') return null;
|
|
38
|
+
try {
|
|
39
|
+
const hostname = new URL(origin).hostname.toLowerCase().replace(/\.$/, '');
|
|
40
|
+
return hostname && hostname.length <= FIELD_MAX_LENGTH.HOSTNAME ? hostname : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The UTM tags of a request, trimmed; absent or blank ones are null.
|
|
48
|
+
* Validation has already bounded each to FIELD_MAX_LENGTH.UTM.
|
|
49
|
+
*
|
|
50
|
+
* @param {Record<string, unknown>} params the query string or body
|
|
51
|
+
* @returns {{ utmSource: string|null, utmMedium: string|null, utmCampaign: string|null,
|
|
52
|
+
* utmTerm: string|null, utmContent: string|null }}
|
|
53
|
+
*/
|
|
54
|
+
function utmTags(params = {}) {
|
|
55
|
+
const read = (name) => {
|
|
56
|
+
const value = params[name];
|
|
57
|
+
if (typeof value !== 'string') return null;
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
return trimmed === '' ? null : trimmed.slice(0, FIELD_MAX_LENGTH.UTM);
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
utmSource: read('utm_source'),
|
|
63
|
+
utmMedium: read('utm_medium'),
|
|
64
|
+
utmCampaign: read('utm_campaign'),
|
|
65
|
+
utmTerm: read('utm_term'),
|
|
66
|
+
utmContent: read('utm_content'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { UTM_PARAMETERS, primaryLanguage, hostnameOf, utmTags };
|