@harshankur/viewcounter 3.4.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 +11 -0
- package/README.md +30 -5
- package/config/index.js +44 -1
- package/index.js +3 -2
- package/middleware/security.js +20 -2
- package/package.json +1 -1
- package/routes/analytics.js +71 -21
- package/utils/errorUtils.js +3 -0
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
|
[](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
|
|
|
@@ -190,7 +190,7 @@ than running on a guessable default:
|
|
|
190
190
|
- `ADMIN_PASSWORD`: turns on the [admin UI](#admin-ui) at `/admin`. At least 16 characters. Unset means the admin UI does not exist.
|
|
191
191
|
|
|
192
192
|
**Optional**: `DB_MODE`, `PORT`, `LOG_LEVEL`, `RATE_LIMIT_WINDOW_MS`,
|
|
193
|
-
`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`,
|
|
194
194
|
`TRASH_RETENTION_DAYS`, `VIEW_LOG_RETENTION_DAYS`, `ADMIN_SESSION_IDLE_TIMEOUT`,
|
|
195
195
|
`ADMIN_SESSION_MAX_AGE`, `GEOIP_CITY_DB`.
|
|
196
196
|
|
|
@@ -867,6 +867,26 @@ Two independent limits apply to writes:
|
|
|
867
867
|
everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
|
|
868
868
|
be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
|
|
869
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
|
+
|
|
870
890
|
Each limit is applied twice, as two separate budgets of that size: one for
|
|
871
891
|
engagement reports (`/engage`) and one for everything else. A page being read
|
|
872
892
|
reports every half minute, so each open, active tab costs two reports a minute;
|
|
@@ -930,7 +950,10 @@ app.use('/analytics', createAnalyticsRouter({
|
|
|
930
950
|
privacy: { visitorSecret: process.env.VISITOR_SECRET },
|
|
931
951
|
server: {
|
|
932
952
|
uniqueVisitorWindowHours: 24,
|
|
933
|
-
//
|
|
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.
|
|
934
957
|
rateLimit: { windowMs: 60000, perAppMax: 1000 },
|
|
935
958
|
},
|
|
936
959
|
},
|
|
@@ -940,8 +963,10 @@ app.use('/analytics', createAnalyticsRouter({
|
|
|
940
963
|
Endpoints then live under the prefix: `POST /analytics/event`,
|
|
941
964
|
`GET /analytics/stats/blog`, and so on.
|
|
942
965
|
|
|
943
|
-
|
|
944
|
-
install them itself: `helmet()` and the CORS allowlist,
|
|
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
|
|
945
970
|
`app.set('trust proxy', <hop count>)`, never `true`, or callers can forge
|
|
946
971
|
their own IP through `X-Forwarded-For`.
|
|
947
972
|
|
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/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,
|
|
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
|
-
|
|
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
|
|
package/middleware/security.js
CHANGED
|
@@ -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
|
|
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
|
};
|
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.5.0",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24"
|
package/routes/analytics.js
CHANGED
|
@@ -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
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
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) =>
|
|
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
|
|
163
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
176
|
-
const { max, windowMs } = rateLimitConfig || {};
|
|
177
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -680,4 +730,4 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true, geo =
|
|
|
680
730
|
return router;
|
|
681
731
|
}
|
|
682
732
|
|
|
683
|
-
module.exports = { createAnalyticsRouter,
|
|
733
|
+
module.exports = { createAnalyticsRouter, buildPerIpLimiter, handleRouteError, logContext, withRequestId, trackingSourceFor };
|
package/utils/errorUtils.js
CHANGED
|
@@ -49,6 +49,7 @@ const WarningType = {
|
|
|
49
49
|
ADMIN_LOG_WRITE_FAILED: 'ADMIN_LOG_WRITE_FAILED',
|
|
50
50
|
TRASH_PURGE_FAILED: 'TRASH_PURGE_FAILED',
|
|
51
51
|
SALT_PRUNE_FAILED: 'SALT_PRUNE_FAILED',
|
|
52
|
+
RATE_LIMIT_UNKNOWN_APP: 'RATE_LIMIT_UNKNOWN_APP',
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
/**
|
|
@@ -125,6 +126,8 @@ const WARNING_MESSAGES = {
|
|
|
125
126
|
`Automatic view log pruning failed: ${info?.cause}`,
|
|
126
127
|
[WarningType.TRASH_PURGE_FAILED]: (info) =>
|
|
127
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`,
|
|
128
131
|
[WarningType.SALT_PRUNE_FAILED]: (info) =>
|
|
129
132
|
`Could not delete the visitor salts of ended windows (it is tried again shortly): ${info?.cause}`,
|
|
130
133
|
};
|