@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,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boundary validation for the admin API (CODE_STANDARDS.md §6).
|
|
3
|
+
*
|
|
4
|
+
* The admin is authenticated, but authenticated is not the same as trusted
|
|
5
|
+
* input: a stolen session, a buggy client, or a crafted request all arrive
|
|
6
|
+
* here. Every value is bounded, every enum-like value is checked against an
|
|
7
|
+
* allowlist, and a batch can never exceed ADMIN.MAX_BATCH_IDS rows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { body, param, query, validationResult } = require('express-validator');
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
ADMIN,
|
|
14
|
+
ADMIN_ACTION,
|
|
15
|
+
ADMIN_RANGE,
|
|
16
|
+
ADMIN_ERROR_CODE,
|
|
17
|
+
ADMIN_SORT_COLUMNS,
|
|
18
|
+
EDITABLE_FIELDS,
|
|
19
|
+
FIELD_MAX_LENGTH,
|
|
20
|
+
HTTP_STATUS,
|
|
21
|
+
MODIFIED_FILTER,
|
|
22
|
+
PAYLOAD_LIMITS,
|
|
23
|
+
SORT_ORDER,
|
|
24
|
+
UUID_PATTERN,
|
|
25
|
+
VIEW_LOG_SOURCE,
|
|
26
|
+
TRACKING_OUTCOME,
|
|
27
|
+
VIEW_STATUS,
|
|
28
|
+
} = require('../constants');
|
|
29
|
+
const ReferrerParser = require('../utils/referrerParser');
|
|
30
|
+
const { FILTER_COLUMNS } = require('../db/analysis');
|
|
31
|
+
const { jsonByteLength } = require('../utils/stringUtils');
|
|
32
|
+
|
|
33
|
+
const within = (values) => (value) => Object.values(values).includes(value);
|
|
34
|
+
|
|
35
|
+
/** appId path parameter: must be a currently allowed app. */
|
|
36
|
+
const adminAppIdParam = (allowed) =>
|
|
37
|
+
param('appId')
|
|
38
|
+
.custom((value) => allowed.appId.includes(value)).withMessage('Unknown appId');
|
|
39
|
+
|
|
40
|
+
/** Optional appId filter on a log listing. */
|
|
41
|
+
const adminAppIdFilter = (allowed) =>
|
|
42
|
+
query('appId')
|
|
43
|
+
.optional()
|
|
44
|
+
.custom((value) => allowed.appId.includes(value)).withMessage('Unknown appId');
|
|
45
|
+
|
|
46
|
+
const pageQuery = () =>
|
|
47
|
+
query('page')
|
|
48
|
+
.optional()
|
|
49
|
+
.isInt({ min: 1, max: ADMIN.PAGE_MAX }).withMessage(`page must be an integer between 1 and ${ADMIN.PAGE_MAX}`);
|
|
50
|
+
|
|
51
|
+
const pageSizeQuery = () =>
|
|
52
|
+
query('pageSize')
|
|
53
|
+
.optional()
|
|
54
|
+
.custom((value) => ADMIN.PAGE_SIZES.includes(Number(value)))
|
|
55
|
+
.withMessage(`pageSize must be one of: ${ADMIN.PAGE_SIZES.join(', ')}`);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `ids`: a non-empty array of distinct UUIDs, at most MAX_BATCH_IDS long.
|
|
59
|
+
* Checked as a whole before any element is inspected, so an enormous array is
|
|
60
|
+
* rejected on its length rather than walked.
|
|
61
|
+
*/
|
|
62
|
+
const idsBody = () =>
|
|
63
|
+
body('ids')
|
|
64
|
+
.isArray({ min: 1, max: ADMIN.MAX_BATCH_IDS })
|
|
65
|
+
.withMessage(`ids must be an array of 1 to ${ADMIN.MAX_BATCH_IDS} view IDs`)
|
|
66
|
+
.bail()
|
|
67
|
+
.custom((ids) => ids.every((id) => typeof id === 'string' && UUID_PATTERN.test(id)))
|
|
68
|
+
.withMessage('every id must be a UUID')
|
|
69
|
+
.bail()
|
|
70
|
+
.custom((ids) => new Set(ids).size === ids.length)
|
|
71
|
+
.withMessage('ids must not repeat');
|
|
72
|
+
|
|
73
|
+
const validateLogin = () => [
|
|
74
|
+
body('password')
|
|
75
|
+
.isString().withMessage('password is required')
|
|
76
|
+
.isLength({ min: 1, max: ADMIN.MAX_PASSWORD_INPUT_LENGTH })
|
|
77
|
+
.withMessage('password is required'),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** The longest breakdown value a `where` filter can hold: a referrer or page path. */
|
|
81
|
+
const WHERE_VALUE_MAX_LENGTH = Math.max(FIELD_MAX_LENGTH.REFERRER, FIELD_MAX_LENGTH.PAGE_PATH);
|
|
82
|
+
/** Every dimension at its longest, as JSON, with room for escaping. */
|
|
83
|
+
const WHERE_MAX_LENGTH = Object.keys(FILTER_COLUMNS).length * (WHERE_VALUE_MAX_LENGTH + 32) * 2;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read `where`: breakdown filters as a JSON object of dimension name to
|
|
87
|
+
* value, with null for rows that have none, such as {"country":"DE"}.
|
|
88
|
+
* @param {unknown} raw
|
|
89
|
+
* @returns {{ ok: true, filters: Record<string, string|null> } | { ok: false, error: string }}
|
|
90
|
+
*/
|
|
91
|
+
function readWhere(raw) {
|
|
92
|
+
if (raw === undefined || raw === '') return { ok: true, filters: {} };
|
|
93
|
+
if (typeof raw !== 'string' || raw.length > WHERE_MAX_LENGTH) return { ok: false, error: 'where must be a short JSON object' };
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(raw);
|
|
97
|
+
} catch {
|
|
98
|
+
return { ok: false, error: 'where must be a JSON object' };
|
|
99
|
+
}
|
|
100
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, error: 'where must be a JSON object' };
|
|
101
|
+
const filters = {};
|
|
102
|
+
for (const [dim, value] of Object.entries(parsed)) {
|
|
103
|
+
if (!Object.hasOwn(FILTER_COLUMNS, dim)) return { ok: false, error: 'where names a dimension that cannot be filtered' };
|
|
104
|
+
if (value !== null && (typeof value !== 'string' || value.length > WHERE_VALUE_MAX_LENGTH)) {
|
|
105
|
+
return { ok: false, error: `each where value must be null or a string of at most ${WHERE_VALUE_MAX_LENGTH} characters` };
|
|
106
|
+
}
|
|
107
|
+
filters[dim] = value;
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, filters };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The breakdown filters of a request that passed validation.
|
|
114
|
+
* @param {unknown} raw
|
|
115
|
+
* @returns {Record<string, string|null>}
|
|
116
|
+
*/
|
|
117
|
+
function parseWhere(raw) {
|
|
118
|
+
const result = readWhere(raw);
|
|
119
|
+
return result.ok ? result.filters : {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Filters shared by a listing and its analysis, for one app or all. */
|
|
123
|
+
const filterQueries = () => [
|
|
124
|
+
query('status').optional().custom(within(VIEW_STATUS)).withMessage('Invalid status'),
|
|
125
|
+
query('modified').optional().custom(within(MODIFIED_FILTER)).withMessage('Invalid modified filter'),
|
|
126
|
+
query('range').optional().custom(within(ADMIN_RANGE)).withMessage('Invalid range'),
|
|
127
|
+
query('eventType')
|
|
128
|
+
.optional()
|
|
129
|
+
.isString().withMessage('eventType must be a string')
|
|
130
|
+
.isLength({ max: FIELD_MAX_LENGTH.EVENT_TYPE })
|
|
131
|
+
.withMessage(`eventType must be at most ${FIELD_MAX_LENGTH.EVENT_TYPE} characters`),
|
|
132
|
+
query('search')
|
|
133
|
+
.optional()
|
|
134
|
+
.isString().withMessage('search must be a string')
|
|
135
|
+
.isLength({ max: ADMIN.SEARCH_MAX_LENGTH })
|
|
136
|
+
.withMessage(`search must be at most ${ADMIN.SEARCH_MAX_LENGTH} characters`),
|
|
137
|
+
query('where')
|
|
138
|
+
.optional()
|
|
139
|
+
.custom((value) => readWhere(value).ok)
|
|
140
|
+
.withMessage((value) => readWhere(value).error),
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** A listing of one app (with `allowed`) or of every app (without). */
|
|
144
|
+
const validateViewListing = (allowed) => [
|
|
145
|
+
...(allowed ? [adminAppIdParam(allowed)] : []),
|
|
146
|
+
...filterQueries(),
|
|
147
|
+
query('sort').optional().custom((value) => Object.hasOwn(ADMIN_SORT_COLUMNS, value)).withMessage('Invalid sort'),
|
|
148
|
+
query('order').optional().custom(within(SORT_ORDER)).withMessage('Invalid order'),
|
|
149
|
+
pageQuery(),
|
|
150
|
+
pageSizeQuery(),
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/** The analysis of one app (with `allowed`) or of every app (without). */
|
|
154
|
+
const validateAnalysis = (allowed) => [
|
|
155
|
+
...(allowed ? [adminAppIdParam(allowed)] : []),
|
|
156
|
+
...filterQueries(),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
/** The event types of one app (with `allowed`) or of every app, in a status. */
|
|
160
|
+
const validateEventTypes = (allowed) => [
|
|
161
|
+
...(allowed ? [adminAppIdParam(allowed)] : []),
|
|
162
|
+
query('status').optional().custom(within(VIEW_STATUS)).withMessage('Invalid status'),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/** Right now takes no filters, only the app for the per-app route. */
|
|
166
|
+
const validateRealtime = (allowed) => (allowed ? [adminAppIdParam(allowed)] : []);
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validate one field of an edit and return the column value to store.
|
|
171
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, error: string }}
|
|
172
|
+
*/
|
|
173
|
+
function checkField(field, value, deviceSizes) {
|
|
174
|
+
const optionalText = (max) => {
|
|
175
|
+
if (value === null || value === '') return { ok: true, value: null };
|
|
176
|
+
if (typeof value !== 'string') return { ok: false, error: `${field} must be a string or null` };
|
|
177
|
+
if (value.length > max) return { ok: false, error: `${field} must be at most ${max} characters` };
|
|
178
|
+
return { ok: true, value };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
switch (field) {
|
|
182
|
+
case 'pagePath':
|
|
183
|
+
return optionalText(FIELD_MAX_LENGTH.PAGE_PATH);
|
|
184
|
+
case 'pageTitle':
|
|
185
|
+
return optionalText(FIELD_MAX_LENGTH.PAGE_TITLE);
|
|
186
|
+
case 'referrer':
|
|
187
|
+
return optionalText(FIELD_MAX_LENGTH.REFERRER);
|
|
188
|
+
case 'deviceSize':
|
|
189
|
+
return deviceSizes.includes(value)
|
|
190
|
+
? { ok: true, value }
|
|
191
|
+
: { ok: false, error: `deviceSize must be one of: ${deviceSizes.join(', ')}` };
|
|
192
|
+
case 'eventType':
|
|
193
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
194
|
+
return { ok: false, error: 'eventType must be a non-empty string' };
|
|
195
|
+
}
|
|
196
|
+
if (value.length > FIELD_MAX_LENGTH.EVENT_TYPE) {
|
|
197
|
+
return { ok: false, error: `eventType must be at most ${FIELD_MAX_LENGTH.EVENT_TYPE} characters` };
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, value };
|
|
200
|
+
case 'eventData':
|
|
201
|
+
if (value === null) return { ok: true, value: null };
|
|
202
|
+
if (typeof value !== 'object') return { ok: false, error: 'eventData must be an object, an array, or null' };
|
|
203
|
+
if (jsonByteLength(value) > PAYLOAD_LIMITS.MAX_EVENT_DATA_BYTES) {
|
|
204
|
+
return { ok: false, error: `eventData must serialize to at most ${PAYLOAD_LIMITS.MAX_EVENT_DATA_BYTES} bytes` };
|
|
205
|
+
}
|
|
206
|
+
return { ok: true, value: JSON.stringify(value) };
|
|
207
|
+
default:
|
|
208
|
+
return { ok: false, error: `${field} is not an editable field` };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Turn an edit's `changes` object into column values.
|
|
214
|
+
*
|
|
215
|
+
* Only EDITABLE_FIELDS may appear. A change to `referrer` also re-derives
|
|
216
|
+
* `referrer_domain` and `source_type` with the same parser the write path
|
|
217
|
+
* uses, and stores the referrer the same way (origin and path only). Whether
|
|
218
|
+
* it is on the row's own site, and so internal, depends on each row's site,
|
|
219
|
+
* which the repository applies per row (AdminRepository.updateContent).
|
|
220
|
+
*
|
|
221
|
+
* @param {unknown} changes
|
|
222
|
+
* @param {string[]} deviceSizes
|
|
223
|
+
* @returns {{ ok: true, columns: Record<string, unknown>, fields: string[] } | { ok: false, error: string }}
|
|
224
|
+
*/
|
|
225
|
+
function resolveChanges(changes, deviceSizes) {
|
|
226
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
|
|
227
|
+
return { ok: false, error: 'changes must be an object' };
|
|
228
|
+
}
|
|
229
|
+
const fields = Object.keys(changes);
|
|
230
|
+
if (fields.length === 0) return { ok: false, error: 'changes must name at least one field' };
|
|
231
|
+
|
|
232
|
+
const columns = {};
|
|
233
|
+
for (const field of fields) {
|
|
234
|
+
if (!Object.hasOwn(EDITABLE_FIELDS, field)) {
|
|
235
|
+
return { ok: false, error: `${field} is not an editable field` };
|
|
236
|
+
}
|
|
237
|
+
const result = checkField(field, changes[field], deviceSizes);
|
|
238
|
+
if (!result.ok) return result;
|
|
239
|
+
columns[EDITABLE_FIELDS[field]] = result.value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (Object.hasOwn(changes, 'referrer')) {
|
|
243
|
+
const parsed = ReferrerParser.parse(columns.referrer);
|
|
244
|
+
columns.referrer = parsed.referrer;
|
|
245
|
+
columns.referrer_domain = parsed.referrerDomain;
|
|
246
|
+
columns.source_type = parsed.sourceType;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { ok: true, columns, fields };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const validateEdit = (allowed) => [
|
|
253
|
+
adminAppIdParam(allowed),
|
|
254
|
+
idsBody(),
|
|
255
|
+
body('changes')
|
|
256
|
+
.custom((changes, { req }) => {
|
|
257
|
+
const result = resolveChanges(changes, allowed.deviceSize);
|
|
258
|
+
req.resolvedChanges = result;
|
|
259
|
+
return result.ok;
|
|
260
|
+
})
|
|
261
|
+
.withMessage((value, { req }) => req.resolvedChanges?.error),
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const validateNote = (allowed) => [
|
|
265
|
+
adminAppIdParam(allowed),
|
|
266
|
+
idsBody(),
|
|
267
|
+
body('note')
|
|
268
|
+
.custom((note) => note === null || typeof note === 'string').withMessage('note must be a string or null')
|
|
269
|
+
.bail()
|
|
270
|
+
.custom((note) => note === null || note.length <= FIELD_MAX_LENGTH.NOTE)
|
|
271
|
+
.withMessage(`note must be at most ${FIELD_MAX_LENGTH.NOTE} characters`),
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
const validateBatch = (allowed) => [adminAppIdParam(allowed), idsBody()];
|
|
275
|
+
|
|
276
|
+
const validateAdminLogListing = (allowed) => [
|
|
277
|
+
pageQuery(),
|
|
278
|
+
pageSizeQuery(),
|
|
279
|
+
adminAppIdFilter(allowed),
|
|
280
|
+
query('action').optional().custom(within(ADMIN_ACTION)).withMessage('Invalid action'),
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
const validateTrackingLogListing = (allowed) => [
|
|
284
|
+
pageQuery(),
|
|
285
|
+
pageSizeQuery(),
|
|
286
|
+
adminAppIdFilter(allowed),
|
|
287
|
+
query('source').optional().custom(within(VIEW_LOG_SOURCE)).withMessage('Invalid source'),
|
|
288
|
+
query('outcome').optional().custom(within(TRACKING_OUTCOME)).withMessage('Invalid outcome'),
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
const validateTrackingSummary = (allowed) => [adminAppIdFilter(allowed)];
|
|
292
|
+
|
|
293
|
+
/** Admin-shaped validation failure: a stable code plus per-field detail. */
|
|
294
|
+
function handleAdminValidation(req, res, next) {
|
|
295
|
+
const errors = validationResult(req);
|
|
296
|
+
if (errors.isEmpty()) return next();
|
|
297
|
+
return res.status(HTTP_STATUS.UNPROCESSABLE_ENTITY).json({
|
|
298
|
+
code: ADMIN_ERROR_CODE.VALIDATION_FAILED,
|
|
299
|
+
errors: errors.array().map((error) => ({ field: error.path, message: error.msg })),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
module.exports = {
|
|
304
|
+
parseWhere,
|
|
305
|
+
validateLogin,
|
|
306
|
+
validateViewListing,
|
|
307
|
+
validateAnalysis,
|
|
308
|
+
validateEventTypes,
|
|
309
|
+
validateRealtime,
|
|
310
|
+
validateEdit,
|
|
311
|
+
validateNote,
|
|
312
|
+
validateBatch,
|
|
313
|
+
validateAdminLogListing,
|
|
314
|
+
validateTrackingLogListing,
|
|
315
|
+
validateTrackingSummary,
|
|
316
|
+
handleAdminValidation,
|
|
317
|
+
resolveChanges,
|
|
318
|
+
checkField,
|
|
319
|
+
};
|
package/middleware/auth.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two distinct steps, deliberately separate:
|
|
5
5
|
*
|
|
6
|
-
* requireReadApiKey
|
|
7
|
-
* requireAppScope
|
|
6
|
+
* requireReadApiKey *authentication*: is this a key we issued?
|
|
7
|
+
* requireAppScope *authorization*: may THIS key read THIS app?
|
|
8
8
|
*
|
|
9
9
|
* The second step is what makes the service multi-tenant. Without it a valid
|
|
10
10
|
* key read every tenant's analytics, because the appId allowlist only ever
|
package/middleware/security.js
CHANGED
|
@@ -10,7 +10,7 @@ const { HTTP_STATUS } = require('../constants');
|
|
|
10
10
|
* agent-instructions SECURITY.md §9: `cors()` with no options is never the
|
|
11
11
|
* default. The wildcard mattered more here than the usual "no credentials, so
|
|
12
12
|
* it's harmless" reasoning suggests, because the read endpoints served real
|
|
13
|
-
* data
|
|
13
|
+
* data: `*` made them script-readable from any origin, not merely reachable.
|
|
14
14
|
*
|
|
15
15
|
* @param {string[]} allowedOrigins
|
|
16
16
|
* @returns {import('cors').CorsOptions}
|
|
@@ -32,6 +32,26 @@ function buildCorsOptions(allowedOrigins) {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Before a browser sends JSON to another site, it asks (a CORS preflight). A
|
|
37
|
+
* site missing from CORS_ORIGINS is refused there, and the request itself
|
|
38
|
+
* never arrives, so nothing else could count it. This counts the refusal, for
|
|
39
|
+
* the tracking-log paths `isTrackingPath` names, so a misconfigured
|
|
40
|
+
* CORS_ORIGINS shows up in the tracking log. Place it before `cors()`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} allowedOrigins as given to buildCorsOptions
|
|
43
|
+
* @param {{ isTrackingPath: (path: string) => boolean, onRefused: (req: import('express').Request) => void }} hooks
|
|
44
|
+
* @returns {import('express').RequestHandler}
|
|
45
|
+
*/
|
|
46
|
+
function countRefusedPreflights(allowedOrigins, { isTrackingPath, onRefused }) {
|
|
47
|
+
const allowlist = new Set(allowedOrigins);
|
|
48
|
+
return (req, res, next) => {
|
|
49
|
+
const origin = req.get('origin');
|
|
50
|
+
if (req.method === 'OPTIONS' && origin && !allowlist.has(origin) && isTrackingPath(req.path)) onRefused(req);
|
|
51
|
+
next();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
/**
|
|
36
56
|
* Extract the requesting origin, falling back to the referrer's origin.
|
|
37
57
|
* @returns {string|null}
|
|
@@ -64,9 +84,11 @@ function requestOrigin(req) {
|
|
|
64
84
|
* every appId still in that state.
|
|
65
85
|
*
|
|
66
86
|
* @param {{ origins: Record<string, string[]> }} allowed
|
|
87
|
+
* @param {{ onReject?: (req: import('express').Request, appId: string) => void }} [options]
|
|
88
|
+
* told about each refusal, so the tracking log can count it
|
|
67
89
|
* @returns {import('express').RequestHandler}
|
|
68
90
|
*/
|
|
69
|
-
function requireRegisteredOrigin(allowed) {
|
|
91
|
+
function requireRegisteredOrigin(allowed, { onReject = () => {} } = {}) {
|
|
70
92
|
const origins = allowed?.origins || {};
|
|
71
93
|
|
|
72
94
|
return (req, res, next) => {
|
|
@@ -82,6 +104,7 @@ function requireRegisteredOrigin(allowed) {
|
|
|
82
104
|
return next();
|
|
83
105
|
}
|
|
84
106
|
|
|
107
|
+
onReject(req, appId);
|
|
85
108
|
return res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
86
109
|
message: 'Request origin is not registered for this appId',
|
|
87
110
|
});
|
|
@@ -103,6 +126,7 @@ function noStore(req, res, next) {
|
|
|
103
126
|
|
|
104
127
|
module.exports = {
|
|
105
128
|
buildCorsOptions,
|
|
129
|
+
countRefusedPreflights,
|
|
106
130
|
requireRegisteredOrigin,
|
|
107
131
|
requestOrigin,
|
|
108
132
|
noStore,
|
package/middleware/validation.js
CHANGED
|
@@ -5,8 +5,12 @@ const {
|
|
|
5
5
|
HTTP_STATUS,
|
|
6
6
|
PAYLOAD_LIMITS,
|
|
7
7
|
QUERY_LIMITS,
|
|
8
|
+
REJECTION_REASON,
|
|
9
|
+
TRACKING,
|
|
8
10
|
TREND_PERIODS,
|
|
11
|
+
UUID_PATTERN,
|
|
9
12
|
} = require('../constants');
|
|
13
|
+
const { UTM_PARAMETERS } = require('../utils/visitorContext');
|
|
10
14
|
const { jsonByteLength } = require('../utils/stringUtils');
|
|
11
15
|
const { isValidAppId } = require('../utils/appIdUtils');
|
|
12
16
|
|
|
@@ -60,7 +64,7 @@ const boundedBody = (name, max) =>
|
|
|
60
64
|
*
|
|
61
65
|
* Deliberately no `.toInt()` sanitizer: under Express 5 `req.query` is a
|
|
62
66
|
* getter-only property, so a sanitizer appears to work but never writes the
|
|
63
|
-
* coerced value back
|
|
67
|
+
* coerced value back: the handler would still receive a string and bind it
|
|
64
68
|
* into `LIMIT ?`, which MySQL rejects. Handlers coerce explicitly instead,
|
|
65
69
|
* after this validator has established the value is a valid integer in range.
|
|
66
70
|
*/
|
|
@@ -83,6 +87,24 @@ const validateRegisterView = (allowedValues) => [
|
|
|
83
87
|
boundedQuery('title', FIELD_MAX_LENGTH.PAGE_TITLE),
|
|
84
88
|
boundedQuery('referrer', FIELD_MAX_LENGTH.REFERRER),
|
|
85
89
|
boundedQuery('sessionId', FIELD_MAX_LENGTH.SESSION_ID),
|
|
90
|
+
...UTM_PARAMETERS.map((name) => boundedQuery(name, FIELD_MAX_LENGTH.UTM)),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate an engagement beacon: how long a recorded view's page was visible
|
|
95
|
+
* and how far it was scrolled.
|
|
96
|
+
*/
|
|
97
|
+
const validateEngage = (allowedValues) => [
|
|
98
|
+
body('appId')
|
|
99
|
+
.notEmpty().withMessage('appId is required')
|
|
100
|
+
.custom((value) => allowedValues.appId.includes(value)).withMessage('Invalid appId'),
|
|
101
|
+
body('id')
|
|
102
|
+
.isString().withMessage('id must be a string')
|
|
103
|
+
.matches(UUID_PATTERN).withMessage('id must be a view ID'),
|
|
104
|
+
body('ms')
|
|
105
|
+
.isInt({ min: 0, max: TRACKING.MAX_ENGAGED_MS }).withMessage(`ms must be an integer between 0 and ${TRACKING.MAX_ENGAGED_MS}`),
|
|
106
|
+
body('scroll')
|
|
107
|
+
.isInt({ min: 0, max: 100 }).withMessage('scroll must be an integer between 0 and 100'),
|
|
86
108
|
];
|
|
87
109
|
|
|
88
110
|
/**
|
|
@@ -165,7 +187,7 @@ const validateSessionRequest = (allowedValues) => [
|
|
|
165
187
|
* Validate an app-provisioning request.
|
|
166
188
|
*
|
|
167
189
|
* `appId` here becomes a table identifier, so it is checked against the strict
|
|
168
|
-
* pattern rather than an allowlist
|
|
190
|
+
* pattern rather than an allowlist: there is no allowlist yet, that is the
|
|
169
191
|
* point of the call.
|
|
170
192
|
*/
|
|
171
193
|
const validateAppRegistration = () => [
|
|
@@ -199,14 +221,40 @@ const handleValidationErrors = (req, res, next) => {
|
|
|
199
221
|
return next();
|
|
200
222
|
};
|
|
201
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The same 422 as handleValidationErrors, for a tracking endpoint: the refusal
|
|
226
|
+
* is also reported, so the tracking log can say why a site's views are not
|
|
227
|
+
* arriving. An appId that was given but is not allowed is its own reason,
|
|
228
|
+
* since a misspelled one is the commonest cause; anything else, including a
|
|
229
|
+
* missing appId, names the first field that failed.
|
|
230
|
+
*
|
|
231
|
+
* @param {(req: import('express').Request, reason: string, details: { appId?: string, detail?: string }) => void} onReject
|
|
232
|
+
* @returns {import('express').RequestHandler}
|
|
233
|
+
*/
|
|
234
|
+
const handleTrackingValidation = (onReject) => (req, res, next) => {
|
|
235
|
+
const errors = validationResult(req);
|
|
236
|
+
if (errors.isEmpty()) return next();
|
|
237
|
+
|
|
238
|
+
const failed = errors.array();
|
|
239
|
+
const appIdError = failed.find((error) => error.path === 'appId');
|
|
240
|
+
if (appIdError && typeof appIdError.value === 'string' && appIdError.value !== '') {
|
|
241
|
+
onReject(req, REJECTION_REASON.UNKNOWN_APP, { appId: appIdError.value });
|
|
242
|
+
} else {
|
|
243
|
+
onReject(req, REJECTION_REASON.INVALID_REQUEST, { detail: (appIdError || failed[0]).path });
|
|
244
|
+
}
|
|
245
|
+
return handleValidationErrors(req, res, next);
|
|
246
|
+
};
|
|
247
|
+
|
|
202
248
|
module.exports = {
|
|
203
249
|
validateAppRegistration,
|
|
204
250
|
validateRegisterView,
|
|
205
251
|
validateEvent,
|
|
252
|
+
validateEngage,
|
|
206
253
|
validateStatsRequest,
|
|
207
254
|
validateTrendsRequest,
|
|
208
255
|
validateListRequest,
|
|
209
256
|
validateViewsRequest,
|
|
210
257
|
validateSessionRequest,
|
|
211
258
|
handleValidationErrors,
|
|
259
|
+
handleTrackingValidation,
|
|
212
260
|
};
|
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.0
|
|
4
|
+
"version": "3.2.0",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24"
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
11
|
"constants.js",
|
|
12
|
+
"admin/",
|
|
13
|
+
"tracker/",
|
|
12
14
|
"config/",
|
|
13
15
|
"db/",
|
|
14
16
|
"middleware/",
|
|
@@ -27,8 +29,11 @@
|
|
|
27
29
|
"start": "node index.js",
|
|
28
30
|
"setup": "node scripts/setup.js",
|
|
29
31
|
"assets": "node scripts/generate-brand-assets.js",
|
|
32
|
+
"map": "node scripts/generate-world-map.js",
|
|
33
|
+
"admin:demo": "node tests/ui/server.js 4173 --demo",
|
|
30
34
|
"lint": "eslint .",
|
|
31
|
-
"test": "npm run lint && jest --coverage --verbose && node scripts/generate-test-report.js",
|
|
35
|
+
"test": "npm run lint && jest --coverage --verbose && playwright test && node scripts/generate-test-report.js",
|
|
36
|
+
"test:ui": "playwright test",
|
|
32
37
|
"test:watch": "jest --watch",
|
|
33
38
|
"test:ci": "npm run lint && jest --coverage --ci",
|
|
34
39
|
"test:persist": "PERSIST_TEST_DB=true jest --coverage --verbose && node scripts/generate-test-report.js",
|
|
@@ -38,10 +43,15 @@
|
|
|
38
43
|
},
|
|
39
44
|
"devDependencies": {
|
|
40
45
|
"@eslint/js": "^9.39.5",
|
|
46
|
+
"@playwright/test": "^1.63.0",
|
|
47
|
+
"d3-geo": "^3.1.1",
|
|
41
48
|
"eslint": "^9.39.5",
|
|
49
|
+
"i18n-iso-countries": "^7.14.0",
|
|
42
50
|
"jest": "^30.4.2",
|
|
43
51
|
"jest-html-reporter": "^4.4.0",
|
|
44
|
-
"supertest": "^7.2.2"
|
|
52
|
+
"supertest": "^7.2.2",
|
|
53
|
+
"topojson-client": "^3.1.0",
|
|
54
|
+
"world-atlas": "^2.0.2"
|
|
45
55
|
},
|
|
46
56
|
"repository": {
|
|
47
57
|
"type": "git",
|
|
@@ -65,17 +75,17 @@
|
|
|
65
75
|
"cors": "^2.8.6",
|
|
66
76
|
"dotenv": "^17.4.2",
|
|
67
77
|
"express": "^5.2.1",
|
|
68
|
-
"express-rate-limit": "^8.
|
|
78
|
+
"express-rate-limit": "^8.7.0",
|
|
69
79
|
"express-validator": "^7.3.2",
|
|
70
|
-
"geoip-country": "^5.0.
|
|
80
|
+
"geoip-country": "^5.0.202609230144",
|
|
71
81
|
"helmet": "^8.3.0",
|
|
72
|
-
"
|
|
73
|
-
"
|
|
82
|
+
"isbot": "^5.2.2",
|
|
83
|
+
"maxmind": "^5.0.7",
|
|
84
|
+
"mysql2": "^3.24.4",
|
|
85
|
+
"ua-parser-js": "^1.0.41",
|
|
74
86
|
"url-parse": "^1.5.10"
|
|
75
87
|
},
|
|
76
88
|
"overrides": {
|
|
77
|
-
"
|
|
78
|
-
"ip-address": "^10.2.0"
|
|
79
|
-
}
|
|
89
|
+
"ip-address": "^10.7.2"
|
|
80
90
|
}
|
|
81
91
|
}
|