@harshankur/viewcounter 3.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ViewCounter Admin",
|
|
3
|
+
"short_name": "ViewCounter",
|
|
4
|
+
"start_url": "./",
|
|
5
|
+
"icons": [
|
|
6
|
+
{
|
|
7
|
+
"src": "icon-192.png",
|
|
8
|
+
"sizes": "192x192",
|
|
9
|
+
"type": "image/png"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"src": "icon-512.png",
|
|
13
|
+
"sizes": "512x512",
|
|
14
|
+
"type": "image/png"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"theme_color": "#0a0f0c",
|
|
18
|
+
"background_color": "#0a0f0c",
|
|
19
|
+
"display": "standalone"
|
|
20
|
+
}
|
package/config/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('path');
|
|
|
4
4
|
require('dotenv').config({ quiet: true });
|
|
5
5
|
|
|
6
6
|
const {
|
|
7
|
+
ADMIN,
|
|
7
8
|
DATABASE,
|
|
8
9
|
INSECURE_DEFAULTS,
|
|
9
10
|
NODE_ENV,
|
|
@@ -12,6 +13,7 @@ const {
|
|
|
12
13
|
SERVER,
|
|
13
14
|
} = require('../constants');
|
|
14
15
|
const { filterValidAppIds } = require('../utils/appIdUtils');
|
|
16
|
+
const { parseDuration, formatDuration } = require('../utils/durationUtils');
|
|
15
17
|
const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
|
|
16
18
|
const { LogLevel } = require('../utils/logger');
|
|
17
19
|
|
|
@@ -70,7 +72,7 @@ function readJsonFile(filePath) {
|
|
|
70
72
|
*
|
|
71
73
|
* CONFIG.md §4: never a shallow spread of a nested object. The previous
|
|
72
74
|
* implementation returned the parsed file verbatim, so a `dbInfo.json` missing
|
|
73
|
-
* `host` produced `host: undefined` instead of falling back
|
|
75
|
+
* `host` produced `host: undefined` instead of falling back, and an
|
|
74
76
|
* `allowed.json` missing `appId` produced `undefined`, which then threw on
|
|
75
77
|
* `.join()` at startup.
|
|
76
78
|
*
|
|
@@ -96,13 +98,17 @@ class Config {
|
|
|
96
98
|
this.allowed = this.loadAllowed();
|
|
97
99
|
this.auth = this.loadAuthConfig();
|
|
98
100
|
this.privacy = this.loadPrivacyConfig();
|
|
101
|
+
this.admin = this.loadAdminConfig();
|
|
102
|
+
// Optional: an .mmdb city database (DB-IP City Lite or GeoLite2 City)
|
|
103
|
+
// for each view's region and city. Without it, country only.
|
|
104
|
+
this.geo = { cityDatabase: this.env.GEOIP_CITY_DB || null };
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/**
|
|
102
108
|
* Server, logging, and proxy settings.
|
|
103
109
|
*
|
|
104
110
|
* `nodeEnv` defaults to production. It previously defaulted to
|
|
105
|
-
* development, which is also what the setup wizard wrote into `.env
|
|
111
|
+
* development, which is also what the setup wizard wrote into `.env`, and
|
|
106
112
|
* ten route handlers echo raw database error text to the caller when the
|
|
107
113
|
* environment is development. A deploy that forgot to set NODE_ENV leaked
|
|
108
114
|
* table names and SQL fragments to anonymous callers.
|
|
@@ -207,7 +213,7 @@ class Config {
|
|
|
207
213
|
*
|
|
208
214
|
* Two sources, because they serve different deployments:
|
|
209
215
|
* - `READ_API_KEYS` (env): unscoped keys that can read every app. This is
|
|
210
|
-
* the single-operator case
|
|
216
|
+
* the single-operator case: all the apps are yours anyway.
|
|
211
217
|
* - `apiKeys` in allowed.json: `{ "<key>": ["blog"] }`, scoped to named
|
|
212
218
|
* apps. This is the multi-tenant case, where one customer's key must
|
|
213
219
|
* not read another customer's analytics. Use `"*"` for an unscoped key.
|
|
@@ -273,7 +279,7 @@ class Config {
|
|
|
273
279
|
// Resolved lazily, on first read rather than at construction.
|
|
274
280
|
//
|
|
275
281
|
// This module is imported by index.js, and index.js is the package
|
|
276
|
-
// entry point
|
|
282
|
+
// entry point, so an application that only wants to mount
|
|
277
283
|
// createAnalyticsRouter would otherwise generate and persist a secret
|
|
278
284
|
// purely as a side effect of `require('@harshankur/viewcounter')`, writing it into
|
|
279
285
|
// node_modules where the next `npm ci` wipes it. Embedders supply their
|
|
@@ -293,6 +299,82 @@ class Config {
|
|
|
293
299
|
};
|
|
294
300
|
}
|
|
295
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Admin UI credential and data-retention settings.
|
|
304
|
+
*
|
|
305
|
+
* `ADMIN_PASSWORD` is its own credential tier (SECURITY.md §3): it can
|
|
306
|
+
* read, edit, and delete every app's data, which neither a read key nor an
|
|
307
|
+
* ADMIN_API_KEYS provisioning key may do. It is read from the environment
|
|
308
|
+
* only, never from a config file, because it is a secret. The UI is
|
|
309
|
+
* disabled, failing closed, whenever it is absent.
|
|
310
|
+
*
|
|
311
|
+
* A password that is set but shorter than the minimum is kept (so
|
|
312
|
+
* validate() can refuse to boot on it in production) but never enables
|
|
313
|
+
* the UI.
|
|
314
|
+
*
|
|
315
|
+
* `TRASH_RETENTION_DAYS` bounds how long a soft-deleted view is kept before
|
|
316
|
+
* it is erased for good (GDPR Art. 5(1)(e), storage limitation). Zero keeps
|
|
317
|
+
* trash until an admin empties it by hand.
|
|
318
|
+
*
|
|
319
|
+
* `VIEW_LOG_RETENTION_DAYS` bounds the view register log, which gains a
|
|
320
|
+
* row for every accepted view. It holds no personal data, so this is about
|
|
321
|
+
* storage rather than law, but a log nobody bounds grows as large as all
|
|
322
|
+
* the app tables together. Zero keeps it forever. The admin log is never
|
|
323
|
+
* pruned: it is the record of who changed or erased what, and it grows only
|
|
324
|
+
* with admin activity.
|
|
325
|
+
*
|
|
326
|
+
* An out-of-range or unparseable retention falls back to the default.
|
|
327
|
+
*
|
|
328
|
+
* `ADMIN_SESSION_IDLE_TIMEOUT` and `ADMIN_SESSION_MAX_AGE` ("30m", "12h",
|
|
329
|
+
* "7d") bound how long a sign-in lasts: signed out after that long unused,
|
|
330
|
+
* and after that long regardless. Unlike a retention, a malformed timeout
|
|
331
|
+
* stops startup: silently using the default would give a typo like
|
|
332
|
+
* "30 min" a week-long session.
|
|
333
|
+
*/
|
|
334
|
+
loadAdminConfig() {
|
|
335
|
+
const password = this.env.ADMIN_PASSWORD || '';
|
|
336
|
+
const longEnough = password.length >= ADMIN.MIN_PASSWORD_LENGTH;
|
|
337
|
+
|
|
338
|
+
const retention = (raw, fallback, max) => {
|
|
339
|
+
const days = parseIntOr(raw, fallback);
|
|
340
|
+
return days >= 0 && days <= max ? days : fallback;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const duration = (field, fallback, min, max) => {
|
|
344
|
+
const raw = this.env[field];
|
|
345
|
+
if (raw === undefined || raw === '') return fallback;
|
|
346
|
+
const ms = parseDuration(raw);
|
|
347
|
+
if (ms === null || ms < min || ms > max) {
|
|
348
|
+
throw getError(ErrorType.CONFIG_INVALID_VALUE, {
|
|
349
|
+
field,
|
|
350
|
+
reason: `must be a duration such as 30m, 12h, or 7d, from ${formatDuration(min)} to ${formatDuration(max)}`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return ms;
|
|
354
|
+
};
|
|
355
|
+
const sessionIdleMs = duration('ADMIN_SESSION_IDLE_TIMEOUT',
|
|
356
|
+
ADMIN.SESSION_IDLE_TIMEOUT_MS, ADMIN.SESSION_IDLE_MIN_MS, ADMIN.SESSION_IDLE_MAX_MS);
|
|
357
|
+
const sessionMaxAgeMs = duration('ADMIN_SESSION_MAX_AGE',
|
|
358
|
+
ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS, ADMIN.SESSION_MAX_AGE_MIN_MS, ADMIN.SESSION_MAX_AGE_MAX_MS);
|
|
359
|
+
if (sessionIdleMs > sessionMaxAgeMs) {
|
|
360
|
+
throw getError(ErrorType.CONFIG_INVALID_VALUE, {
|
|
361
|
+
field: 'ADMIN_SESSION_IDLE_TIMEOUT',
|
|
362
|
+
reason: `must not be longer than ADMIN_SESSION_MAX_AGE (${formatDuration(sessionMaxAgeMs)})`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
password,
|
|
368
|
+
enabled: longEnough,
|
|
369
|
+
sessionIdleMs,
|
|
370
|
+
sessionMaxAgeMs,
|
|
371
|
+
trashRetentionDays: retention(
|
|
372
|
+
this.env.TRASH_RETENTION_DAYS, ADMIN.DEFAULT_TRASH_RETENTION_DAYS, ADMIN.MAX_TRASH_RETENTION_DAYS),
|
|
373
|
+
viewLogRetentionDays: retention(
|
|
374
|
+
this.env.VIEW_LOG_RETENTION_DAYS, ADMIN.DEFAULT_VIEW_LOG_RETENTION_DAYS, ADMIN.MAX_VIEW_LOG_RETENTION_DAYS),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
296
378
|
/**
|
|
297
379
|
* Fail-fast startup validation (CONFIG.md §3, SECURITY.md §1).
|
|
298
380
|
*
|
|
@@ -308,6 +390,8 @@ class Config {
|
|
|
308
390
|
// persist it fails at startup rather than on its first request.
|
|
309
391
|
void this.privacy.visitorSecret;
|
|
310
392
|
|
|
393
|
+
this.validateAdmin();
|
|
394
|
+
|
|
311
395
|
if (!this.server.isProduction) {
|
|
312
396
|
this.warnAboutDevelopmentDefaults();
|
|
313
397
|
return this;
|
|
@@ -338,6 +422,40 @@ class Config {
|
|
|
338
422
|
return this;
|
|
339
423
|
}
|
|
340
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Admin credential checks, shared by every environment.
|
|
427
|
+
*
|
|
428
|
+
* A password that is present but too short is a misconfiguration, not a
|
|
429
|
+
* choice, so production refuses to boot on it rather than silently leaving
|
|
430
|
+
* the UI off. Reusing an API key as the password collapses two tiers into
|
|
431
|
+
* one secret, which is worth a warning in any environment.
|
|
432
|
+
*/
|
|
433
|
+
validateAdmin() {
|
|
434
|
+
const { password, enabled } = this.admin;
|
|
435
|
+
|
|
436
|
+
if (!password) {
|
|
437
|
+
logWarning(WarningType.ADMIN_UI_DISABLED);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!enabled && this.server.isProduction) {
|
|
442
|
+
throw getError(ErrorType.CONFIG_INVALID_VALUE, {
|
|
443
|
+
field: 'ADMIN_PASSWORD',
|
|
444
|
+
reason: `must be at least ${ADMIN.MIN_PASSWORD_LENGTH} characters`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!enabled) {
|
|
449
|
+
logWarning(WarningType.ADMIN_UI_DISABLED);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const apiKeys = [...Object.keys(this.auth.readKeyScopes), ...this.auth.adminApiKeys];
|
|
454
|
+
if (apiKeys.includes(password)) {
|
|
455
|
+
logWarning(WarningType.ADMIN_PASSWORD_REUSED);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
341
459
|
/** Surface the same problems as warnings outside production. */
|
|
342
460
|
warnAboutDevelopmentDefaults() {
|
|
343
461
|
if (Object.keys(this.auth.readKeyScopes).length === 0) {
|
package/constants.js
CHANGED
|
@@ -14,9 +14,11 @@ const APP_SLUG = 'viewcounter';
|
|
|
14
14
|
|
|
15
15
|
const HTTP_STATUS = {
|
|
16
16
|
OK: 200,
|
|
17
|
+
NO_CONTENT: 204,
|
|
17
18
|
BAD_REQUEST: 400,
|
|
18
19
|
UNAUTHORIZED: 401,
|
|
19
20
|
FORBIDDEN: 403,
|
|
21
|
+
NOT_FOUND: 404,
|
|
20
22
|
UNPROCESSABLE_ENTITY: 422,
|
|
21
23
|
TOO_MANY_REQUESTS: 429,
|
|
22
24
|
INTERNAL_SERVER_ERROR: 500,
|
|
@@ -46,6 +48,18 @@ const FIELD_MAX_LENGTH = {
|
|
|
46
48
|
DEVICE_TYPE: 20,
|
|
47
49
|
SESSION_ID: 64,
|
|
48
50
|
EVENT_TYPE: 50,
|
|
51
|
+
/** Free-text admin annotation on a single view. */
|
|
52
|
+
NOTE: 1000,
|
|
53
|
+
/** CHAR(36): the canonical textual form of a UUID. */
|
|
54
|
+
UUID: 36,
|
|
55
|
+
/** The longest a DNS name can be. */
|
|
56
|
+
HOSTNAME: 253,
|
|
57
|
+
/** A primary language subtag: two or three letters, rarely up to eight. */
|
|
58
|
+
LANGUAGE: 8,
|
|
59
|
+
/** Each of utm_source, utm_medium, utm_campaign, utm_term, utm_content. */
|
|
60
|
+
UTM: 100,
|
|
61
|
+
REGION: 100,
|
|
62
|
+
CITY: 100,
|
|
49
63
|
};
|
|
50
64
|
|
|
51
65
|
/** Bounds for user-supplied pagination and range parameters. */
|
|
@@ -83,7 +97,9 @@ const DATABASE = {
|
|
|
83
97
|
QUERY_TIMEOUT_MS: 5_000,
|
|
84
98
|
CONNECT_TIMEOUT_MS: 10_000,
|
|
85
99
|
DEFAULT_PORT: 3306,
|
|
86
|
-
SCHEMA_VERSION: '
|
|
100
|
+
SCHEMA_VERSION: 'schema_v5',
|
|
101
|
+
/** Rows given a public_id per statement when backfilling an old table. */
|
|
102
|
+
BACKFILL_BATCH_SIZE: 500,
|
|
87
103
|
};
|
|
88
104
|
|
|
89
105
|
const SERVER = {
|
|
@@ -117,6 +133,296 @@ const PRIVACY = {
|
|
|
117
133
|
MIN_ADMIN_KEY_LENGTH: 32,
|
|
118
134
|
};
|
|
119
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Admin UI and API.
|
|
138
|
+
*
|
|
139
|
+
* The admin tier is a separate credential from both read keys and
|
|
140
|
+
* ADMIN_API_KEYS (SECURITY.md §3): it can read, edit, and delete every app's
|
|
141
|
+
* data, which neither of the other tiers may do.
|
|
142
|
+
*/
|
|
143
|
+
const ADMIN = {
|
|
144
|
+
/** Where the UI and its API are mounted. */
|
|
145
|
+
PATH_PREFIX: '/admin',
|
|
146
|
+
/** Relative to PATH_PREFIX. */
|
|
147
|
+
API_PATH: '/api',
|
|
148
|
+
SESSION_COOKIE: 'vc_admin_session',
|
|
149
|
+
CSRF_HEADER: 'x-csrf-token',
|
|
150
|
+
/** Long enough that the login rate limit makes guessing hopeless. */
|
|
151
|
+
MIN_PASSWORD_LENGTH: 16,
|
|
152
|
+
/** Longest submitted password even looked at; bounds the comparison cost. */
|
|
153
|
+
MAX_PASSWORD_INPUT_LENGTH: 1024,
|
|
154
|
+
SESSION_TOKEN_BYTES: 32,
|
|
155
|
+
CSRF_TOKEN_BYTES: 32,
|
|
156
|
+
/** Default for ADMIN_SESSION_IDLE_TIMEOUT: signed out after this long unused. */
|
|
157
|
+
SESSION_IDLE_TIMEOUT_MS: 7 * 24 * 60 * 60 * 1000,
|
|
158
|
+
/** Default for ADMIN_SESSION_MAX_AGE: signed out after this long regardless. */
|
|
159
|
+
SESSION_ABSOLUTE_TIMEOUT_MS: 30 * 24 * 60 * 60 * 1000,
|
|
160
|
+
SESSION_IDLE_MIN_MS: 5 * 60 * 1000,
|
|
161
|
+
SESSION_IDLE_MAX_MS: 90 * 24 * 60 * 60 * 1000,
|
|
162
|
+
SESSION_MAX_AGE_MIN_MS: 60 * 60 * 1000,
|
|
163
|
+
SESSION_MAX_AGE_MAX_MS: 365 * 24 * 60 * 60 * 1000,
|
|
164
|
+
/**
|
|
165
|
+
* A session's last-seen time is written at most this often, so an active
|
|
166
|
+
* admin costs one database write a minute rather than one per request.
|
|
167
|
+
*/
|
|
168
|
+
SESSION_TOUCH_INTERVAL_MS: 60 * 1000,
|
|
169
|
+
/** Permanent erasure needs the password to have been entered this recently. */
|
|
170
|
+
REAUTH_WINDOW_MS: 15 * 60 * 1000,
|
|
171
|
+
/** Oldest sessions are evicted beyond this, bounding memory. */
|
|
172
|
+
MAX_SESSIONS: 50,
|
|
173
|
+
LOGIN_RATE_LIMIT_WINDOW_MS: 15 * 60 * 1000,
|
|
174
|
+
/** Failed attempts per IP per window. Successful logins do not count. */
|
|
175
|
+
LOGIN_RATE_LIMIT_MAX: 5,
|
|
176
|
+
RATE_LIMIT_WINDOW_MS: 60 * 1000,
|
|
177
|
+
/** Requests per IP per window across the whole admin surface. */
|
|
178
|
+
RATE_LIMIT_MAX: 600,
|
|
179
|
+
/** Upper bound on the rows one batch operation may touch. */
|
|
180
|
+
MAX_BATCH_IDS: 500,
|
|
181
|
+
/**
|
|
182
|
+
* JSON body ceiling for the admin API. A full batch of MAX_BATCH_IDS
|
|
183
|
+
* UUIDs is about 20 kB on its own, above the public endpoints' limit.
|
|
184
|
+
*/
|
|
185
|
+
MAX_BODY_BYTES: 64 * 1024,
|
|
186
|
+
PAGE_SIZES: [25, 50, 100],
|
|
187
|
+
PAGE_SIZE_DEFAULT: 50,
|
|
188
|
+
PAGE_MAX: 100_000,
|
|
189
|
+
SEARCH_MAX_LENGTH: 200,
|
|
190
|
+
DEFAULT_TRASH_RETENTION_DAYS: 30,
|
|
191
|
+
MAX_TRASH_RETENTION_DAYS: 3650,
|
|
192
|
+
DEFAULT_VIEW_LOG_RETENTION_DAYS: 90,
|
|
193
|
+
MAX_VIEW_LOG_RETENTION_DAYS: 3650,
|
|
194
|
+
/** Rows removed per statement when pruning the view log, so no delete holds locks for long. */
|
|
195
|
+
VIEW_LOG_PRUNE_BATCH_SIZE: 5000,
|
|
196
|
+
/** How often expired trash and old view-log entries are checked for. */
|
|
197
|
+
RETENTION_INTERVAL_MS: 60 * 60 * 1000,
|
|
198
|
+
/** The tracking log's summary covers this many recent hours. */
|
|
199
|
+
TRACKING_SUMMARY_HOURS: 24,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Date ranges an admin listing and its analysis can be limited to, mapped to
|
|
204
|
+
* a number of days. `all` has no lower bound. Only these values reach SQL.
|
|
205
|
+
*/
|
|
206
|
+
const ADMIN_RANGE = {
|
|
207
|
+
DAY: '24h',
|
|
208
|
+
WEEK: '7d',
|
|
209
|
+
MONTH: '30d',
|
|
210
|
+
QUARTER: '90d',
|
|
211
|
+
YEAR: '1y',
|
|
212
|
+
ALL: 'all',
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const ADMIN_RANGE_DAYS = {
|
|
216
|
+
[ADMIN_RANGE.DAY]: 1,
|
|
217
|
+
[ADMIN_RANGE.WEEK]: 7,
|
|
218
|
+
[ADMIN_RANGE.MONTH]: 30,
|
|
219
|
+
[ADMIN_RANGE.QUARTER]: 90,
|
|
220
|
+
[ADMIN_RANGE.YEAR]: 365,
|
|
221
|
+
[ADMIN_RANGE.ALL]: null,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Time-series bucket for the admin analysis, chosen from the span of the data
|
|
226
|
+
* actually in the filtered set, so a chart never has thousands of points.
|
|
227
|
+
*/
|
|
228
|
+
const TREND_BUCKET = {
|
|
229
|
+
HOUR: 'hour',
|
|
230
|
+
DAY: 'day',
|
|
231
|
+
WEEK: 'week',
|
|
232
|
+
MONTH: 'month',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Largest span, in days, charted per day and per week. */
|
|
236
|
+
const TREND_BUCKET_MAX_DAYS = {
|
|
237
|
+
[TREND_BUCKET.HOUR]: 2,
|
|
238
|
+
[TREND_BUCKET.DAY]: 92,
|
|
239
|
+
[TREND_BUCKET.WEEK]: 731,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** Rows per breakdown returned by the admin analysis; the rest is "other". */
|
|
243
|
+
const ANALYSIS_TOP_N = 10;
|
|
244
|
+
|
|
245
|
+
/** Bounds of the admin analysis beyond the breakdowns. */
|
|
246
|
+
const ANALYSIS = {
|
|
247
|
+
/**
|
|
248
|
+
* A visit ends after this long without a page view, the common 30-minute
|
|
249
|
+
* convention. Visits are read from the rotating visitor hash, so they
|
|
250
|
+
* never link a visitor across days.
|
|
251
|
+
*/
|
|
252
|
+
VISIT_GAP_SECONDS: 30 * 60,
|
|
253
|
+
/** Page-to-page steps listed in the page flow. */
|
|
254
|
+
TRANSITIONS_TOP_N: 15,
|
|
255
|
+
/** The time-of-day heatmap reads at most the last year of the range. */
|
|
256
|
+
HEATMAP_MAX_DAYS: 365,
|
|
257
|
+
/** Most recent custom events whose properties are tallied. */
|
|
258
|
+
EVENT_PROPERTIES_SAMPLE: 5000,
|
|
259
|
+
/** Property values listed across all custom events. */
|
|
260
|
+
EVENT_PROPERTIES_TOP_N: 30,
|
|
261
|
+
/** "Right now" means the last few minutes; the live chart covers half an hour. */
|
|
262
|
+
REALTIME_VISITOR_MINUTES: 5,
|
|
263
|
+
REALTIME_CHART_MINUTES: 30,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** Which rows an admin listing returns. */
|
|
267
|
+
const VIEW_STATUS = {
|
|
268
|
+
ACTIVE: 'active',
|
|
269
|
+
DELETED: 'deleted',
|
|
270
|
+
ALL: 'all',
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** Filter on whether an admin has edited a row's content. */
|
|
274
|
+
const MODIFIED_FILTER = {
|
|
275
|
+
ANY: 'any',
|
|
276
|
+
MODIFIED: 'modified',
|
|
277
|
+
UNMODIFIED: 'unmodified',
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const SORT_ORDER = {
|
|
281
|
+
ASC: 'asc',
|
|
282
|
+
DESC: 'desc',
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Columns an admin listing may be sorted by, mapped from the API name to the
|
|
287
|
+
* column. Sorting interpolates the column, so only these values can reach SQL.
|
|
288
|
+
*/
|
|
289
|
+
const ADMIN_SORT_COLUMNS = {
|
|
290
|
+
timestamp: 'timestamp',
|
|
291
|
+
page: 'page_path',
|
|
292
|
+
country: 'country',
|
|
293
|
+
deviceSize: 'devicesize',
|
|
294
|
+
eventType: 'event_type',
|
|
295
|
+
source: 'source_type',
|
|
296
|
+
browser: 'browser',
|
|
297
|
+
os: 'os',
|
|
298
|
+
title: 'page_title',
|
|
299
|
+
hostname: 'hostname',
|
|
300
|
+
referrer: 'referrer_domain',
|
|
301
|
+
campaign: 'utm_campaign',
|
|
302
|
+
language: 'language',
|
|
303
|
+
engagedMs: 'engaged_ms',
|
|
304
|
+
modifiedAt: 'admin_modified_at',
|
|
305
|
+
deletedAt: 'deleted_at',
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Content fields an admin may edit, mapped from the API name to the column.
|
|
310
|
+
*
|
|
311
|
+
* Deliberately excludes everything that records who or when: the masked IP,
|
|
312
|
+
* visitor hash, timestamp, country, and every User-Agent-derived field. An
|
|
313
|
+
* edit can correct what was viewed, never fabricate who viewed it or when.
|
|
314
|
+
* `referrerDomain` and `sourceType` are not editable directly; they are
|
|
315
|
+
* re-derived whenever `referrer` changes, so they can never disagree with it.
|
|
316
|
+
*/
|
|
317
|
+
const EDITABLE_FIELDS = {
|
|
318
|
+
pagePath: 'page_path',
|
|
319
|
+
pageTitle: 'page_title',
|
|
320
|
+
referrer: 'referrer',
|
|
321
|
+
deviceSize: 'devicesize',
|
|
322
|
+
eventType: 'event_type',
|
|
323
|
+
eventData: 'event_data',
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** Every entry in the admin operation log is one of these. */
|
|
327
|
+
const ADMIN_ACTION = {
|
|
328
|
+
LOGIN_SUCCEEDED: 'login_succeeded',
|
|
329
|
+
LOGIN_FAILED: 'login_failed',
|
|
330
|
+
LOGOUT: 'logout',
|
|
331
|
+
VIEWS_EDITED: 'views_edited',
|
|
332
|
+
NOTE_SET: 'note_set',
|
|
333
|
+
NOTE_CLEARED: 'note_cleared',
|
|
334
|
+
VIEWS_DELETED: 'views_deleted',
|
|
335
|
+
VIEWS_RESTORED: 'views_restored',
|
|
336
|
+
VIEWS_PURGED: 'views_purged',
|
|
337
|
+
TRASH_AUTO_PURGED: 'trash_auto_purged',
|
|
338
|
+
VIEW_LOG_PRUNED: 'view_log_pruned',
|
|
339
|
+
/** The password entered again to allow a permanent erasure. */
|
|
340
|
+
PASSWORD_CONFIRMED: 'password_confirmed',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Machine-readable error codes returned by the admin API. The UI maps each one
|
|
345
|
+
* to a translated message, so no server-side English reaches the screen.
|
|
346
|
+
*/
|
|
347
|
+
const ADMIN_ERROR_CODE = {
|
|
348
|
+
UNAUTHENTICATED: 'UNAUTHENTICATED',
|
|
349
|
+
INVALID_PASSWORD: 'INVALID_PASSWORD',
|
|
350
|
+
TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS',
|
|
351
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
352
|
+
CSRF_REJECTED: 'CSRF_REJECTED',
|
|
353
|
+
/** The action needs the password entered again (POST /reauth), then a retry. */
|
|
354
|
+
REAUTH_REQUIRED: 'REAUTH_REQUIRED',
|
|
355
|
+
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
356
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
357
|
+
SERVER_ERROR: 'SERVER_ERROR',
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/** Which tracking endpoint a request came through. */
|
|
361
|
+
const VIEW_LOG_SOURCE = {
|
|
362
|
+
REGISTER_VIEW: 'registerView',
|
|
363
|
+
EVENT: 'event',
|
|
364
|
+
/** Time on page and scroll depth for a view already recorded. */
|
|
365
|
+
ENGAGE: 'engage',
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** Service-owned tables. All carry the reserved `_` prefix. */
|
|
369
|
+
const ADMIN_LOG_TABLE = '_admin_log';
|
|
370
|
+
const ADMIN_SESSIONS_TABLE = '_admin_sessions';
|
|
371
|
+
const VIEW_LOG_TABLE = '_view_log';
|
|
372
|
+
/** Tracking requests that were not stored, counted per minute. */
|
|
373
|
+
const TRACKING_REJECTIONS_TABLE = '_tracking_rejections';
|
|
374
|
+
|
|
375
|
+
/** The tracking pipeline's own bounds. */
|
|
376
|
+
const TRACKING = {
|
|
377
|
+
/** Longest visible time one page view may report. */
|
|
378
|
+
MAX_ENGAGED_MS: 6 * 60 * 60 * 1000,
|
|
379
|
+
/** Engagement is only accepted for a view recorded this recently. */
|
|
380
|
+
ENGAGE_WINDOW_HOURS: 24,
|
|
381
|
+
/** A beacon is a few dozen bytes; this bounds what is even parsed. */
|
|
382
|
+
ENGAGE_BODY_BYTES: 1024,
|
|
383
|
+
/** How often counted rejections are written to the database. */
|
|
384
|
+
REJECTION_FLUSH_MS: 15_000,
|
|
385
|
+
/**
|
|
386
|
+
* Distinct rejection keys kept with their app, detail, and hostname, per
|
|
387
|
+
* minute and per hour, however often the counts are written. Beyond these,
|
|
388
|
+
* a request is still counted, by minute, endpoint, and reason alone, so a
|
|
389
|
+
* flood of made-up values cannot grow memory or the table: at most a few
|
|
390
|
+
* thousand rows an hour, whatever arrives.
|
|
391
|
+
*/
|
|
392
|
+
REJECTION_MAX_KEYS_PER_MINUTE: 100,
|
|
393
|
+
REJECTION_MAX_KEYS_PER_HOUR: 1000,
|
|
394
|
+
/** Rejections are only ever stored as per-minute counts. */
|
|
395
|
+
REJECTION_BUCKET_MS: 60 * 1000,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/** What happened to one tracking request, as the tracking log reports it. */
|
|
399
|
+
const TRACKING_OUTCOME = {
|
|
400
|
+
/** Stored, and the first view of the page by this visitor in the window. */
|
|
401
|
+
RECORDED: 'recorded',
|
|
402
|
+
/** Stored, but the same visitor viewed it again within the window. */
|
|
403
|
+
REPEAT: 'repeat',
|
|
404
|
+
/** A crawler, preview fetcher, or headless browser; counted, not stored. */
|
|
405
|
+
BOT: 'bot',
|
|
406
|
+
/** Refused; the reason says why. */
|
|
407
|
+
REJECTED: 'rejected',
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/** Why a tracking request was not stored. */
|
|
411
|
+
const REJECTION_REASON = {
|
|
412
|
+
BOT: 'bot',
|
|
413
|
+
UNKNOWN_APP: 'unknown_app',
|
|
414
|
+
ORIGIN_NOT_ALLOWED: 'origin_not_allowed',
|
|
415
|
+
INVALID_REQUEST: 'invalid_request',
|
|
416
|
+
INVALID_IP: 'invalid_ip',
|
|
417
|
+
RATE_LIMITED: 'rate_limited',
|
|
418
|
+
/** Engagement for a view that does not exist, is trashed, or is too old. */
|
|
419
|
+
UNKNOWN_VIEW: 'unknown_view',
|
|
420
|
+
SERVER_ERROR: 'server_error',
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/** Canonical UUID text form, any version. Admin row IDs are validated with it. */
|
|
424
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
425
|
+
|
|
120
426
|
/** Recognised event types. `pageview` is the only one the server itself emits. */
|
|
121
427
|
const EVENT_TYPE = {
|
|
122
428
|
PAGEVIEW: 'pageview',
|
|
@@ -139,6 +445,8 @@ const SOURCE_TYPE = {
|
|
|
139
445
|
EMAIL: 'email',
|
|
140
446
|
CAMPAIGN: 'campaign',
|
|
141
447
|
REFERRAL: 'referral',
|
|
448
|
+
/** From another page of the same site: a click within it, not a way in. */
|
|
449
|
+
INTERNAL: 'internal',
|
|
142
450
|
UNKNOWN: 'unknown',
|
|
143
451
|
};
|
|
144
452
|
|
|
@@ -171,7 +479,7 @@ const SCOPE_ALL = '*';
|
|
|
171
479
|
* identifiers cannot be bound as parameters. Until now app IDs only ever came
|
|
172
480
|
* from trusted local config; they can now arrive over HTTP from the admin API,
|
|
173
481
|
* so the character set is restricted to what is unambiguously safe as an
|
|
174
|
-
* identifier. This is the gate
|
|
482
|
+
* identifier. This is the gate, not a nicety.
|
|
175
483
|
*
|
|
176
484
|
* Letters, digits, underscore, and hyphen only. A backtick is the sole
|
|
177
485
|
* character that can terminate a quoted identifier, and none of these can;
|
|
@@ -180,7 +488,7 @@ const SCOPE_ALL = '*';
|
|
|
180
488
|
*/
|
|
181
489
|
const APP_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
182
490
|
|
|
183
|
-
/** Reserved prefix for the service's own tables (`_migrations`, `_apps
|
|
491
|
+
/** Reserved prefix for the service's own tables (`_migrations`, `_apps`, logs). */
|
|
184
492
|
const RESERVED_TABLE_PREFIX = '_';
|
|
185
493
|
|
|
186
494
|
/** Internal registry of dynamically provisioned apps. */
|
|
@@ -213,6 +521,29 @@ module.exports = {
|
|
|
213
521
|
DATABASE,
|
|
214
522
|
SERVER,
|
|
215
523
|
PRIVACY,
|
|
524
|
+
ADMIN,
|
|
525
|
+
VIEW_STATUS,
|
|
526
|
+
ADMIN_RANGE,
|
|
527
|
+
ADMIN_RANGE_DAYS,
|
|
528
|
+
TREND_BUCKET,
|
|
529
|
+
TREND_BUCKET_MAX_DAYS,
|
|
530
|
+
ANALYSIS_TOP_N,
|
|
531
|
+
ANALYSIS,
|
|
532
|
+
MODIFIED_FILTER,
|
|
533
|
+
SORT_ORDER,
|
|
534
|
+
ADMIN_SORT_COLUMNS,
|
|
535
|
+
EDITABLE_FIELDS,
|
|
536
|
+
ADMIN_ACTION,
|
|
537
|
+
ADMIN_ERROR_CODE,
|
|
538
|
+
VIEW_LOG_SOURCE,
|
|
539
|
+
ADMIN_LOG_TABLE,
|
|
540
|
+
ADMIN_SESSIONS_TABLE,
|
|
541
|
+
VIEW_LOG_TABLE,
|
|
542
|
+
TRACKING_REJECTIONS_TABLE,
|
|
543
|
+
TRACKING,
|
|
544
|
+
TRACKING_OUTCOME,
|
|
545
|
+
REJECTION_REASON,
|
|
546
|
+
UUID_PATTERN,
|
|
216
547
|
EVENT_TYPE,
|
|
217
548
|
TREND_PERIOD,
|
|
218
549
|
TREND_PERIODS,
|