@harshankur/viewcounter 3.2.0 → 3.4.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 +3 -1
- package/README.md +80 -15
- package/constants.js +12 -1
- package/db/DatabaseManager.js +25 -7
- package/db/adminSchema.js +22 -1
- package/db/analysis.js +9 -4
- package/db/visitorSalt.js +137 -0
- package/index.js +5 -16
- package/middleware/validation.js +2 -0
- package/package.json +2 -2
- package/routes/analytics.js +42 -3
- package/tracker/tracker.js +117 -30
- package/utils/errorUtils.js +3 -0
- package/utils/privacyUtils.js +18 -3
package/.env.example
CHANGED
|
@@ -115,7 +115,9 @@ PORT=3030
|
|
|
115
115
|
LOG_LEVEL=info
|
|
116
116
|
|
|
117
117
|
# Rate limiting, applied per client IP across all endpoints. This is the
|
|
118
|
-
# single-abuser backstop.
|
|
118
|
+
# single-abuser backstop. Engagement reports (/engage, two a minute from each
|
|
119
|
+
# page being read) have a separate budget of the same size, here and per app,
|
|
120
|
+
# so they never use up the one page views depend on.
|
|
119
121
|
RATE_LIMIT_WINDOW_MS=60000
|
|
120
122
|
RATE_LIMIT_MAX=100
|
|
121
123
|
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://viewcounter.harshankur.com)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
|
|
6
|
-
[](TEST_REPORT.md)
|
|
7
7
|
[](https://www.npmjs.com/package/@harshankur/viewcounter)
|
|
8
8
|
[](https://www.npmjs.com/package/@harshankur/viewcounter#provenance)
|
|
9
9
|
|
|
@@ -12,13 +12,48 @@ A comprehensive Node.js/Express analytics server for tracking website views with
|
|
|
12
12
|
## 📖 Documentation
|
|
13
13
|
Visit our [Interactive Documentation](https://viewcounter.harshankur.com) for detailed API specifications, debugging tips, and integration guides.
|
|
14
14
|
|
|
15
|
-
## 🛡️ GDPR
|
|
16
|
-
**
|
|
15
|
+
## 🛡️ Built for GDPR Compliance, Privacy-First
|
|
16
|
+
**Built for GDPR compliance, by design.** This project is built from the ground up to respect user privacy and adhere to modern ethical standards:
|
|
17
17
|
- **Nothing on the visitor's device**: no cookie, no localStorage, no sessionStorage, and no identifier sent with a view, so the tracker needs no consent banner under the ePrivacy rules on device storage. (The optional admin UI signs its operator in with a session cookie; tracking never sets one.)
|
|
18
18
|
- **Data Sovereignty**: You own your data. Analytics never leave your private infrastructure.
|
|
19
|
-
- **Minimal Collection**: records what analytics needs, each in a form that does not identify a person. [What Gets Tracked?](#what-gets-tracked) lists every field, where it comes from, and how it is stored; the raw IP address, the user agent, and the query string are never stored.
|
|
19
|
+
- **Minimal Collection**: records what analytics needs, each in a form that does not identify a person directly. [What Gets Tracked?](#what-gets-tracked) lists every field, where it comes from, and how it is stored; the raw IP address, the user agent, and the query string are never stored.
|
|
20
20
|
- **Bots left out**: crawlers, link previewers, and automated browsers are recognised and never stored, only counted per minute by name.
|
|
21
21
|
|
|
22
|
+
### What is still yours to do
|
|
23
|
+
|
|
24
|
+
ViewCounter does its part, and that is what "built for GDPR compliance" means.
|
|
25
|
+
Compliance itself belongs to whoever runs it, and a few things no software can
|
|
26
|
+
do for you:
|
|
27
|
+
|
|
28
|
+
- **Say so.** Mention in your privacy notice that visits are counted, what is
|
|
29
|
+
recorded ([What Gets Tracked?](#what-gets-tracked)), and for how long.
|
|
30
|
+
- **Have a legal basis.** For plain visit counting this is normally legitimate
|
|
31
|
+
interest; that judgement is yours to make and record.
|
|
32
|
+
- **Keep personal details out.** ViewCounter stores what you send in custom
|
|
33
|
+
event data and the optional `sessionId` as given. Do not put names, email
|
|
34
|
+
addresses, or account IDs there.
|
|
35
|
+
- **Answer requests.** During its window (a day by default) a visitor hash is
|
|
36
|
+
pseudonymous: whoever holds both the server secret and that window's salt
|
|
37
|
+
could tie it to a known address and browser. A few minutes after the window
|
|
38
|
+
ends the salt is deleted and, as long as no copy of it survives (next point),
|
|
39
|
+
nobody can, you included. What remains is a masked address
|
|
40
|
+
and coarse details. Treat it as personal data all the same: a visitor may
|
|
41
|
+
ask what is held or ask for erasure, which the admin UI does
|
|
42
|
+
([Deleting, and GDPR](#deleting-and-gdpr)).
|
|
43
|
+
- **Keep no copies of the salts.** The `_visitor_salts` table holds the salts
|
|
44
|
+
of the windows now running and nothing else. A copy of an old salt, wherever
|
|
45
|
+
it is, keeps the one thing whose deletion makes old hashes untraceable:
|
|
46
|
+
- **Backups**: exclude the table
|
|
47
|
+
(`mysqldump --ignore-table=<database>._visitor_salts`). Restoring without
|
|
48
|
+
it is harmless: a new salt is made on the next view.
|
|
49
|
+
- **The binary log**: MySQL 8 has it on by default and keeps 30 days, salts
|
|
50
|
+
included. If you do not replicate, turn it off (`skip-log-bin`) or leave
|
|
51
|
+
this database out of it (`binlog-ignore-db`); otherwise shorten
|
|
52
|
+
`binlog_expire_logs_seconds` to what you can accept. MariaDB has it off
|
|
53
|
+
unless you turned it on.
|
|
54
|
+
|
|
55
|
+
This is a description of the software, not legal advice.
|
|
56
|
+
|
|
22
57
|
### 🔄 Data Privacy Lifecycle
|
|
23
58
|
```mermaid
|
|
24
59
|
graph LR
|
|
@@ -39,7 +74,8 @@ We believe in total transparency regarding your visitors' data:
|
|
|
39
74
|
2. **Immediate Masking**: Before being saved, the IP is masked (IPv4 last octet zeroed; IPv6 interface identifier zeroed).
|
|
40
75
|
3. **Keyed, Not Just Hashed**: The visitor identifier is an HMAC-SHA-256 keyed with a 32-byte server secret generated on first run and stored at mode `0600`. This matters: an *unkeyed* hash of an IP is reversible by exhausting the 2^32 IPv4 space, which takes about an hour on one CPU core. Without the secret, that search is infeasible.
|
|
41
76
|
4. **Rotating**: The hash also mixes in a time window (`UNIQUE_VISITOR_WINDOW_HOURS`), so the same visitor hashes differently after each window and their visits cannot be linked over time.
|
|
42
|
-
5. **
|
|
77
|
+
5. **Salted, and the salt is thrown away**: each window has its own random salt, kept in the `_visitor_salts` table only while the window lasts and deleted a few minutes after it ends, by a timer, whether or not anyone visits. From then on the hashes of that window cannot be recomputed from a known address and browser by anyone, the secret's holder included. The salt is useless without the server secret, which is never in the database.
|
|
78
|
+
6. **Automated Guards**: [`tests/privacyFailSafe.test.js`](tests/privacyFailSafe.test.js) asserts that no raw IP or User-Agent reaches either the bound parameters *or* the SQL text of any statement, and that the hash is genuinely keyed. CI runs it on every push, so a change that started storing raw IPs would fail the build.
|
|
43
79
|
|
|
44
80
|
## ✨ Features
|
|
45
81
|
|
|
@@ -220,8 +256,9 @@ every app together under **All apps**; the choice follows you between them.
|
|
|
220
256
|
bounce rate, visit duration, pages per visit, time on page, scroll depth),
|
|
221
257
|
each against the period before, with a sparkline; choose one to chart it
|
|
222
258
|
over time, or read every number per period as a table;
|
|
223
|
-
- **Right now**: visitors in the last few minutes,
|
|
224
|
-
|
|
259
|
+
- **Right now**: visitors in the last few minutes (by a new view, or by the
|
|
260
|
+
tracker's report from a page still being read), views per minute over the
|
|
261
|
+
last half hour, and the pages open, refreshed while you look;
|
|
225
262
|
- where visits come from (channels, referrers, referring pages, and every
|
|
226
263
|
campaign tag), pages (top, entry with bounce rate, exit, titles, sites),
|
|
227
264
|
locations (a world map, countries, regions, cities, languages), devices,
|
|
@@ -392,7 +429,13 @@ Content-Type: text/plain # or application/json
|
|
|
392
429
|
{"appId": "blog", "id": "<the id /registerView returned>", "ms": 42000, "scroll": 80}
|
|
393
430
|
```
|
|
394
431
|
How long the page was visible (`ms`, up to 6 hours) and how much of it had been
|
|
395
|
-
on screen (`scroll`, 0 to 100).
|
|
432
|
+
on screen (`scroll`, 0 to 100). `scroll` is optional: leave it out for a page
|
|
433
|
+
that fits its window, where there is nothing to scroll, and the view keeps no
|
|
434
|
+
depth and stays out of the scroll averages. The tracker script does this
|
|
435
|
+
itself. A later report can only raise either. Each
|
|
436
|
+
report also marks the view as seen just now, which keeps its visitor in the
|
|
437
|
+
admin's **Right now** for the next few minutes; the tracker script sends one
|
|
438
|
+
every half minute while the page is being read. A report
|
|
396
439
|
for a view that is unknown, trashed, or older than a day changes nothing and is
|
|
397
440
|
counted in the tracking log as refused. `text/plain` is accepted so
|
|
398
441
|
`navigator.sendBeacon` can deliver it as the page closes, without a CORS
|
|
@@ -618,7 +661,7 @@ returned by any API.
|
|
|
618
661
|
|-------|-----------|-----------|-----|
|
|
619
662
|
| **Timestamp** | Server | When the view was recorded | Everything over time |
|
|
620
663
|
| **Masked IP** | Request | IPv4 with the last octet zeroed, IPv6 with the interface identifier zeroed | Abuse investigation at network level, never a person |
|
|
621
|
-
| **Visitor hash** | IP and User-Agent, with a secret | HMAC-SHA-256, keyed with a server secret, rotating every window | Unique views, visitors, and visits; never returned |
|
|
664
|
+
| **Visitor hash** | IP and User-Agent, with a secret | HMAC-SHA-256, keyed with a server secret and the window's salt, rotating every window; the salt is deleted when the window ends | Unique views, visitors, and visits; never returned |
|
|
622
665
|
| **Country** | IP, looked up in memory | Two-letter code | Where visitors are |
|
|
623
666
|
| **Region, City** | IP, with an optional [city database](#location-data) | Names, such as Bavaria and Munich | Where visitors are, more finely |
|
|
624
667
|
| **Language** | `Accept-Language` | Primary subtag only, such as `de` (never `de-CH`, never a list) | Which languages to write in |
|
|
@@ -631,7 +674,7 @@ returned by any API.
|
|
|
631
674
|
| **Device Size** | `deviceSize` | small, medium, large | Layout decisions |
|
|
632
675
|
| **Browser, OS, and versions** | User-Agent, parsed in memory | Names and versions, such as Chrome 140 on macOS 15 | Compatibility |
|
|
633
676
|
| **Device Type** | User-Agent | desktop, mobile, tablet, tv, console, wearable | Compatibility |
|
|
634
|
-
| **Time on page, Scroll depth** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen | Whether pages are read |
|
|
677
|
+
| **Time on page, Scroll depth, Last seen** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen, empty for a page that fits its window; when the page last reported | Whether pages are read |
|
|
635
678
|
| **Event Type, Event Data** | `/event` | Type name; JSON up to 4 kB, as your site sends it | Custom events |
|
|
636
679
|
| **Session ID** | `sessionId` (optional) | As your site sends it | Your own grouping; the tracker never sends one |
|
|
637
680
|
|
|
@@ -662,7 +705,10 @@ This setting prevents counting the same visitor multiple times within a time win
|
|
|
662
705
|
- If no: it is stored as a unique view
|
|
663
706
|
|
|
664
707
|
The window is also how often the visitor hash rotates, so it bounds how long
|
|
665
|
-
the same person counts as one visitor.
|
|
708
|
+
the same person counts as one visitor. It is the lifetime of the window's salt
|
|
709
|
+
as well: the longer the window, the longer a hash stays traceable by whoever
|
|
710
|
+
holds the secret and the salt. A day is what privacy-first analytics commonly
|
|
711
|
+
uses; lengthen it knowingly.
|
|
666
712
|
|
|
667
713
|
**Examples:**
|
|
668
714
|
- `24` (default): the same visitor counts once per day
|
|
@@ -688,7 +734,7 @@ because a browser on your site must be able to reach them. Everything that
|
|
|
688
734
|
- ✅ **Authenticated, scoped read API**: every analytics endpoint requires `x-api-key`, compared in constant time, and each key is authorized against the specific `appId` requested. Fails closed when unconfigured.
|
|
689
735
|
- ✅ **Separate admin tier**: provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
|
|
690
736
|
- ✅ **Per-tenant rate limits**: an `appId`-keyed budget alongside the per-IP limit.
|
|
691
|
-
- ✅ **Keyed visitor hashing**: HMAC-SHA-256 with a persisted 32-byte server secret
|
|
737
|
+
- ✅ **Keyed visitor hashing**: HMAC-SHA-256 with a persisted 32-byte server secret and a per-window salt that is deleted when the window ends, so stored hashes are not reversible to an IP, and after their window not even recomputable.
|
|
692
738
|
- ✅ **SQL injection prevention**: every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
|
|
693
739
|
- ✅ **Explicit CORS allowlist**: no wildcard, and writes can be bound to registered origins per app.
|
|
694
740
|
- ✅ **Proxy-aware IP derivation**: client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
|
|
@@ -821,6 +867,15 @@ Two independent limits apply to writes:
|
|
|
821
867
|
everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
|
|
822
868
|
be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
|
|
823
869
|
|
|
870
|
+
Each limit is applied twice, as two separate budgets of that size: one for
|
|
871
|
+
engagement reports (`/engage`) and one for everything else. A page being read
|
|
872
|
+
reports every half minute, so each open, active tab costs two reports a minute;
|
|
873
|
+
on a shared budget, the readers behind one office address could have used it up
|
|
874
|
+
and had their page views refused. Apart, reports can only crowd out other
|
|
875
|
+
reports. With the defaults that is room for about 50 readers at once per
|
|
876
|
+
address and 500 per app; raise the limits if you expect more, or a reader's
|
|
877
|
+
time on page is only updated when their page is hidden or left.
|
|
878
|
+
|
|
824
879
|
### What is still yours to build
|
|
825
880
|
|
|
826
881
|
Tenancy here is data isolation and quota, not a billing system. There is no
|
|
@@ -943,7 +998,8 @@ One tag, anywhere in the page:
|
|
|
943
998
|
It records a view of each page, including page changes in single-page apps
|
|
944
999
|
(`history.pushState`, `replaceState`, and the back button, each referred by
|
|
945
1000
|
the page it left); how long each page was visible and how far it was
|
|
946
|
-
scrolled
|
|
1001
|
+
scrolled, reported when the page is hidden or left and every half minute
|
|
1002
|
+
while it is being read; clicks on links to other sites (the other site's hostname only);
|
|
947
1003
|
clicks on downloads (the file name only); and the landing URL's campaign tags.
|
|
948
1004
|
It stores nothing on the device and sends no identifier, and it skips
|
|
949
1005
|
automated browsers.
|
|
@@ -953,6 +1009,9 @@ automated browsers.
|
|
|
953
1009
|
| `data-app` | required | The app ID the views belong to |
|
|
954
1010
|
| `data-hosts` | every host | Only track on these hostnames, comma-separated, so development servers and previews stay out of the data |
|
|
955
1011
|
| `data-spa` | `true` | Treat history changes as page views |
|
|
1012
|
+
| `data-hash` | none | Fragment prefixes, comma-separated (`#docs/,#spec/`), that count as their own page, for pages that route by fragment. Any other fragment stays part of the same page. A matching fragment is stored whole as part of the page, so list only prefixes whose fragments carry nothing private. As a referrer, such a page is its path alone |
|
|
1013
|
+
| `data-heartbeat` | `true` | Report time on page every half minute while the page is visible and in use, not only when it is hidden or left. It keeps the visitor in the admin's **Right now** while they read one page, and saves the time of a tab the browser closes without warning. It stops after half an hour without any input |
|
|
1014
|
+
| `data-campaigns` | `true` | Send the landing URL's `utm_*` tags with the view |
|
|
956
1015
|
| `data-outbound` | `true` | Record clicks on links to other sites, as `outbound` events |
|
|
957
1016
|
| `data-downloads` | `true` | Record clicks on downloads (pdf, zip, dmg, docx, and so on), as `download` events |
|
|
958
1017
|
| `data-respect-dnt` | `false` | Send nothing when the browser's Do Not Track is on |
|
|
@@ -1030,10 +1089,10 @@ npm run test:watch
|
|
|
1030
1089
|
# Run tests and persist database for inspection
|
|
1031
1090
|
npm run test:persist
|
|
1032
1091
|
|
|
1033
|
-
#
|
|
1092
|
+
# The fast gate CI runs: lint and Jest with coverage, no browser, no report
|
|
1034
1093
|
npm run test:ci
|
|
1035
1094
|
|
|
1036
|
-
# Run only the admin UI tests in a real browser (Playwright)
|
|
1095
|
+
# Run only the admin UI and tracker tests in a real browser (Playwright)
|
|
1037
1096
|
npx playwright install chromium # once
|
|
1038
1097
|
npm run test:ui
|
|
1039
1098
|
```
|
|
@@ -1041,6 +1100,12 @@ npm run test:ui
|
|
|
1041
1100
|
`npm test` includes the Playwright suite, so run `npx playwright install
|
|
1042
1101
|
chromium` once before the first run.
|
|
1043
1102
|
|
|
1103
|
+
CI runs everything except the browser tests: lint, Jest with its coverage
|
|
1104
|
+
floor, the dependency audit, the tarball check, and the end-to-end run against
|
|
1105
|
+
a real MySQL. It does not download a browser, to save CI time, so the browser
|
|
1106
|
+
tests are a local step: run `npm run test:ui` whenever you change anything
|
|
1107
|
+
under `admin/` or `tracker/`, and the full `npm test` before a release.
|
|
1108
|
+
|
|
1044
1109
|
### Test Database
|
|
1045
1110
|
|
|
1046
1111
|
**Automatic Management:**
|
package/constants.js
CHANGED
|
@@ -97,7 +97,7 @@ const DATABASE = {
|
|
|
97
97
|
QUERY_TIMEOUT_MS: 5_000,
|
|
98
98
|
CONNECT_TIMEOUT_MS: 10_000,
|
|
99
99
|
DEFAULT_PORT: 3306,
|
|
100
|
-
SCHEMA_VERSION: '
|
|
100
|
+
SCHEMA_VERSION: 'schema_v7',
|
|
101
101
|
/** Rows given a public_id per statement when backfilling an old table. */
|
|
102
102
|
BACKFILL_BATCH_SIZE: 500,
|
|
103
103
|
};
|
|
@@ -123,6 +123,14 @@ const PRIVACY = {
|
|
|
123
123
|
/** Owner-only. The secret is what makes visitor hashes irreversible. */
|
|
124
124
|
SECRET_FILE_MODE: 0o600,
|
|
125
125
|
SECRET_FILENAME: '.visitor-secret',
|
|
126
|
+
/**
|
|
127
|
+
* How long after its window ended a visitor salt may still exist, so that
|
|
128
|
+
* instances whose clocks differ by a little agree on the window's salt
|
|
129
|
+
* right up to its end.
|
|
130
|
+
*/
|
|
131
|
+
SALT_GRACE_MS: 5 * 60 * 1000,
|
|
132
|
+
/** How often ended windows' salts are looked for and deleted. */
|
|
133
|
+
SALT_PRUNE_INTERVAL_MS: 5 * 60 * 1000,
|
|
126
134
|
/** Rejects a key short enough to be guessable. */
|
|
127
135
|
MIN_API_KEY_LENGTH: 32,
|
|
128
136
|
/**
|
|
@@ -371,6 +379,8 @@ const ADMIN_SESSIONS_TABLE = '_admin_sessions';
|
|
|
371
379
|
const VIEW_LOG_TABLE = '_view_log';
|
|
372
380
|
/** Tracking requests that were not stored, counted per minute. */
|
|
373
381
|
const TRACKING_REJECTIONS_TABLE = '_tracking_rejections';
|
|
382
|
+
/** The salt of the current visitor-hash window, deleted when the window ends. */
|
|
383
|
+
const VISITOR_SALTS_TABLE = '_visitor_salts';
|
|
374
384
|
|
|
375
385
|
/** The tracking pipeline's own bounds. */
|
|
376
386
|
const TRACKING = {
|
|
@@ -540,6 +550,7 @@ module.exports = {
|
|
|
540
550
|
ADMIN_SESSIONS_TABLE,
|
|
541
551
|
VIEW_LOG_TABLE,
|
|
542
552
|
TRACKING_REJECTIONS_TABLE,
|
|
553
|
+
VISITOR_SALTS_TABLE,
|
|
543
554
|
TRACKING,
|
|
544
555
|
TRACKING_OUTCOME,
|
|
545
556
|
REJECTION_REASON,
|
package/db/DatabaseManager.js
CHANGED
|
@@ -33,6 +33,7 @@ const {
|
|
|
33
33
|
} = require('./adminSchema');
|
|
34
34
|
const LogRepository = require('./LogRepository');
|
|
35
35
|
const AdminRepository = require('./AdminRepository');
|
|
36
|
+
const { createVisitorSaltStore } = require('./visitorSalt');
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Columns returned for a session lookup.
|
|
@@ -182,6 +183,7 @@ class DatabaseManager {
|
|
|
182
183
|
this.mode = config.mode || 'connect';
|
|
183
184
|
this.logs = new LogRepository(this);
|
|
184
185
|
this.admin = new AdminRepository(this);
|
|
186
|
+
this.visitorSalts = createVisitorSaltStore(() => this.pool);
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
/**
|
|
@@ -203,6 +205,10 @@ class DatabaseManager {
|
|
|
203
205
|
logger.info(`Assigned public IDs to ${result.backfilled} existing row(s) in '${appId}'`);
|
|
204
206
|
}
|
|
205
207
|
}
|
|
208
|
+
|
|
209
|
+
// From here on the salts of ended visitor-hash windows are deleted on
|
|
210
|
+
// the clock, whether or not anyone visits.
|
|
211
|
+
this.visitorSalts.start();
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
/** @throws {Error} when a query is attempted before initialize() */
|
|
@@ -457,12 +463,19 @@ class DatabaseManager {
|
|
|
457
463
|
|
|
458
464
|
// Privacy boundary. Neither the raw IP nor the raw User-Agent is bound
|
|
459
465
|
// into any statement below; only the masked address and the keyed,
|
|
460
|
-
// rotating hash derived from them.
|
|
466
|
+
// rotating hash derived from them. The hash is also salted with a
|
|
467
|
+
// value that is deleted when its window ends, so that afterwards not
|
|
468
|
+
// even the secret's holder can recompute it.
|
|
469
|
+
if (!visitorSecret) throw getError(ErrorType.SECRET_UNAVAILABLE);
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
const salt = await this.visitorSalts.current(uniqueWindowHours, now);
|
|
461
472
|
const hashedVisitor = PrivacyUtils.generateVisitorHash(
|
|
462
473
|
ip,
|
|
463
474
|
userAgent,
|
|
464
475
|
visitorSecret,
|
|
465
476
|
uniqueWindowHours,
|
|
477
|
+
now,
|
|
478
|
+
salt,
|
|
466
479
|
);
|
|
467
480
|
const maskedIp = PrivacyUtils.maskIP(ip);
|
|
468
481
|
|
|
@@ -548,14 +561,16 @@ class DatabaseManager {
|
|
|
548
561
|
/**
|
|
549
562
|
* Record how long a view's page was visible and how far it was scrolled.
|
|
550
563
|
*
|
|
551
|
-
* A page reports this when it is hidden or left,
|
|
552
|
-
*
|
|
553
|
-
*
|
|
564
|
+
* A page reports this when it is hidden or left, and every half minute
|
|
565
|
+
* while it is being read, each time with its running total, so the larger
|
|
566
|
+
* value always wins. Each report also marks the view as seen just now,
|
|
567
|
+
* which is what keeps its visitor in "right now" between page views. Only a live view from the last
|
|
554
568
|
* TRACKING.ENGAGE_WINDOW_HOURS is updated: an old or trashed view keeps
|
|
555
569
|
* what it had.
|
|
556
570
|
*
|
|
557
571
|
* @param {string} appId already validated
|
|
558
|
-
* @param {{ viewId: string, engagedMs: number, scrollDepth: number }} engagement
|
|
572
|
+
* @param {{ viewId: string, engagedMs: number, scrollDepth: number|null }} engagement
|
|
573
|
+
* `scrollDepth` is null for a page that did not scroll, which leaves the stored depth as it was
|
|
559
574
|
* @returns {Promise<boolean>} whether a view was updated
|
|
560
575
|
*/
|
|
561
576
|
async addEngagement(appId, { viewId, engagedMs, scrollDepth }) {
|
|
@@ -563,10 +578,12 @@ class DatabaseManager {
|
|
|
563
578
|
const [result] = await this.pool.query(
|
|
564
579
|
`UPDATE \`${appId}\`
|
|
565
580
|
SET engaged_ms = GREATEST(COALESCE(engaged_ms, 0), ?),
|
|
566
|
-
scroll_depth =
|
|
581
|
+
scroll_depth = CASE WHEN ? IS NULL THEN scroll_depth
|
|
582
|
+
ELSE GREATEST(COALESCE(scroll_depth, 0), ?) END,
|
|
583
|
+
last_seen_at = NOW()
|
|
567
584
|
WHERE public_id = ? AND ${LIVE_ROW}
|
|
568
585
|
AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
|
|
569
|
-
[engagedMs, scrollDepth, viewId, TRACKING.ENGAGE_WINDOW_HOURS]
|
|
586
|
+
[engagedMs, scrollDepth, scrollDepth, viewId, TRACKING.ENGAGE_WINDOW_HOURS]
|
|
570
587
|
);
|
|
571
588
|
return result.affectedRows > 0;
|
|
572
589
|
}
|
|
@@ -811,6 +828,7 @@ class DatabaseManager {
|
|
|
811
828
|
* Gracefully close all connections
|
|
812
829
|
*/
|
|
813
830
|
async close() {
|
|
831
|
+
this.visitorSalts.stop();
|
|
814
832
|
if (this.pool) {
|
|
815
833
|
await this.pool.end();
|
|
816
834
|
this.pool = null;
|
package/db/adminSchema.js
CHANGED
|
@@ -14,6 +14,7 @@ const {
|
|
|
14
14
|
DATABASE,
|
|
15
15
|
FIELD_MAX_LENGTH,
|
|
16
16
|
TRACKING_REJECTIONS_TABLE,
|
|
17
|
+
VISITOR_SALTS_TABLE,
|
|
17
18
|
VIEW_LOG_TABLE,
|
|
18
19
|
} = require('../constants');
|
|
19
20
|
const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
|
|
@@ -42,7 +43,9 @@ const ADMIN_COLUMNS = [
|
|
|
42
43
|
* Columns 3.2 added for richer, still identifier-free analysis: which of the
|
|
43
44
|
* app's sites and which language, the campaign tags of the landing URL, an
|
|
44
45
|
* optional region and city (only with a city database configured), and how
|
|
45
|
-
* long the page was visible and how far it was scrolled.
|
|
46
|
+
* long the page was visible and how far it was scrolled. 3.3 added
|
|
47
|
+
* `last_seen_at`: when the page last reported its engagement, which is how
|
|
48
|
+
* "right now" still counts a visitor who has been reading one page for a while.
|
|
46
49
|
*/
|
|
47
50
|
const TRACKING_COLUMNS = [
|
|
48
51
|
{ name: 'hostname', ddl: `VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL` },
|
|
@@ -56,13 +59,29 @@ const TRACKING_COLUMNS = [
|
|
|
56
59
|
{ name: 'city', ddl: `VARCHAR(${FIELD_MAX_LENGTH.CITY}) DEFAULT NULL` },
|
|
57
60
|
{ name: 'engaged_ms', ddl: 'INT UNSIGNED DEFAULT NULL' },
|
|
58
61
|
{ name: 'scroll_depth', ddl: 'TINYINT UNSIGNED DEFAULT NULL' },
|
|
62
|
+
{ name: 'last_seen_at', ddl: 'DATETIME DEFAULT NULL' },
|
|
59
63
|
];
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The salt of the current visitor-hash window (db/visitorSalt.js). One row at
|
|
67
|
+
* a time: the rows of windows that have ended are deleted, which is the point.
|
|
68
|
+
*/
|
|
69
|
+
const VISITOR_SALTS_DDL = `
|
|
70
|
+
CREATE TABLE IF NOT EXISTS \`${VISITOR_SALTS_TABLE}\` (
|
|
71
|
+
\`rotation_hours\` INT UNSIGNED NOT NULL,
|
|
72
|
+
\`window_id\` BIGINT UNSIGNED NOT NULL,
|
|
73
|
+
\`salt\` CHAR(64) NOT NULL,
|
|
74
|
+
\`created_at\` DATETIME NOT NULL,
|
|
75
|
+
PRIMARY KEY (\`rotation_hours\`, \`window_id\`)
|
|
76
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
77
|
+
`;
|
|
78
|
+
|
|
61
79
|
/** Indexes the admin columns need, keyed by index name. */
|
|
62
80
|
const ADMIN_INDEXES = {
|
|
63
81
|
uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
|
|
64
82
|
idx_deleted_at: 'INDEX `idx_deleted_at` (`deleted_at`)',
|
|
65
83
|
idx_admin_modified_at: 'INDEX `idx_admin_modified_at` (`admin_modified_at`)',
|
|
84
|
+
idx_last_seen_at: 'INDEX `idx_last_seen_at` (`last_seen_at`)',
|
|
66
85
|
};
|
|
67
86
|
|
|
68
87
|
/**
|
|
@@ -303,6 +322,7 @@ async function ensureLogTables(pool) {
|
|
|
303
322
|
await pool.query(VIEW_LOG_DDL);
|
|
304
323
|
await pool.query(TRACKING_REJECTIONS_DDL);
|
|
305
324
|
await pool.query(ADMIN_SESSIONS_DDL);
|
|
325
|
+
await pool.query(VISITOR_SALTS_DDL);
|
|
306
326
|
|
|
307
327
|
const viewLogColumns = await readColumns(pool, VIEW_LOG_TABLE);
|
|
308
328
|
for (const column of VIEW_LOG_ADDED_COLUMNS) {
|
|
@@ -321,6 +341,7 @@ module.exports = {
|
|
|
321
341
|
VIEW_LOG_ADDED_COLUMNS,
|
|
322
342
|
TRACKING_REJECTIONS_DDL,
|
|
323
343
|
ADMIN_SESSIONS_DDL,
|
|
344
|
+
VISITOR_SALTS_DDL,
|
|
324
345
|
NEW_TABLE_ADMIN_COLUMNS,
|
|
325
346
|
NEW_TABLE_ADMIN_INDEXES,
|
|
326
347
|
backfillPublicIds,
|
package/db/analysis.js
CHANGED
|
@@ -436,12 +436,17 @@ function tallyEventProperties(rows) {
|
|
|
436
436
|
* @param {string[]} appIds
|
|
437
437
|
*/
|
|
438
438
|
async function runRealtime(pool, table, appIds) {
|
|
439
|
-
const
|
|
439
|
+
const within = (column, minutes) => `${column} >= DATE_SUB(NOW(), INTERVAL ${minutes} MINUTE)`;
|
|
440
|
+
// A visitor is here now when a view of theirs was recorded, or last
|
|
441
|
+
// reported its engagement, in the last few minutes: someone reading one
|
|
442
|
+
// page for a quarter of an hour sends no new view, only those reports.
|
|
443
|
+
const recent = `(${within('timestamp', ANALYSIS.REALTIME_VISITOR_MINUTES)} OR ${within('last_seen_at', ANALYSIS.REALTIME_VISITOR_MINUTES)})`;
|
|
444
|
+
const charted = within('timestamp', ANALYSIS.REALTIME_CHART_MINUTES);
|
|
445
|
+
const branches = appIds.map((appId) => `SELECT ? AS app_id, visitor_hash, page_path, event_type, timestamp, last_seen_at
|
|
440
446
|
FROM ${table(appId)}
|
|
441
|
-
WHERE deleted_at IS NULL AND
|
|
447
|
+
WHERE deleted_at IS NULL AND (${charted} OR ${within('last_seen_at', ANALYSIS.REALTIME_VISITOR_MINUTES)})`);
|
|
442
448
|
const cte = `WITH v AS (${branches.join(' UNION ALL ')})`;
|
|
443
449
|
const params = [...appIds];
|
|
444
|
-
const recent = `timestamp >= DATE_SUB(NOW(), INTERVAL ${ANALYSIS.REALTIME_VISITOR_MINUTES} MINUTE)`;
|
|
445
450
|
|
|
446
451
|
const [[summary = {}]] = await pool.query(`${cte}
|
|
447
452
|
SELECT COUNT(DISTINCT CASE WHEN ${recent} THEN visitor_hash END) AS visitors,
|
|
@@ -449,7 +454,7 @@ async function runRealtime(pool, table, appIds) {
|
|
|
449
454
|
FROM v`, params);
|
|
450
455
|
const [minutes] = await pool.query(`${cte}
|
|
451
456
|
SELECT FLOOR(UNIX_TIMESTAMP(timestamp) / 60) AS minute, COUNT(*) AS views
|
|
452
|
-
FROM v GROUP BY minute ORDER BY minute`, params);
|
|
457
|
+
FROM v WHERE ${charted} GROUP BY minute ORDER BY minute`, params);
|
|
453
458
|
const [pages] = await pool.query(`${cte}
|
|
454
459
|
SELECT app_id, page_path AS page, COUNT(DISTINCT visitor_hash) AS visitors
|
|
455
460
|
FROM v WHERE ${recent} AND event_type = ${PAGEVIEW}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The salt of each visitor-hash window.
|
|
3
|
+
*
|
|
4
|
+
* A visitor hash is keyed with the server secret and with a random salt that
|
|
5
|
+
* exists only for the window it belongs to. Once the window is over the salt
|
|
6
|
+
* is deleted, and from then on nobody, the operator included, can recompute a
|
|
7
|
+
* hash of that window from a known address and browser: the records it left
|
|
8
|
+
* can no longer be tied to anyone.
|
|
9
|
+
*
|
|
10
|
+
* The salt lives in the database so that a restart, or a second instance,
|
|
11
|
+
* within the window keeps telling the same visitors apart. It is useless
|
|
12
|
+
* without the server secret, which is never in the database.
|
|
13
|
+
*
|
|
14
|
+
* Deletion goes by the clock, not by traffic: a salt is removed a few minutes
|
|
15
|
+
* after its window ended (the grace covers instances whose clocks differ),
|
|
16
|
+
* by a timer and by every read, whether or not another view ever arrives.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const { PRIVACY, VISITOR_SALTS_TABLE } = require('../constants');
|
|
22
|
+
const PrivacyUtils = require('../utils/privacyUtils');
|
|
23
|
+
const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
|
|
24
|
+
|
|
25
|
+
const TABLE = `\`${VISITOR_SALTS_TABLE}\``;
|
|
26
|
+
const SALT_PATTERN = new RegExp(`^[0-9a-f]{${PRIVACY.SECRET_BYTES * 2}}$`);
|
|
27
|
+
const MS_PER_HOUR = 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** When the window `windowId` of length `hours` is over, in epoch millis. */
|
|
30
|
+
const windowEnd = (hours, windowId) => (windowId + 1) * hours * MS_PER_HOUR;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {() => { query: Function }} getPool the manager's pool, read on each use
|
|
34
|
+
* @returns {{
|
|
35
|
+
* current: (rotationHours: number, now?: number) => Promise<string>,
|
|
36
|
+
* prune: (now?: number) => Promise<number>,
|
|
37
|
+
* start: (intervalMs?: number) => void,
|
|
38
|
+
* stop: () => void,
|
|
39
|
+
* }}
|
|
40
|
+
*/
|
|
41
|
+
function createVisitorSaltStore(getPool) {
|
|
42
|
+
/**
|
|
43
|
+
* The salts in use, and the reads under way, by `hours:windowId`. The
|
|
44
|
+
* service hashes page views and custom events over different window
|
|
45
|
+
* lengths, so there is one entry per length in use: a handful at most.
|
|
46
|
+
* @type {Map<string, { salt?: string, promise?: Promise<string>, end: number }>}
|
|
47
|
+
*/
|
|
48
|
+
const windows = new Map();
|
|
49
|
+
let timer = null;
|
|
50
|
+
|
|
51
|
+
/** Forget, in memory too, every window that is over. */
|
|
52
|
+
function forgetEnded(now) {
|
|
53
|
+
for (const [key, entry] of windows) {
|
|
54
|
+
if (entry.end + PRIVACY.SALT_GRACE_MS <= now) windows.delete(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Delete the salt of every window that ended more than the grace ago.
|
|
60
|
+
* @param {number} [now] epoch millis
|
|
61
|
+
* @returns {Promise<number>} salts deleted
|
|
62
|
+
*/
|
|
63
|
+
async function prune(now = Date.now()) {
|
|
64
|
+
forgetEnded(now);
|
|
65
|
+
const [result] = await getPool().query(
|
|
66
|
+
`DELETE FROM ${TABLE} WHERE (window_id + 1) * rotation_hours * ${MS_PER_HOUR} + ? <= ?`,
|
|
67
|
+
[PRIVACY.SALT_GRACE_MS, now]);
|
|
68
|
+
return (result && result.affectedRows) || 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function read(hours, windowId, now) {
|
|
72
|
+
const pool = getPool();
|
|
73
|
+
// Whoever gets there first decides the window's salt; everyone reads that one.
|
|
74
|
+
await pool.query(
|
|
75
|
+
`INSERT IGNORE INTO ${TABLE} (rotation_hours, window_id, salt, created_at) VALUES (?, ?, ?, NOW())`,
|
|
76
|
+
[hours, windowId, crypto.randomBytes(PRIVACY.SECRET_BYTES).toString('hex')]);
|
|
77
|
+
const [rows] = await pool.query(
|
|
78
|
+
`SELECT salt FROM ${TABLE} WHERE rotation_hours = ? AND window_id = ?`, [hours, windowId]);
|
|
79
|
+
const salt = Array.isArray(rows) && rows[0] ? rows[0].salt : null;
|
|
80
|
+
// Failing closed is deliberate: a hash made without the salt would
|
|
81
|
+
// stay recomputable for ever while looking like the others.
|
|
82
|
+
if (typeof salt !== 'string' || !SALT_PATTERN.test(salt)) throw getError(ErrorType.SECRET_UNAVAILABLE);
|
|
83
|
+
// A new window is the moment the one before it ended.
|
|
84
|
+
await prune(now);
|
|
85
|
+
return salt;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
/**
|
|
90
|
+
* @param {number} rotationHours the unique-visitor window
|
|
91
|
+
* @param {number} [now] epoch millis; pass the same value to the hash
|
|
92
|
+
* @returns {Promise<string>} hex salt of the window `now` falls in
|
|
93
|
+
*/
|
|
94
|
+
async current(rotationHours, now = Date.now()) {
|
|
95
|
+
const hours = PrivacyUtils.rotationHours(rotationHours);
|
|
96
|
+
const windowId = PrivacyUtils.currentWindowId(hours, now);
|
|
97
|
+
const key = `${hours}:${windowId}`;
|
|
98
|
+
const known = windows.get(key);
|
|
99
|
+
if (known) return known.salt || known.promise;
|
|
100
|
+
|
|
101
|
+
const entry = { end: windowEnd(hours, windowId) };
|
|
102
|
+
entry.promise = read(hours, windowId, now).then((salt) => {
|
|
103
|
+
entry.salt = salt;
|
|
104
|
+
return salt;
|
|
105
|
+
}, (error) => {
|
|
106
|
+
// Nothing is remembered of a failed read: the next view tries again.
|
|
107
|
+
if (windows.get(key) === entry) windows.delete(key);
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
windows.set(key, entry);
|
|
111
|
+
return entry.promise;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
prune,
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Prune now and then on an interval, so a salt goes when its window
|
|
118
|
+
* ends even on a site nobody visits for days. The timer is unref'd:
|
|
119
|
+
* it never keeps the process alive on its own.
|
|
120
|
+
* @param {number} [intervalMs]
|
|
121
|
+
*/
|
|
122
|
+
start(intervalMs = PRIVACY.SALT_PRUNE_INTERVAL_MS) {
|
|
123
|
+
if (timer) return;
|
|
124
|
+
const run = () => prune().catch((cause) => logWarning(WarningType.SALT_PRUNE_FAILED, { cause: cause.message }));
|
|
125
|
+
run();
|
|
126
|
+
timer = setInterval(run, intervalMs);
|
|
127
|
+
timer.unref();
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
stop() {
|
|
131
|
+
clearInterval(timer);
|
|
132
|
+
timer = null;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { createVisitorSaltStore };
|
package/index.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const cors = require('cors');
|
|
3
3
|
const helmet = require('helmet');
|
|
4
|
-
const rateLimit = require('express-rate-limit');
|
|
5
4
|
|
|
6
5
|
const { ADMIN, APP_NAME, PAYLOAD_LIMITS, REJECTION_REASON, SERVER } = require('./constants');
|
|
7
6
|
const config = require('./config');
|
|
8
7
|
const DatabaseManager = require('./db/DatabaseManager');
|
|
9
8
|
const logger = require('./utils/logger');
|
|
10
9
|
const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
|
|
11
|
-
const { createAnalyticsRouter, trackingSourceFor } = require('./routes/analytics');
|
|
10
|
+
const { createAnalyticsRouter, buildPerIpLimiters, trackingSourceFor } = require('./routes/analytics');
|
|
12
11
|
const { createAdminRouter } = require('./routes/admin');
|
|
13
12
|
const { startRetention } = require('./db/retention');
|
|
14
13
|
const { createDbSessionStore } = require('./db/adminSessionStore');
|
|
@@ -79,20 +78,10 @@ function createApp() {
|
|
|
79
78
|
// endpoint taking a body and its payload is small.
|
|
80
79
|
app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
standardHeaders: true,
|
|
87
|
-
legacyHeaders: false,
|
|
88
|
-
// A tracking request turned away here is counted in the tracking log
|
|
89
|
-
// like any other refusal (in memory, written in batches).
|
|
90
|
-
handler: (req, res, next, options) => {
|
|
91
|
-
if (trackingSourceFor(req.path)) {
|
|
92
|
-
router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
|
|
93
|
-
}
|
|
94
|
-
res.status(options.statusCode).json(options.message);
|
|
95
|
-
},
|
|
81
|
+
// A tracking request turned away here is counted in the tracking log like
|
|
82
|
+
// any other refusal (in memory, written in batches).
|
|
83
|
+
app.use(buildPerIpLimiters(config.server.rateLimit, (req) => {
|
|
84
|
+
if (trackingSourceFor(req.path)) router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
|
|
96
85
|
}));
|
|
97
86
|
|
|
98
87
|
app.use(router);
|
package/middleware/validation.js
CHANGED
|
@@ -103,7 +103,9 @@ const validateEngage = (allowedValues) => [
|
|
|
103
103
|
.matches(UUID_PATTERN).withMessage('id must be a view ID'),
|
|
104
104
|
body('ms')
|
|
105
105
|
.isInt({ min: 0, max: TRACKING.MAX_ENGAGED_MS }).withMessage(`ms must be an integer between 0 and ${TRACKING.MAX_ENGAGED_MS}`),
|
|
106
|
+
// Absent for a page that fits its window: there was nothing to scroll.
|
|
106
107
|
body('scroll')
|
|
108
|
+
.optional({ values: 'null' })
|
|
107
109
|
.isInt({ min: 0, max: 100 }).withMessage('scroll must be an integer between 0 and 100'),
|
|
108
110
|
];
|
|
109
111
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harshankur/viewcounter",
|
|
3
3
|
"description": "A middleware backend server that registers views to my db server when requested to register a view from my other projects.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.4.0",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"i18n-iso-countries": "^7.14.0",
|
|
50
50
|
"jest": "^30.4.2",
|
|
51
51
|
"jest-html-reporter": "^4.4.0",
|
|
52
|
-
"supertest": "^7.
|
|
52
|
+
"supertest": "^7.3.1",
|
|
53
53
|
"topojson-client": "^3.1.0",
|
|
54
54
|
"world-atlas": "^2.0.2"
|
|
55
55
|
},
|
package/routes/analytics.js
CHANGED
|
@@ -155,6 +155,40 @@ function buildPerAppLimiter(rateLimitConfig, onLimit = () => {}) {
|
|
|
155
155
|
});
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** Whether a request is an engagement report, the one tracking request a page repeats. */
|
|
159
|
+
const isEngagement = (req) => trackingSourceFor(req.path) === VIEW_LOG_SOURCE.ENGAGE;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The per-IP limiters: one budget for engagement reports, one for everything
|
|
163
|
+
* else, each of `max` requests per window.
|
|
164
|
+
*
|
|
165
|
+
* They are separate because a page being read reports its engagement every
|
|
166
|
+
* half minute. On one shared budget, a few dozen readers behind one address
|
|
167
|
+
* (an office, a campus) would use it up with those reports alone, and the page
|
|
168
|
+
* views of everyone at that address would be refused. Apart, reports can only
|
|
169
|
+
* ever crowd out other reports.
|
|
170
|
+
*
|
|
171
|
+
* @param {{ max: number, windowMs: number }} rateLimitConfig
|
|
172
|
+
* @param {(req: import('express').Request) => void} [onLimit] called for a refused request
|
|
173
|
+
* @returns {import('express').RequestHandler[]}
|
|
174
|
+
*/
|
|
175
|
+
function buildPerIpLimiters(rateLimitConfig, onLimit = () => {}) {
|
|
176
|
+
const { max, windowMs } = rateLimitConfig || {};
|
|
177
|
+
const limiter = (skip) => rateLimit({
|
|
178
|
+
windowMs,
|
|
179
|
+
limit: max,
|
|
180
|
+
message: { message: 'Too many requests, please try again later.' },
|
|
181
|
+
standardHeaders: true,
|
|
182
|
+
legacyHeaders: false,
|
|
183
|
+
skip,
|
|
184
|
+
handler: (req, res, next, options) => {
|
|
185
|
+
onLimit(req);
|
|
186
|
+
res.status(options.statusCode).json(options.message);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return [limiter(isEngagement), limiter((req) => !isEngagement(req))];
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
/**
|
|
159
193
|
* Attach a request id used for correlating a client-visible error with the
|
|
160
194
|
* server-side log line that has the real detail.
|
|
@@ -232,6 +266,11 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
232
266
|
});
|
|
233
267
|
const limitPerApp = buildPerAppLimiter(config.server?.rateLimit,
|
|
234
268
|
(req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'app' }));
|
|
269
|
+
// Engagement reports draw on a per-app budget of their own, for the reason
|
|
270
|
+
// buildPerIpLimiters gives: an app with many readers must not have its
|
|
271
|
+
// views refused because of the reports those readers' pages send.
|
|
272
|
+
const limitEngagePerApp = buildPerAppLimiter(config.server?.rateLimit,
|
|
273
|
+
(req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'app' }));
|
|
235
274
|
const trackingValidation = handleTrackingValidation(reject);
|
|
236
275
|
|
|
237
276
|
/** Views from these are counted in the tracking log and never stored. */
|
|
@@ -429,7 +468,7 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
429
468
|
router.post('/engage',
|
|
430
469
|
express.text({ type: () => true, limit: TRACKING.ENGAGE_BODY_BYTES }),
|
|
431
470
|
parseBeaconBody,
|
|
432
|
-
|
|
471
|
+
limitEngagePerApp,
|
|
433
472
|
requireOrigin,
|
|
434
473
|
validateEngage(config.allowed),
|
|
435
474
|
trackingValidation,
|
|
@@ -441,7 +480,7 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
441
480
|
const updated = await dbManager.addEngagement(appId, {
|
|
442
481
|
viewId: id,
|
|
443
482
|
engagedMs: Number(ms),
|
|
444
|
-
scrollDepth: Number(scroll),
|
|
483
|
+
scrollDepth: scroll === undefined || scroll === null ? null : Number(scroll),
|
|
445
484
|
});
|
|
446
485
|
if (!updated) reject(req, REJECTION_REASON.UNKNOWN_VIEW);
|
|
447
486
|
return res.status(HTTP_STATUS.NO_CONTENT).end();
|
|
@@ -641,4 +680,4 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
641
680
|
return router;
|
|
642
681
|
}
|
|
643
682
|
|
|
644
|
-
module.exports = { createAnalyticsRouter, handleRouteError, logContext, withRequestId, trackingSourceFor };
|
|
683
|
+
module.exports = { createAnalyticsRouter, buildPerIpLimiters, handleRouteError, logContext, withRequestId, trackingSourceFor };
|
package/tracker/tracker.js
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* viewcounter tracker
|
|
2
|
+
* viewcounter tracker
|
|
3
|
+
*
|
|
4
|
+
* If you found this on a site you were visiting: this is viewcounter, a
|
|
5
|
+
* privacy-first view counter that the site runs on its own server. It is built
|
|
6
|
+
* for GDPR compliance:
|
|
7
|
+
* - it sets no cookie and stores nothing on your device;
|
|
8
|
+
* - it sends nothing that identifies you or your device;
|
|
9
|
+
* - the server never stores your IP address or your browser's user agent.
|
|
10
|
+
* It keeps a masked address, a visitor hash that changes every day by
|
|
11
|
+
* default, and coarse details such as your country, browser and system,
|
|
12
|
+
* so nothing it keeps identifies you directly.
|
|
13
|
+
* Check it yourself: the source is at https://github.com/harshankur/viewcounter
|
|
14
|
+
* and the homepage at https://viewcounter.harshankur.com.
|
|
15
|
+
*
|
|
16
|
+
* For site owners:
|
|
3
17
|
*
|
|
4
18
|
* <script defer src="https://your-server/tracker.js" data-app="blog"></script>
|
|
5
19
|
*
|
|
6
20
|
* Records a view of each page (including page changes in single-page apps),
|
|
7
|
-
* how long it was visible and
|
|
21
|
+
* how long it was visible and, on a page that scrolls, how far (reported when the page
|
|
22
|
+
* is hidden or left, and every half minute while it is being read), clicks on links to
|
|
8
23
|
* other sites and on downloads, and the campaign tags of the landing URL.
|
|
9
24
|
* The page before is sent as its origin and path only.
|
|
10
25
|
*
|
|
11
26
|
* It stores nothing on the visitor's device (no cookie, no localStorage, no
|
|
12
27
|
* sessionStorage), so it needs no consent banner, and it sends no identifier:
|
|
13
|
-
* the server tells repeat visits apart with a hash it rotates every day.
|
|
28
|
+
* the server tells repeat visits apart with a hash it rotates every day by default.
|
|
14
29
|
*
|
|
15
30
|
* Options, as attributes on the script tag:
|
|
16
31
|
* data-app="blog" required: the app ID the views belong to
|
|
@@ -18,6 +33,11 @@
|
|
|
18
33
|
* only track on these hostnames (keeps dev
|
|
19
34
|
* servers and previews out of the data)
|
|
20
35
|
* data-spa="false" do not treat history changes as page views
|
|
36
|
+
* data-hash="#docs/,#spec/" count a URL fragment that starts with one of
|
|
37
|
+
* these as its own page (hash-routed pages)
|
|
38
|
+
* data-heartbeat="false" report time on page only when the page is
|
|
39
|
+
* hidden or left, not while it is being read
|
|
40
|
+
* data-campaigns="false" do not send the landing URL's utm_* tags
|
|
21
41
|
* data-outbound="false" do not record clicks on links to other sites
|
|
22
42
|
* data-downloads="false" do not record clicks on downloads
|
|
23
43
|
* data-respect-dnt="true" send nothing when Do Not Track is on
|
|
@@ -32,6 +52,7 @@
|
|
|
32
52
|
const option = (name, fallback) => (script.dataset[name] === undefined ? fallback : script.dataset[name] !== 'false');
|
|
33
53
|
const hosts = (script.dataset.hosts || '').split(',').map((host) => host.trim().toLowerCase()).filter(Boolean);
|
|
34
54
|
if (hosts.length && !hosts.includes(location.hostname.toLowerCase())) return;
|
|
55
|
+
const hashRoutes = (script.dataset.hash || '').split(',').map((prefix) => prefix.trim()).filter(Boolean);
|
|
35
56
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') return;
|
|
36
57
|
// Automated browsers are not visitors.
|
|
37
58
|
if (navigator.webdriver) return;
|
|
@@ -41,12 +62,25 @@
|
|
|
41
62
|
const UTM = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
42
63
|
const DOWNLOAD = /\.(pdf|zip|gz|tgz|rar|7z|dmg|exe|msi|pkg|deb|rpm|apk|iso|csv|xlsx?|docx?|pptx?|odt|ods|epub|mp3|mp4|mov|avi|wav)$/i;
|
|
43
64
|
const MAX_ENGAGED_MS = 6 * 60 * 60 * 1000;
|
|
65
|
+
const ENGAGE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
66
|
+
const HEARTBEAT_MS = 30 * 1000;
|
|
67
|
+
// A tab left open with nobody at it stops reporting after this long without input.
|
|
68
|
+
const IDLE_MS = 30 * 60 * 1000;
|
|
44
69
|
|
|
45
70
|
const deviceSize = () => (innerWidth < 768 ? 'small' : innerWidth < 1200 ? 'medium' : 'large');
|
|
46
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* How much of the page has been on screen, from 0 to 100, or null for a
|
|
73
|
+
* page that fits the window: there is nothing to scroll, so "all of it"
|
|
74
|
+
* would say nothing about the reader.
|
|
75
|
+
*/
|
|
47
76
|
const seen = () => {
|
|
48
77
|
const height = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
|
49
|
-
return height <=
|
|
78
|
+
return height <= innerHeight + 1 ? null : Math.min(100, Math.round(((scrollY + innerHeight) / height) * 100));
|
|
79
|
+
};
|
|
80
|
+
/** Note how far the page on screen has been scrolled, if it scrolls at all (it may have grown since it loaded). */
|
|
81
|
+
const measure = () => {
|
|
82
|
+
const depth = view ? seen() : null;
|
|
83
|
+
if (depth !== null) view.scroll = Math.max(view.scroll === null ? 0 : view.scroll, depth);
|
|
50
84
|
};
|
|
51
85
|
|
|
52
86
|
/** A URL's origin and path: its query and fragment can carry tokens or emails. */
|
|
@@ -59,18 +93,28 @@
|
|
|
59
93
|
}
|
|
60
94
|
};
|
|
61
95
|
|
|
96
|
+
/** The page on screen: its path, and its fragment when that is one of the site's own routes. */
|
|
97
|
+
const currentPage = () => location.pathname
|
|
98
|
+
+ (hashRoutes.some((prefix) => location.hash.startsWith(prefix)) ? location.hash : '');
|
|
99
|
+
|
|
62
100
|
let view = null;
|
|
63
101
|
let referrer = document.referrer ? originAndPath(document.referrer) : '';
|
|
64
|
-
let path =
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
102
|
+
let path = currentPage();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Tell the server how long a page was visible and how far it was scrolled.
|
|
106
|
+
* `alive` sends it even when neither has grown since the last report: that
|
|
107
|
+
* is the heartbeat saying the visitor is still there.
|
|
108
|
+
*/
|
|
109
|
+
function reportEngagement(of = view, alive = false) {
|
|
110
|
+
if (!of || !of.id) return;
|
|
111
|
+
const ms = Math.min(MAX_ENGAGED_MS, Math.round(of.visibleMs + (of.visibleSince === null ? 0 : performance.now() - of.visibleSince)));
|
|
112
|
+
const scroll = of.scroll === null ? -1 : of.scroll;
|
|
113
|
+
if (!alive && ms <= of.sentMs && scroll <= of.sentScroll) return;
|
|
114
|
+
of.sentMs = ms;
|
|
115
|
+
of.sentScroll = scroll;
|
|
116
|
+
// A page that never scrolled reports its time alone.
|
|
117
|
+
const body = JSON.stringify({ appId: app, id: of.id, ms, scroll: of.scroll === null ? undefined : of.scroll });
|
|
74
118
|
// text/plain needs no CORS preflight, so the beacon survives the page closing.
|
|
75
119
|
if (!(navigator.sendBeacon && navigator.sendBeacon(`${base}engage`, new Blob([body], { type: 'text/plain' })))) {
|
|
76
120
|
fetch(`${base}engage`, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } })
|
|
@@ -79,34 +123,55 @@
|
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
function pageview() {
|
|
82
|
-
|
|
126
|
+
// The page being left stops counting here, and reports what it has.
|
|
127
|
+
// It is still the one on screen (a single-page app draws the next
|
|
128
|
+
// only after changing the address), so this is its last measure.
|
|
129
|
+
if (view) {
|
|
130
|
+
if (view.visibleSince !== null) view.visibleMs += performance.now() - view.visibleSince;
|
|
131
|
+
view.visibleSince = null;
|
|
132
|
+
measure();
|
|
133
|
+
reportEngagement();
|
|
134
|
+
}
|
|
135
|
+
const first = !view;
|
|
83
136
|
const params = new URLSearchParams({
|
|
84
137
|
appId: app,
|
|
85
138
|
deviceSize: deviceSize(),
|
|
86
|
-
page:
|
|
139
|
+
page: currentPage().slice(0, 500),
|
|
87
140
|
title: document.title.slice(0, 200),
|
|
88
141
|
referrer: referrer.slice(0, 500),
|
|
89
142
|
});
|
|
90
143
|
// Only the campaign tags: the rest of a query string can carry
|
|
91
144
|
// emails, tokens, or IDs, and never leaves the page.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
145
|
+
if (option('campaigns', true)) {
|
|
146
|
+
const query = new URLSearchParams(location.search);
|
|
147
|
+
for (const key of UTM) {
|
|
148
|
+
const value = query.get(key);
|
|
149
|
+
if (value) params.set(key, value.slice(0, 100));
|
|
150
|
+
}
|
|
96
151
|
}
|
|
97
152
|
|
|
98
153
|
const current = {
|
|
99
154
|
id: null,
|
|
155
|
+
startedAt: Date.now(),
|
|
100
156
|
visibleMs: 0,
|
|
101
157
|
visibleSince: document.visibilityState === 'visible' ? performance.now() : null,
|
|
102
|
-
scroll:
|
|
158
|
+
scroll: null,
|
|
103
159
|
sentMs: 0,
|
|
104
|
-
sentScroll:
|
|
160
|
+
sentScroll: -1,
|
|
105
161
|
};
|
|
106
162
|
view = current;
|
|
163
|
+
// A page that has just loaded can be measured. After a navigation the
|
|
164
|
+
// old page is still on screen, and its depth is not the new one's:
|
|
165
|
+
// the new page is measured when it is scrolled, hidden, or reports.
|
|
166
|
+
if (first) measure();
|
|
107
167
|
fetch(`${base}registerView?${params}`, { keepalive: true, credentials: 'omit', referrerPolicy: 'no-referrer' })
|
|
108
168
|
.then((response) => (response.ok ? response.json() : null))
|
|
109
|
-
.then((result) => {
|
|
169
|
+
.then((result) => {
|
|
170
|
+
if (!result || !result.id) return;
|
|
171
|
+
current.id = result.id;
|
|
172
|
+
// Left before the server answered: its report could not go then, so it goes now.
|
|
173
|
+
if (view !== current) reportEngagement(current);
|
|
174
|
+
})
|
|
110
175
|
.catch(() => {});
|
|
111
176
|
}
|
|
112
177
|
|
|
@@ -122,7 +187,7 @@
|
|
|
122
187
|
appId: app,
|
|
123
188
|
eventType: eventType.slice(0, 50),
|
|
124
189
|
eventData: eventData && typeof eventData === 'object' ? eventData : undefined,
|
|
125
|
-
page:
|
|
190
|
+
page: currentPage().slice(0, 500),
|
|
126
191
|
title: document.title.slice(0, 200),
|
|
127
192
|
}),
|
|
128
193
|
}).catch(() => {});
|
|
@@ -131,9 +196,10 @@
|
|
|
131
196
|
// A page change in a single-page app is a new page view, with the page it
|
|
132
197
|
// came from as its referrer (which the server files as internal).
|
|
133
198
|
function navigated() {
|
|
134
|
-
if (
|
|
135
|
-
referrer
|
|
136
|
-
|
|
199
|
+
if (currentPage() === path) return;
|
|
200
|
+
// Like every referrer, without the fragment: the server keeps an origin and a path.
|
|
201
|
+
referrer = `${location.origin}${path.split('#')[0]}`;
|
|
202
|
+
path = currentPage();
|
|
137
203
|
pageview();
|
|
138
204
|
}
|
|
139
205
|
if (option('spa', true)) {
|
|
@@ -147,6 +213,8 @@
|
|
|
147
213
|
}
|
|
148
214
|
addEventListener('popstate', navigated);
|
|
149
215
|
}
|
|
216
|
+
// Asked for by name, so it does not wait on data-spa.
|
|
217
|
+
if (hashRoutes.length) addEventListener('hashchange', navigated);
|
|
150
218
|
|
|
151
219
|
let scrollQueued = false;
|
|
152
220
|
addEventListener('scroll', () => {
|
|
@@ -154,7 +222,7 @@
|
|
|
154
222
|
scrollQueued = true;
|
|
155
223
|
requestAnimationFrame(() => {
|
|
156
224
|
scrollQueued = false;
|
|
157
|
-
|
|
225
|
+
measure();
|
|
158
226
|
});
|
|
159
227
|
}, { passive: true });
|
|
160
228
|
|
|
@@ -163,12 +231,31 @@
|
|
|
163
231
|
if (document.visibilityState === 'hidden') {
|
|
164
232
|
if (view.visibleSince !== null) view.visibleMs += performance.now() - view.visibleSince;
|
|
165
233
|
view.visibleSince = null;
|
|
234
|
+
measure();
|
|
166
235
|
reportEngagement();
|
|
167
236
|
} else if (view.visibleSince === null) {
|
|
168
237
|
view.visibleSince = performance.now();
|
|
169
238
|
}
|
|
170
239
|
});
|
|
171
|
-
addEventListener('pagehide', reportEngagement);
|
|
240
|
+
addEventListener('pagehide', () => { measure(); reportEngagement(); });
|
|
241
|
+
|
|
242
|
+
// While the page is being read, report as it goes: the server then knows the
|
|
243
|
+
// visitor is still there, and a tab the browser kills without warning (common
|
|
244
|
+
// on phones) loses half a minute of its time at most, not all of it.
|
|
245
|
+
if (option('heartbeat', true)) {
|
|
246
|
+
let lastInput = performance.now();
|
|
247
|
+
const active = () => { lastInput = performance.now(); };
|
|
248
|
+
for (const type of ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart']) {
|
|
249
|
+
addEventListener(type, active, { passive: true, capture: true });
|
|
250
|
+
}
|
|
251
|
+
setInterval(() => {
|
|
252
|
+
if (!view || document.visibilityState !== 'visible' || performance.now() - lastInput > IDLE_MS) return;
|
|
253
|
+
// The server takes reports for a view for a day. A page open longer says no more.
|
|
254
|
+
if (Date.now() - view.startedAt > ENGAGE_WINDOW_MS) return;
|
|
255
|
+
measure();
|
|
256
|
+
reportEngagement(view, true);
|
|
257
|
+
}, HEARTBEAT_MS);
|
|
258
|
+
}
|
|
172
259
|
|
|
173
260
|
// Links out and downloads. Only the other site's hostname, or the file's
|
|
174
261
|
// name, is recorded: never the whole URL, which can carry personal data.
|
package/utils/errorUtils.js
CHANGED
|
@@ -48,6 +48,7 @@ const WarningType = {
|
|
|
48
48
|
TRACKING_LOG_WRITE_FAILED: 'TRACKING_LOG_WRITE_FAILED',
|
|
49
49
|
ADMIN_LOG_WRITE_FAILED: 'ADMIN_LOG_WRITE_FAILED',
|
|
50
50
|
TRASH_PURGE_FAILED: 'TRASH_PURGE_FAILED',
|
|
51
|
+
SALT_PRUNE_FAILED: 'SALT_PRUNE_FAILED',
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -124,6 +125,8 @@ const WARNING_MESSAGES = {
|
|
|
124
125
|
`Automatic view log pruning failed: ${info?.cause}`,
|
|
125
126
|
[WarningType.TRASH_PURGE_FAILED]: (info) =>
|
|
126
127
|
`Automatic trash purge failed for '${info?.appId}': ${info?.cause}`,
|
|
128
|
+
[WarningType.SALT_PRUNE_FAILED]: (info) =>
|
|
129
|
+
`Could not delete the visitor salts of ended windows (it is tried again shortly): ${info?.cause}`,
|
|
127
130
|
};
|
|
128
131
|
|
|
129
132
|
/**
|
package/utils/privacyUtils.js
CHANGED
|
@@ -61,8 +61,17 @@ class PrivacyUtils {
|
|
|
61
61
|
* @returns {number}
|
|
62
62
|
*/
|
|
63
63
|
static currentWindowId(rotationHours, now = Date.now()) {
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
return Math.floor(now / (this.rotationHours(rotationHours) * MS_PER_HOUR));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The length of a rotation window, in hours: the unique-visitor window,
|
|
69
|
+
* and never less than MIN_ROTATION_HOURS.
|
|
70
|
+
* @param {number} rotationHours
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
static rotationHours(rotationHours) {
|
|
74
|
+
return Math.max(Number(rotationHours) || 0, MIN_ROTATION_HOURS);
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/**
|
|
@@ -80,6 +89,10 @@ class PrivacyUtils {
|
|
|
80
89
|
* @param {string} secret Server secret from utils/secretStore.js
|
|
81
90
|
* @param {number} [rotationHours] Window length, defaults to the unique-visitor window
|
|
82
91
|
* @param {number} [now] epoch millis, injectable for tests
|
|
92
|
+
* @param {string} [salt] The window's own salt (db/visitorSalt.js), which
|
|
93
|
+
* is deleted when the window ends. With it, a hash cannot be recomputed
|
|
94
|
+
* afterwards even by whoever holds the secret. Without it, the window
|
|
95
|
+
* id alone separates the windows, and the secret holder still can.
|
|
83
96
|
* @returns {string} HMAC-SHA-256 hex digest
|
|
84
97
|
* @throws {Error} ErrorType.SECRET_UNAVAILABLE when no secret is supplied
|
|
85
98
|
*/
|
|
@@ -89,6 +102,7 @@ class PrivacyUtils {
|
|
|
89
102
|
secret,
|
|
90
103
|
rotationHours = SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS,
|
|
91
104
|
now = Date.now(),
|
|
105
|
+
salt = '',
|
|
92
106
|
) {
|
|
93
107
|
if (!secret) {
|
|
94
108
|
// Failing closed is deliberate: silently hashing without the key
|
|
@@ -98,7 +112,8 @@ class PrivacyUtils {
|
|
|
98
112
|
}
|
|
99
113
|
|
|
100
114
|
const windowId = this.currentWindowId(rotationHours, now);
|
|
101
|
-
|
|
115
|
+
// The unsalted form is kept byte for byte, for callers that use this helper on its own.
|
|
116
|
+
const input = salt ? `${salt}|${ip}|${userAgent}|${windowId}` : `${ip}|${userAgent}|${windowId}`;
|
|
102
117
|
|
|
103
118
|
return crypto.createHmac('sha256', secret).update(input).digest('hex');
|
|
104
119
|
}
|