@harshankur/viewcounter 3.3.0 → 3.5.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 CHANGED
@@ -127,6 +127,17 @@ RATE_LIMIT_MAX=100
127
127
  # Set 0 to disable (sensible for a single-tenant deployment).
128
128
  APP_RATE_LIMIT_MAX=1000
129
129
 
130
+ # An app's own figures, where the general ones above do not fit it: a list of
131
+ # appId:number. A site that records far more per visitor than the others (every
132
+ # step inside a single-page app, say) gets the room it needs without loosening
133
+ # the limits for every other app. An app listed in RATE_LIMIT_MAX_BY_APP is
134
+ # counted on its own per address, so its traffic does not use up the address's
135
+ # budget for other apps. Zero means no limit, here as in the general figures
136
+ # above. A malformed entry, or an app listed twice, stops the server from
137
+ # starting; a figure for an app that is not configured is warned about.
138
+ #RATE_LIMIT_MAX_BY_APP=homepage:600
139
+ #APP_RATE_LIMIT_MAX_BY_APP=homepage:5000
140
+
130
141
  # How long a visitor counts as "the same visitor" for deduplication, in hours.
131
142
  # Also the rotation period for the visitor hash: after this window the same
132
143
  # person hashes differently, so their visits cannot be linked across windows.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Documentation](https://img.shields.io/badge/docs-viewcounter.harshankur.com-blueviolet)](https://viewcounter.harshankur.com)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Test Suite](https://github.com/harshankur/viewcounter/actions/workflows/test.yml/badge.svg)](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
6
- [![Tests](https://img.shields.io/badge/tests-1109%20passing-success)](TEST_REPORT.md)
6
+ [![Tests](https://img.shields.io/badge/tests-1164%20passing-success)](TEST_REPORT.md)
7
7
  [![npm](https://img.shields.io/npm/v/@harshankur/viewcounter?logo=npm)](https://www.npmjs.com/package/@harshankur/viewcounter)
8
8
  [![provenance](https://img.shields.io/badge/provenance-signed-brightgreen?logo=github)](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 Compliant & Privacy-First
16
- **100% GDPR Compliant By Design.** This project is built from the ground up to respect user privacy and adhere to modern ethical standards:
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. **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.
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
 
@@ -154,7 +190,7 @@ than running on a guessable default:
154
190
  - `ADMIN_PASSWORD`: turns on the [admin UI](#admin-ui) at `/admin`. At least 16 characters. Unset means the admin UI does not exist.
155
191
 
156
192
  **Optional**: `DB_MODE`, `PORT`, `LOG_LEVEL`, `RATE_LIMIT_WINDOW_MS`,
157
- `RATE_LIMIT_MAX`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`,
193
+ `RATE_LIMIT_MAX`, `RATE_LIMIT_MAX_BY_APP`, `APP_RATE_LIMIT_MAX_BY_APP`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`,
158
194
  `TRASH_RETENTION_DAYS`, `VIEW_LOG_RETENTION_DAYS`, `ADMIN_SESSION_IDLE_TIMEOUT`,
159
195
  `ADMIN_SESSION_MAX_AGE`, `GEOIP_CITY_DB`.
160
196
 
@@ -393,7 +429,10 @@ Content-Type: text/plain # or application/json
393
429
  {"appId": "blog", "id": "<the id /registerView returned>", "ms": 42000, "scroll": 80}
394
430
  ```
395
431
  How long the page was visible (`ms`, up to 6 hours) and how much of it had been
396
- on screen (`scroll`, 0 to 100). A later report can only raise either. Each
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
397
436
  report also marks the view as seen just now, which keeps its visitor in the
398
437
  admin's **Right now** for the next few minutes; the tracker script sends one
399
438
  every half minute while the page is being read. A report
@@ -622,7 +661,7 @@ returned by any API.
622
661
  |-------|-----------|-----------|-----|
623
662
  | **Timestamp** | Server | When the view was recorded | Everything over time |
624
663
  | **Masked IP** | Request | IPv4 with the last octet zeroed, IPv6 with the interface identifier zeroed | Abuse investigation at network level, never a person |
625
- | **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 |
626
665
  | **Country** | IP, looked up in memory | Two-letter code | Where visitors are |
627
666
  | **Region, City** | IP, with an optional [city database](#location-data) | Names, such as Bavaria and Munich | Where visitors are, more finely |
628
667
  | **Language** | `Accept-Language` | Primary subtag only, such as `de` (never `de-CH`, never a list) | Which languages to write in |
@@ -635,7 +674,7 @@ returned by any API.
635
674
  | **Device Size** | `deviceSize` | small, medium, large | Layout decisions |
636
675
  | **Browser, OS, and versions** | User-Agent, parsed in memory | Names and versions, such as Chrome 140 on macOS 15 | Compatibility |
637
676
  | **Device Type** | User-Agent | desktop, mobile, tablet, tv, console, wearable | Compatibility |
638
- | **Time on page, Scroll depth, Last seen** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen; when the page last reported | 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 |
639
678
  | **Event Type, Event Data** | `/event` | Type name; JSON up to 4 kB, as your site sends it | Custom events |
640
679
  | **Session ID** | `sessionId` (optional) | As your site sends it | Your own grouping; the tracker never sends one |
641
680
 
@@ -666,7 +705,10 @@ This setting prevents counting the same visitor multiple times within a time win
666
705
  - If no: it is stored as a unique view
667
706
 
668
707
  The window is also how often the visitor hash rotates, so it bounds how long
669
- 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.
670
712
 
671
713
  **Examples:**
672
714
  - `24` (default): the same visitor counts once per day
@@ -692,7 +734,7 @@ because a browser on your site must be able to reach them. Everything that
692
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.
693
735
  - ✅ **Separate admin tier**: provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
694
736
  - ✅ **Per-tenant rate limits**: an `appId`-keyed budget alongside the per-IP limit.
695
- - ✅ **Keyed visitor hashing**: HMAC-SHA-256 with a persisted 32-byte server secret, rotating per window, so stored hashes are not reversible to an IP.
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.
696
738
  - ✅ **SQL injection prevention**: every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
697
739
  - ✅ **Explicit CORS allowlist**: no wildcard, and writes can be bound to registered origins per app.
698
740
  - ✅ **Proxy-aware IP derivation**: client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
@@ -825,6 +867,26 @@ Two independent limits apply to writes:
825
867
  everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
826
868
  be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
827
869
 
870
+ Either can be set for one app where the general figure does not fit it, as a
871
+ list of `appId:number`:
872
+
873
+ - `RATE_LIMIT_MAX_BY_APP=homepage:600`: tracking requests a minute from one
874
+ address to that app. Such an app is counted on its own, so its traffic does
875
+ not use up the address's budget for your other apps, nor the other way round.
876
+ - `APP_RATE_LIMIT_MAX_BY_APP=homepage:5000`: that app's whole budget.
877
+
878
+ Use these for a site that records far more per visitor than the others, such
879
+ as a single-page app that counts every step inside it.
880
+
881
+ - **Zero means no limit**, in all four settings: as a general figure it
882
+ switches that limit off, as an app's own figure it lifts it for that app.
883
+ - **Only tracking requests** (`/registerView`, `/event`, `/engage`) are limited
884
+ as an app, and always as the app they are stored under. A read or admin call
885
+ is limited by the general figure whatever it names.
886
+ - **Mistakes are not silent.** A malformed entry, or an app listed twice, stops
887
+ the server from starting. A figure for an app that is not configured is
888
+ logged as a warning at start, since it would otherwise never apply.
889
+
828
890
  Each limit is applied twice, as two separate budgets of that size: one for
829
891
  engagement reports (`/engage`) and one for everything else. A page being read
830
892
  reports every half minute, so each open, active tab costs two reports a minute;
@@ -888,7 +950,10 @@ app.use('/analytics', createAnalyticsRouter({
888
950
  privacy: { visitorSecret: process.env.VISITOR_SECRET },
889
951
  server: {
890
952
  uniqueVisitorWindowHours: 24,
891
- // omit to disable the per-app write budget
953
+ // perAppMax: the per-app write budget (omit to disable it).
954
+ // max: per address, applied by the router to engagement reports only;
955
+ // every other request is yours to limit, as below.
956
+ // perAppMaxByApp / maxByApp: { appId: number } for apps with their own figures.
892
957
  rateLimit: { windowMs: 60000, perAppMax: 1000 },
893
958
  },
894
959
  },
@@ -898,8 +963,10 @@ app.use('/analytics', createAnalyticsRouter({
898
963
  Endpoints then live under the prefix: `POST /analytics/event`,
899
964
  `GET /analytics/stats/blog`, and so on.
900
965
 
901
- Two things the host application owns in this mode, because the router does not
902
- install them itself: `helmet()` and the CORS allowlist, and `trust proxy`. Set
966
+ Three things the host application owns in this mode, because the router does
967
+ not install them itself: `helmet()` and the CORS allowlist, a per-address rate
968
+ limit (the router limits only engagement reports per address, and only when
969
+ `rateLimit.max` is given), and `trust proxy`. Set
903
970
  `app.set('trust proxy', <hop count>)`, never `true`, or callers can forge
904
971
  their own IP through `X-Forwarded-For`.
905
972
 
@@ -969,6 +1036,7 @@ automated browsers.
969
1036
  | `data-spa` | `true` | Treat history changes as page views |
970
1037
  | `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 |
971
1038
  | `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 |
1039
+ | `data-campaigns` | `true` | Send the landing URL's `utm_*` tags with the view |
972
1040
  | `data-outbound` | `true` | Record clicks on links to other sites, as `outbound` events |
973
1041
  | `data-downloads` | `true` | Record clicks on downloads (pdf, zip, dmg, docx, and so on), as `download` events |
974
1042
  | `data-respect-dnt` | `false` | Send nothing when the browser's Do Not Track is on |
package/config/index.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  SCOPE_ALL,
13
13
  SERVER,
14
14
  } = require('../constants');
15
- const { filterValidAppIds } = require('../utils/appIdUtils');
15
+ const { filterValidAppIds, isValidAppId } = require('../utils/appIdUtils');
16
16
  const { parseDuration, formatDuration } = require('../utils/durationUtils');
17
17
  const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
18
18
  const { LogLevel } = require('../utils/logger');
@@ -47,6 +47,30 @@ function parseList(raw) {
47
47
  .filter(Boolean);
48
48
  }
49
49
 
50
+ /**
51
+ * Parse per-app figures, `appId:number` separated by commas
52
+ * (`homepage:600,shop:0`), into a map. A malformed entry is an error: a
53
+ * limit silently not applied is worse than a refusal to start.
54
+ * @param {string|undefined} raw
55
+ * @param {string} field the setting's name, for the error
56
+ * @returns {Record<string, number>}
57
+ */
58
+ function parseAppNumbers(raw, field) {
59
+ const figures = {};
60
+ for (const entry of parseList(raw)) {
61
+ const [appId, value, ...rest] = entry.split(':').map((part) => part.trim());
62
+ if (rest.length || !isValidAppId(appId) || !/^\d+$/.test(value || '')) {
63
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, { field, reason: `'${entry}' is not appId:number, such as homepage:600` });
64
+ }
65
+ // Two figures for one app is a mistake, and guessing which was meant is not ours to do.
66
+ if (Object.hasOwn(figures, appId)) {
67
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, { field, reason: `'${appId}' is listed twice` });
68
+ }
69
+ figures[appId] = Number(value);
70
+ }
71
+ return figures;
72
+ }
73
+
50
74
  /** Parse an integer env var, falling back when absent or unparseable. */
51
75
  function parseIntOr(raw, fallback) {
52
76
  const parsed = Number.parseInt(raw, 10);
@@ -130,6 +154,9 @@ class Config {
130
154
  // Per-app ceiling on writes, so one tenant cannot exhaust the
131
155
  // budget the others depend on.
132
156
  perAppMax: parseIntOr(this.env.APP_RATE_LIMIT_MAX, SERVER.DEFAULT_APP_RATE_LIMIT_MAX),
157
+ // An app's own figures, where the general ones do not fit it.
158
+ maxByApp: parseAppNumbers(this.env.RATE_LIMIT_MAX_BY_APP, 'RATE_LIMIT_MAX_BY_APP'),
159
+ perAppMaxByApp: parseAppNumbers(this.env.APP_RATE_LIMIT_MAX_BY_APP, 'APP_RATE_LIMIT_MAX_BY_APP'),
133
160
  },
134
161
  uniqueVisitorWindowHours: parseIntOr(
135
162
  this.env.UNIQUE_VISITOR_WINDOW_HOURS,
@@ -391,6 +418,7 @@ class Config {
391
418
  void this.privacy.visitorSecret;
392
419
 
393
420
  this.validateAdmin();
421
+ this.warnAboutUnknownRateLimitApps();
394
422
 
395
423
  if (!this.server.isProduction) {
396
424
  this.warnAboutDevelopmentDefaults();
@@ -457,6 +485,21 @@ class Config {
457
485
  }
458
486
 
459
487
  /** Surface the same problems as warnings outside production. */
488
+ /**
489
+ * A per-app rate limit figure for an app this configuration does not
490
+ * list is most likely a typo, and would otherwise never apply without a
491
+ * word. It is a warning, not an error: an app may also be registered
492
+ * while the server runs.
493
+ */
494
+ warnAboutUnknownRateLimitApps() {
495
+ const { maxByApp = {}, perAppMaxByApp = {} } = this.server.rateLimit;
496
+ for (const [field, figures] of [['RATE_LIMIT_MAX_BY_APP', maxByApp], ['APP_RATE_LIMIT_MAX_BY_APP', perAppMaxByApp]]) {
497
+ for (const appId of Object.keys(figures)) {
498
+ if (!this.allowed.appId.includes(appId)) logWarning(WarningType.RATE_LIMIT_UNKNOWN_APP, { field, appId });
499
+ }
500
+ }
501
+ }
502
+
460
503
  warnAboutDevelopmentDefaults() {
461
504
  if (Object.keys(this.auth.readKeyScopes).length === 0) {
462
505
  logWarning(WarningType.READ_API_UNPROTECTED);
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: 'schema_v6',
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,
@@ -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
 
@@ -556,7 +569,8 @@ class DatabaseManager {
556
569
  * what it had.
557
570
  *
558
571
  * @param {string} appId already validated
559
- * @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
560
574
  * @returns {Promise<boolean>} whether a view was updated
561
575
  */
562
576
  async addEngagement(appId, { viewId, engagedMs, scrollDepth }) {
@@ -564,11 +578,12 @@ class DatabaseManager {
564
578
  const [result] = await this.pool.query(
565
579
  `UPDATE \`${appId}\`
566
580
  SET engaged_ms = GREATEST(COALESCE(engaged_ms, 0), ?),
567
- scroll_depth = GREATEST(COALESCE(scroll_depth, 0), ?),
581
+ scroll_depth = CASE WHEN ? IS NULL THEN scroll_depth
582
+ ELSE GREATEST(COALESCE(scroll_depth, 0), ?) END,
568
583
  last_seen_at = NOW()
569
584
  WHERE public_id = ? AND ${LIVE_ROW}
570
585
  AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
571
- [engagedMs, scrollDepth, viewId, TRACKING.ENGAGE_WINDOW_HOURS]
586
+ [engagedMs, scrollDepth, scrollDepth, viewId, TRACKING.ENGAGE_WINDOW_HOURS]
572
587
  );
573
588
  return result.affectedRows > 0;
574
589
  }
@@ -813,6 +828,7 @@ class DatabaseManager {
813
828
  * Gracefully close all connections
814
829
  */
815
830
  async close() {
831
+ this.visitorSalts.stop();
816
832
  if (this.pool) {
817
833
  await this.pool.end();
818
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');
@@ -61,6 +62,20 @@ const TRACKING_COLUMNS = [
61
62
  { name: 'last_seen_at', ddl: 'DATETIME DEFAULT NULL' },
62
63
  ];
63
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
+
64
79
  /** Indexes the admin columns need, keyed by index name. */
65
80
  const ADMIN_INDEXES = {
66
81
  uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
@@ -307,6 +322,7 @@ async function ensureLogTables(pool) {
307
322
  await pool.query(VIEW_LOG_DDL);
308
323
  await pool.query(TRACKING_REJECTIONS_DDL);
309
324
  await pool.query(ADMIN_SESSIONS_DDL);
325
+ await pool.query(VISITOR_SALTS_DDL);
310
326
 
311
327
  const viewLogColumns = await readColumns(pool, VIEW_LOG_TABLE);
312
328
  for (const column of VIEW_LOG_ADDED_COLUMNS) {
@@ -325,6 +341,7 @@ module.exports = {
325
341
  VIEW_LOG_ADDED_COLUMNS,
326
342
  TRACKING_REJECTIONS_DDL,
327
343
  ADMIN_SESSIONS_DDL,
344
+ VISITOR_SALTS_DDL,
328
345
  NEW_TABLE_ADMIN_COLUMNS,
329
346
  NEW_TABLE_ADMIN_INDEXES,
330
347
  backfillPublicIds,
@@ -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
@@ -7,7 +7,7 @@ const config = require('./config');
7
7
  const DatabaseManager = require('./db/DatabaseManager');
8
8
  const logger = require('./utils/logger');
9
9
  const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
10
- const { createAnalyticsRouter, buildPerIpLimiters, trackingSourceFor } = require('./routes/analytics');
10
+ const { createAnalyticsRouter, buildPerIpLimiter, trackingSourceFor } = require('./routes/analytics');
11
11
  const { createAdminRouter } = require('./routes/admin');
12
12
  const { startRetention } = require('./db/retention');
13
13
  const { createDbSessionStore } = require('./db/adminSessionStore');
@@ -80,7 +80,8 @@ function createApp() {
80
80
 
81
81
  // A tracking request turned away here is counted in the tracking log like
82
82
  // any other refusal (in memory, written in batches).
83
- app.use(buildPerIpLimiters(config.server.rateLimit, (req) => {
83
+ // (Engagement reports have a limiter of their own, on their route.)
84
+ app.use(buildPerIpLimiter(config.server.rateLimit, (req) => {
84
85
  if (trackingSourceFor(req.path)) router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
85
86
  }));
86
87
 
@@ -70,6 +70,23 @@ function requestOrigin(req) {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * The app a tracking request is for, read from the one place its route
75
+ * validates and stores: the query of a GET, the body of anything else.
76
+ *
77
+ * Everything that decides by app before validation (the origin check, the
78
+ * rate limits) must read it here. Reading "query, or else body" let a POST
79
+ * name one app in its query, be checked and limited as that app, and then be
80
+ * stored under the other app named in its body.
81
+ *
82
+ * @param {import('express').Request} req
83
+ * @returns {string} the appId, or '' when absent or not a string
84
+ */
85
+ function requestedAppId(req) {
86
+ const appId = req.method === 'GET' ? req.query?.appId : req.body?.appId;
87
+ return typeof appId === 'string' ? appId : '';
88
+ }
89
+
73
90
  /**
74
91
  * Bind writes for an appId to the site origins registered for it.
75
92
  *
@@ -92,8 +109,8 @@ function requireRegisteredOrigin(allowed, { onReject = () => {} } = {}) {
92
109
  const origins = allowed?.origins || {};
93
110
 
94
111
  return (req, res, next) => {
95
- const appId = req.query.appId || req.body?.appId;
96
- const registered = origins[appId];
112
+ const appId = requestedAppId(req);
113
+ const registered = Object.hasOwn(origins, appId) ? origins[appId] : undefined;
97
114
 
98
115
  if (!Array.isArray(registered) || registered.length === 0) {
99
116
  return next();
@@ -128,6 +145,7 @@ module.exports = {
128
145
  buildCorsOptions,
129
146
  countRefusedPreflights,
130
147
  requireRegisteredOrigin,
148
+ requestedAppId,
131
149
  requestOrigin,
132
150
  noStore,
133
151
  };
@@ -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.3.0",
4
+ "version": "3.5.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.2.2",
52
+ "supertest": "^7.3.1",
53
53
  "topojson-client": "^3.1.0",
54
54
  "world-atlas": "^2.0.2"
55
55
  },
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const express = require('express');
5
5
  const rateLimit = require('express-rate-limit');
6
+ const { ipKeyGenerator } = rateLimit;
6
7
  const geoip = require('geoip-country');
7
8
 
8
9
  const {
@@ -21,7 +22,7 @@ const PrivacyUtils = require('../utils/privacyUtils');
21
22
  const logger = require('../utils/logger');
22
23
  const { getClientIp, isValidIP, normalizeIp } = require('../utils/ipUtils');
23
24
  const { requireReadApiKey, requireAppScope, requireAdminApiKey, appsInScope } = require('../middleware/auth');
24
- const { requireRegisteredOrigin, requestOrigin, noStore } = require('../middleware/security');
25
+ const { requireRegisteredOrigin, requestedAppId, requestOrigin, noStore } = require('../middleware/security');
25
26
  const { createRejectionCounter } = require('../db/rejectionCounter');
26
27
  const { hostnameOf, primaryLanguage, utmTags } = require('../utils/visitorContext');
27
28
  const {
@@ -132,21 +133,24 @@ function intQuery(req, name, fallback) {
132
133
  * @returns {import('express').RequestHandler}
133
134
  */
134
135
  function buildPerAppLimiter(rateLimitConfig, onLimit = () => {}) {
135
- const { perAppMax, windowMs } = rateLimitConfig || {};
136
- // Zero disables it, for single-tenant deployments where the per-IP limit
137
- // is the only bound that means anything.
138
- if (!perAppMax || perAppMax <= 0) return (req, res, next) => next();
136
+ const { perAppMax, windowMs, perAppMaxByApp = {} } = rateLimitConfig || {};
137
+ // An app's own figure wins over the general one; zero means no ceiling.
138
+ const limitOf = (req) => (Object.hasOwn(perAppMaxByApp, appOf(req)) ? perAppMaxByApp[appOf(req)] : perAppMax) || 0;
139
+ // Nothing set anywhere disables it, for single-tenant deployments where
140
+ // the per-IP limit is the only bound that means anything.
141
+ if (!(perAppMax > 0) && !Object.values(perAppMaxByApp).some((value) => value > 0)) return (req, res, next) => next();
139
142
 
140
143
  return rateLimit({
141
144
  windowMs,
142
- limit: perAppMax,
145
+ limit: limitOf,
146
+ skip: (req) => limitOf(req) <= 0,
143
147
  standardHeaders: true,
144
148
  legacyHeaders: false,
145
149
  message: { message: 'This app has exceeded its request budget, please try again later.' },
146
150
  // A request with no appId lands in one shared bucket rather than
147
151
  // falling back to the IP, which would reintroduce the address-rotation
148
152
  // bypass this limiter exists to be immune to.
149
- keyGenerator: (req) => String(req.query?.appId || req.body?.appId || '__unattributed__'),
153
+ keyGenerator: (req) => appOf(req) || '__unattributed__',
150
154
  validate: { keyGeneratorIpFallback: false },
151
155
  handler: (req, res, next, options) => {
152
156
  onLimit(req);
@@ -159,34 +163,71 @@ function buildPerAppLimiter(rateLimitConfig, onLimit = () => {}) {
159
163
  const isEngagement = (req) => trackingSourceFor(req.path) === VIEW_LOG_SOURCE.ENGAGE;
160
164
 
161
165
  /**
162
- * The per-IP limiters: one budget for engagement reports, one for everything
163
- * else, each of `max` requests per window.
166
+ * The app a request is limited as: for a tracking request, the app its route
167
+ * goes on to validate and store under; for anything else, none. A read or
168
+ * admin call cannot name an app to borrow that app's figures.
169
+ */
170
+ const appOf = (req) => (trackingSourceFor(req.path) ? requestedAppId(req) : '');
171
+
172
+ /**
173
+ * A per-IP limiter: `max` requests per window from one address, or the app's
174
+ * own figure (`maxByApp`) for tracking requests to an app that has one. Zero
175
+ * means no limit, as the general figure or as an app's own.
164
176
  *
165
- * They are separate because a page being read reports its engagement every
177
+ * There are two of these, with separate budgets: one for engagement reports
178
+ * and one for everything else. A page being read reports its engagement every
166
179
  * half minute. On one shared budget, a few dozen readers behind one address
167
180
  * (an office, a campus) would use it up with those reports alone, and the page
168
181
  * views of everyone at that address would be refused. Apart, reports can only
169
182
  * ever crowd out other reports.
170
183
  *
171
- * @param {{ max: number, windowMs: number }} rateLimitConfig
184
+ * An app with its own figure is counted on its own, per address: a site that
185
+ * records far more per visitor than the others (every step inside a
186
+ * single-page app, say) gets the room it needs without loosening the limit
187
+ * for every other app, and without using up the address's budget for them.
188
+ *
189
+ * @param {{ max: number, windowMs: number, maxByApp?: Record<string, number> }} rateLimitConfig
172
190
  * @param {(req: import('express').Request) => void} [onLimit] called for a refused request
173
- * @returns {import('express').RequestHandler[]}
191
+ * @param {{ engagement?: boolean }} [options] true for the limiter of engagement
192
+ * reports, which is mounted on that route so it can know the app; false
193
+ * (the default) for the limiter of everything else, mounted app-wide
194
+ * @returns {import('express').RequestHandler}
174
195
  */
175
- function buildPerIpLimiters(rateLimitConfig, onLimit = () => {}) {
176
- const { max, windowMs } = rateLimitConfig || {};
177
- const limiter = (skip) => rateLimit({
196
+ function buildPerIpLimiter(rateLimitConfig, onLimit = () => {}, { engagement = false } = {}) {
197
+ const { max = 0, windowMs, maxByApp = {} } = rateLimitConfig || {};
198
+ const ownFigure = (req) => (Object.hasOwn(maxByApp, appOf(req)) ? maxByApp[appOf(req)] : null);
199
+ const pass = (req, res, next) => next();
200
+ const shared = {
178
201
  windowMs,
179
- limit: max,
180
202
  message: { message: 'Too many requests, please try again later.' },
181
203
  standardHeaders: true,
182
204
  legacyHeaders: false,
183
- skip,
184
205
  handler: (req, res, next, options) => {
185
206
  onLimit(req);
186
207
  res.status(options.statusCode).json(options.message);
187
208
  },
188
- });
189
- return [limiter(isEngagement), limiter((req) => !isEngagement(req))];
209
+ };
210
+
211
+ // The general budget keeps the library's own key (the address) and, with
212
+ // it, the library's checks for a misconfigured proxy.
213
+ const general = max > 0 ? rateLimit({ ...shared, limit: max }) : pass;
214
+ // Apps with a figure of their own: one counter per address and app.
215
+ const own = Object.values(maxByApp).some((value) => value > 0)
216
+ ? rateLimit({
217
+ ...shared,
218
+ limit: (req) => ownFigure(req),
219
+ keyGenerator: (req) => `${ipKeyGenerator(req.ip)}|${appOf(req)}`,
220
+ })
221
+ : pass;
222
+
223
+ return (req, res, next) => {
224
+ // The reports' limiter is the one on their route: POST /engage.
225
+ const isReport = isEngagement(req) && req.method === 'POST';
226
+ if (isReport !== engagement) return next();
227
+ const figure = ownFigure(req);
228
+ if (figure === null) return general(req, res, next);
229
+ return figure > 0 ? own(req, res, next) : next();
230
+ };
190
231
  }
191
232
 
192
233
  /**
@@ -271,6 +312,11 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
271
312
  // views refused because of the reports those readers' pages send.
272
313
  const limitEngagePerApp = buildPerAppLimiter(config.server?.rateLimit,
273
314
  (req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'app' }));
315
+ // Per address too. This one sits on the route, not app-wide with the
316
+ // other, so it runs after the beacon's body is read and knows the app.
317
+ const readBeacon = express.text({ type: () => true, limit: TRACKING.ENGAGE_BODY_BYTES });
318
+ const limitEngagePerIp = buildPerIpLimiter(config.server?.rateLimit,
319
+ (req) => reject(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' }), { engagement: true });
274
320
  const trackingValidation = handleTrackingValidation(reject);
275
321
 
276
322
  /** Views from these are counted in the tracking log and never stored. */
@@ -466,8 +512,12 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
466
512
  * far it was scrolled, sent by the tracker when the page is hidden or left.
467
513
  */
468
514
  router.post('/engage',
469
- express.text({ type: () => true, limit: TRACKING.ENGAGE_BODY_BYTES }),
515
+ // A body that cannot be read (too large, say) still counts against
516
+ // the address before it is refused: the app is unknown, so by the
517
+ // general figure.
518
+ (req, res, next) => readBeacon(req, res, (error) => (error ? limitEngagePerIp(req, res, () => next(error)) : next())),
470
519
  parseBeaconBody,
520
+ limitEngagePerIp,
471
521
  limitEngagePerApp,
472
522
  requireOrigin,
473
523
  validateEngage(config.allowed),
@@ -480,7 +530,7 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
480
530
  const updated = await dbManager.addEngagement(appId, {
481
531
  viewId: id,
482
532
  engagedMs: Number(ms),
483
- scrollDepth: Number(scroll),
533
+ scrollDepth: scroll === undefined || scroll === null ? null : Number(scroll),
484
534
  });
485
535
  if (!updated) reject(req, REJECTION_REASON.UNKNOWN_VIEW);
486
536
  return res.status(HTTP_STATUS.NO_CONTENT).end();
@@ -680,4 +730,4 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
680
730
  return router;
681
731
  }
682
732
 
683
- module.exports = { createAnalyticsRouter, buildPerIpLimiters, handleRouteError, logContext, withRequestId, trackingSourceFor };
733
+ module.exports = { createAnalyticsRouter, buildPerIpLimiter, handleRouteError, logContext, withRequestId, trackingSourceFor };
@@ -1,17 +1,31 @@
1
1
  /*!
2
- * viewcounter tracker, https://viewcounter.harshankur.com
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 how far it was scrolled (reported when the page
21
+ * how long it was visible and, on a page that scrolls, how far (reported when the page
8
22
  * is hidden or left, and every half minute while it is being read), clicks on links to
9
23
  * other sites and on downloads, and the campaign tags of the landing URL.
10
24
  * The page before is sent as its origin and path only.
11
25
  *
12
26
  * It stores nothing on the visitor's device (no cookie, no localStorage, no
13
27
  * sessionStorage), so it needs no consent banner, and it sends no identifier:
14
- * 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.
15
29
  *
16
30
  * Options, as attributes on the script tag:
17
31
  * data-app="blog" required: the app ID the views belong to
@@ -23,6 +37,7 @@
23
37
  * these as its own page (hash-routed pages)
24
38
  * data-heartbeat="false" report time on page only when the page is
25
39
  * hidden or left, not while it is being read
40
+ * data-campaigns="false" do not send the landing URL's utm_* tags
26
41
  * data-outbound="false" do not record clicks on links to other sites
27
42
  * data-downloads="false" do not record clicks on downloads
28
43
  * data-respect-dnt="true" send nothing when Do Not Track is on
@@ -53,10 +68,19 @@
53
68
  const IDLE_MS = 30 * 60 * 1000;
54
69
 
55
70
  const deviceSize = () => (innerWidth < 768 ? 'small' : innerWidth < 1200 ? 'medium' : 'large');
56
- /** How much of the page has been on screen, from 0 to 100. */
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
+ */
57
76
  const seen = () => {
58
77
  const height = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
59
- return height <= 0 ? 100 : Math.min(100, Math.round(((scrollY + innerHeight) / height) * 100));
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);
60
84
  };
61
85
 
62
86
  /** A URL's origin and path: its query and fragment can carry tokens or emails. */
@@ -85,10 +109,12 @@
85
109
  function reportEngagement(of = view, alive = false) {
86
110
  if (!of || !of.id) return;
87
111
  const ms = Math.min(MAX_ENGAGED_MS, Math.round(of.visibleMs + (of.visibleSince === null ? 0 : performance.now() - of.visibleSince)));
88
- if (!alive && ms <= of.sentMs && of.scroll <= of.sentScroll) return;
112
+ const scroll = of.scroll === null ? -1 : of.scroll;
113
+ if (!alive && ms <= of.sentMs && scroll <= of.sentScroll) return;
89
114
  of.sentMs = ms;
90
- of.sentScroll = of.scroll;
91
- const body = JSON.stringify({ appId: app, id: of.id, ms, scroll: of.scroll });
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 });
92
118
  // text/plain needs no CORS preflight, so the beacon survives the page closing.
93
119
  if (!(navigator.sendBeacon && navigator.sendBeacon(`${base}engage`, new Blob([body], { type: 'text/plain' })))) {
94
120
  fetch(`${base}engage`, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } })
@@ -98,11 +124,15 @@
98
124
 
99
125
  function pageview() {
100
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.
101
129
  if (view) {
102
130
  if (view.visibleSince !== null) view.visibleMs += performance.now() - view.visibleSince;
103
131
  view.visibleSince = null;
132
+ measure();
104
133
  reportEngagement();
105
134
  }
135
+ const first = !view;
106
136
  const params = new URLSearchParams({
107
137
  appId: app,
108
138
  deviceSize: deviceSize(),
@@ -112,10 +142,12 @@
112
142
  });
113
143
  // Only the campaign tags: the rest of a query string can carry
114
144
  // emails, tokens, or IDs, and never leaves the page.
115
- const query = new URLSearchParams(location.search);
116
- for (const key of UTM) {
117
- const value = query.get(key);
118
- if (value) params.set(key, value.slice(0, 100));
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
+ }
119
151
  }
120
152
 
121
153
  const current = {
@@ -123,11 +155,15 @@
123
155
  startedAt: Date.now(),
124
156
  visibleMs: 0,
125
157
  visibleSince: document.visibilityState === 'visible' ? performance.now() : null,
126
- scroll: seen(),
158
+ scroll: null,
127
159
  sentMs: 0,
128
- sentScroll: 0,
160
+ sentScroll: -1,
129
161
  };
130
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();
131
167
  fetch(`${base}registerView?${params}`, { keepalive: true, credentials: 'omit', referrerPolicy: 'no-referrer' })
132
168
  .then((response) => (response.ok ? response.json() : null))
133
169
  .then((result) => {
@@ -186,7 +222,7 @@
186
222
  scrollQueued = true;
187
223
  requestAnimationFrame(() => {
188
224
  scrollQueued = false;
189
- if (view) view.scroll = Math.max(view.scroll, seen());
225
+ measure();
190
226
  });
191
227
  }, { passive: true });
192
228
 
@@ -195,12 +231,13 @@
195
231
  if (document.visibilityState === 'hidden') {
196
232
  if (view.visibleSince !== null) view.visibleMs += performance.now() - view.visibleSince;
197
233
  view.visibleSince = null;
234
+ measure();
198
235
  reportEngagement();
199
236
  } else if (view.visibleSince === null) {
200
237
  view.visibleSince = performance.now();
201
238
  }
202
239
  });
203
- addEventListener('pagehide', () => reportEngagement());
240
+ addEventListener('pagehide', () => { measure(); reportEngagement(); });
204
241
 
205
242
  // While the page is being read, report as it goes: the server then knows the
206
243
  // visitor is still there, and a tab the browser kills without warning (common
@@ -215,6 +252,7 @@
215
252
  if (!view || document.visibilityState !== 'visible' || performance.now() - lastInput > IDLE_MS) return;
216
253
  // The server takes reports for a view for a day. A page open longer says no more.
217
254
  if (Date.now() - view.startedAt > ENGAGE_WINDOW_MS) return;
255
+ measure();
218
256
  reportEngagement(view, true);
219
257
  }, HEARTBEAT_MS);
220
258
  }
@@ -48,6 +48,8 @@ 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',
52
+ RATE_LIMIT_UNKNOWN_APP: 'RATE_LIMIT_UNKNOWN_APP',
51
53
  };
52
54
 
53
55
  /**
@@ -124,6 +126,10 @@ const WARNING_MESSAGES = {
124
126
  `Automatic view log pruning failed: ${info?.cause}`,
125
127
  [WarningType.TRASH_PURGE_FAILED]: (info) =>
126
128
  `Automatic trash purge failed for '${info?.appId}': ${info?.cause}`,
129
+ [WarningType.RATE_LIMIT_UNKNOWN_APP]: (info) =>
130
+ `${info?.field} names '${info?.appId}', which is not one of the configured apps: its figure applies only if an app of that name is registered later`,
131
+ [WarningType.SALT_PRUNE_FAILED]: (info) =>
132
+ `Could not delete the visitor salts of ended windows (it is tried again shortly): ${info?.cause}`,
127
133
  };
128
134
 
129
135
  /**
@@ -61,8 +61,17 @@ class PrivacyUtils {
61
61
  * @returns {number}
62
62
  */
63
63
  static currentWindowId(rotationHours, now = Date.now()) {
64
- const hours = Math.max(Number(rotationHours) || 0, MIN_ROTATION_HOURS);
65
- return Math.floor(now / (hours * MS_PER_HOUR));
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
- const input = `${ip}|${userAgent}|${windowId}`;
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
  }