@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.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. 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
+ };
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Two distinct steps, deliberately separate:
5
5
  *
6
- * requireReadApiKey — *authentication*: is this a key we issued?
7
- * requireAppScope — *authorization*: may THIS key read THIS app?
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
@@ -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 — `*` made them script-readable from any origin, not merely reachable.
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,
@@ -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 — the handler would still receive a string and bind it
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 — there is no allowlist yet, that is the
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.1",
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.6.0",
78
+ "express-rate-limit": "^8.7.0",
69
79
  "express-validator": "^7.3.2",
70
- "geoip-country": "^5.0.202607180103",
80
+ "geoip-country": "^5.0.202609230144",
71
81
  "helmet": "^8.3.0",
72
- "mysql2": "^3.23.0",
73
- "ua-parser-js": "^2.0.10",
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
- "geoip-country": {
78
- "ip-address": "^10.2.0"
79
- }
89
+ "ip-address": "^10.7.2"
80
90
  }
81
91
  }