@harshankur/viewcounter 3.1.0 → 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 (50) hide show
  1. package/.env.example +34 -10
  2. package/README.md +306 -136
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +55 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +89 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +479 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +53 -19
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +197 -18
  39. package/tracker/tracker.js +191 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
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.1.0",
4
+ "version": "3.2.0",
5
5
  "main": "index.js",
6
6
  "engines": {
7
7
  "node": ">=24"
@@ -10,6 +10,7 @@
10
10
  "index.js",
11
11
  "constants.js",
12
12
  "admin/",
13
+ "tracker/",
13
14
  "config/",
14
15
  "db/",
15
16
  "middleware/",
@@ -78,8 +79,10 @@
78
79
  "express-validator": "^7.3.2",
79
80
  "geoip-country": "^5.0.202609230144",
80
81
  "helmet": "^8.3.0",
82
+ "isbot": "^5.2.2",
83
+ "maxmind": "^5.0.7",
81
84
  "mysql2": "^3.24.4",
82
- "ua-parser-js": "^2.0.10",
85
+ "ua-parser-js": "^1.0.41",
83
86
  "url-parse": "^1.5.10"
84
87
  },
85
88
  "overrides": {
package/routes/admin.js CHANGED
@@ -25,11 +25,14 @@ const {
25
25
  ADMIN_ERROR_CODE,
26
26
  ADMIN_RANGE,
27
27
  ADMIN_SORT_COLUMNS,
28
+ ANALYSIS,
28
29
  EDITABLE_FIELDS,
29
30
  FIELD_MAX_LENGTH,
30
31
  HTTP_STATUS,
31
32
  MODIFIED_FILTER,
33
+ REJECTION_REASON,
32
34
  SORT_ORDER,
35
+ TRACKING_OUTCOME,
33
36
  VIEW_LOG_SOURCE,
34
37
  VIEW_STATUS,
35
38
  } = require('../constants');
@@ -41,25 +44,35 @@ const {
41
44
  requireAdminSession,
42
45
  requireCsrf,
43
46
  verifyPassword,
47
+ requireRecentPassword,
48
+ sessionCsrfToken,
44
49
  cookieOptions,
45
50
  originAllowed,
46
51
  readToken,
47
52
  } = require('../middleware/adminAuth');
48
53
  const {
54
+ parseWhere,
49
55
  validateLogin,
50
56
  validateViewListing,
51
57
  validateAnalysis,
58
+ validateEventTypes,
59
+ validateRealtime,
52
60
  validateEdit,
53
61
  validateNote,
54
62
  validateBatch,
55
63
  validateAdminLogListing,
56
- validateViewLogListing,
64
+ validateTrackingLogListing,
65
+ validateTrackingSummary,
57
66
  handleAdminValidation,
58
67
  } = require('../middleware/adminValidation');
59
68
  const { logContext, withRequestId } = require('./analytics');
69
+ const { attributions } = require('../utils/geoCity');
70
+ const { ACQUISITION_DIMENSIONS } = require('../db/analysis');
60
71
 
61
72
  /** The UI's static files, shipped in the package. */
62
73
  const ADMIN_UI_DIR = path.join(__dirname, '..', 'admin');
74
+ /** Shown in the UI's header and footer, so an admin knows what they are running. */
75
+ const PACKAGE_VERSION = require('../package.json').version;
63
76
 
64
77
  /**
65
78
  * Content Security Policy for the admin surface. Stricter than helmet's
@@ -94,6 +107,8 @@ function filterQuery(req) {
94
107
  range: req.query.range || ADMIN_RANGE.ALL,
95
108
  search: req.query.search || '',
96
109
  eventType: req.query.eventType || '',
110
+ // Validated already; parsed again here since Express 5 recomputes req.query.
111
+ where: parseWhere(req.query.where),
97
112
  };
98
113
  }
99
114
 
@@ -108,16 +123,19 @@ function adminError(req, res, error, operation) {
108
123
 
109
124
  /**
110
125
  * @param {{ config: object, adminRepo: object, logRepo: object,
111
- * sessionStore?: object, isReady?: () => boolean, uiDir?: string }} deps
126
+ * sessionStore?: object, isReady?: () => boolean, uiDir?: string,
127
+ * geo?: { city: object|null } }} deps
128
+ * `geo.city` is the optional city database, whose credit the UI shows
112
129
  * @returns {import('express').Router}
113
130
  */
114
131
  function createAdminRouter({
115
132
  config,
116
133
  adminRepo,
117
134
  logRepo,
118
- sessionStore = createSessionStore(),
135
+ sessionStore = createSessionStore({ idleMs: config.admin.sessionIdleMs, absoluteMs: config.admin.sessionMaxAgeMs }),
119
136
  isReady = () => true,
120
137
  uiDir = ADMIN_UI_DIR,
138
+ geo = { city: null },
121
139
  }) {
122
140
  // The standalone server checks this when it loads its config; an app
123
141
  // embedding the router gets the same floor, not a weaker admin surface.
@@ -152,7 +170,7 @@ function createAdminRouter({
152
170
  handler: (req, res) => res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({ code: ADMIN_ERROR_CODE.RATE_LIMITED }),
153
171
  }));
154
172
 
155
- router.use(ADMIN.API_PATH, createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }));
173
+ router.use(ADMIN.API_PATH, createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady, geo }));
156
174
 
157
175
  // Relative asset URLs in the UI only resolve under a trailing slash.
158
176
  router.get('/', (req, res, next) => {
@@ -168,7 +186,7 @@ function createAdminRouter({
168
186
  /**
169
187
  * The JSON API behind the UI.
170
188
  */
171
- function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
189
+ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady, geo }) {
172
190
  const api = express.Router();
173
191
  const allowed = config.allowed;
174
192
 
@@ -196,10 +214,15 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
196
214
 
197
215
  // ---- Session ----------------------------------------------------------
198
216
 
199
- api.get('/session', (req, res) => {
200
- const session = sessionStore.get(readToken(req));
201
- if (!session) return res.json({ authenticated: false });
202
- return res.json({ authenticated: true, csrfToken: session.csrfToken });
217
+ api.get('/session', async (req, res) => {
218
+ try {
219
+ const token = readToken(req);
220
+ const session = await sessionStore.get(token);
221
+ if (!session) return res.json({ authenticated: false });
222
+ return res.json({ authenticated: true, csrfToken: sessionCsrfToken(token) });
223
+ } catch (error) {
224
+ return adminError(req, res, error, 'read session');
225
+ }
203
226
  });
204
227
 
205
228
  const loginLimiter = rateLimit({
@@ -232,22 +255,38 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
232
255
 
233
256
  // A fresh token on every login; any previous session in this
234
257
  // browser is ended rather than left valid alongside the new one.
235
- sessionStore.destroy(readToken(req));
236
- const { token, session } = sessionStore.create();
237
- res.cookie(ADMIN.SESSION_COOKIE, token, cookieOptions(req));
258
+ await sessionStore.destroy(readToken(req));
259
+ const { token, session } = await sessionStore.create();
260
+ res.cookie(ADMIN.SESSION_COOKIE, token, cookieOptions(req, sessionStore.absoluteMs));
238
261
 
239
262
  req.adminSession = session;
240
263
  await record(req, ADMIN_ACTION.LOGIN_SUCCEEDED);
241
- return res.json({ authenticated: true, csrfToken: session.csrfToken });
264
+ return res.json({ authenticated: true, csrfToken: sessionCsrfToken(token) });
242
265
  } catch (error) {
243
266
  return adminError(req, res, error, 'login');
244
267
  }
245
268
  });
246
269
 
270
+ // The password again, for actions that cannot be undone. Rate limited
271
+ // with sign-in, since it is the same guess.
272
+ api.post('/reauth', authed, loginLimiter, validateLogin(), handleAdminValidation, async (req, res) => {
273
+ try {
274
+ if (!verifyPassword(req.body.password, config.admin.password)) {
275
+ await record(req, ADMIN_ACTION.LOGIN_FAILED);
276
+ return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.INVALID_PASSWORD });
277
+ }
278
+ await sessionStore.confirmPassword(req.adminToken);
279
+ await record(req, ADMIN_ACTION.PASSWORD_CONFIRMED);
280
+ return res.status(HTTP_STATUS.NO_CONTENT).end();
281
+ } catch (error) {
282
+ return adminError(req, res, error, 'confirm password');
283
+ }
284
+ });
285
+
247
286
  api.post('/logout', authed, async (req, res) => {
248
287
  try {
249
288
  await record(req, ADMIN_ACTION.LOGOUT);
250
- sessionStore.destroy(req.adminToken);
289
+ await sessionStore.destroy(req.adminToken);
251
290
  res.clearCookie(ADMIN.SESSION_COOKIE, { ...cookieOptions(req), maxAge: undefined });
252
291
  return res.status(HTTP_STATUS.NO_CONTENT).end();
253
292
  } catch (error) {
@@ -259,6 +298,7 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
259
298
 
260
299
  api.get('/meta', requireSession, (req, res) => {
261
300
  res.json({
301
+ version: PACKAGE_VERSION,
262
302
  deviceSizes: allowed.deviceSize,
263
303
  editableFields: Object.keys(EDITABLE_FIELDS),
264
304
  sortFields: Object.keys(ADMIN_SORT_COLUMNS),
@@ -267,11 +307,21 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
267
307
  modifiedFilters: Object.values(MODIFIED_FILTER),
268
308
  actions: Object.values(ADMIN_ACTION),
269
309
  sources: Object.values(VIEW_LOG_SOURCE),
310
+ outcomes: Object.values(TRACKING_OUTCOME),
311
+ rejectionReasons: Object.values(REJECTION_REASON),
270
312
  maxBatchIds: ADMIN.MAX_BATCH_IDS,
271
313
  pageSizes: ADMIN.PAGE_SIZES,
272
314
  pageSizeDefault: ADMIN.PAGE_SIZE_DEFAULT,
273
315
  searchMaxLength: ADMIN.SEARCH_MAX_LENGTH,
274
316
  trashRetentionDays: config.admin.trashRetentionDays,
317
+ reauthWindowMs: ADMIN.REAUTH_WINDOW_MS,
318
+ // "Right now" counts visitors seen within this many minutes.
319
+ realtimeVisitorMinutes: ANALYSIS.REALTIME_VISITOR_MINUTES,
320
+ // Breakdowns of how visits arrived, which count page views only.
321
+ acquisitionDimensions: [...ACQUISITION_DIMENSIONS],
322
+ // Credits the location data's licences ask for, shown in the UI.
323
+ attributions: attributions(geo.city),
324
+ hasCityData: Boolean(geo.city),
275
325
  viewLogRetentionDays: config.admin.viewLogRetentionDays,
276
326
  maxLength: {
277
327
  note: FIELD_MAX_LENGTH.NOTE,
@@ -347,6 +397,46 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
347
397
  }
348
398
  });
349
399
 
400
+ // Every event type the apps hold in a status, whatever other filter is
401
+ // set, for the event-type filter above a listing.
402
+ api.get('/apps/:appId/event-types', requireSession, validateEventTypes(allowed), handleAdminValidation,
403
+ async (req, res) => {
404
+ try {
405
+ const status = req.query.status || VIEW_STATUS.ACTIVE;
406
+ return res.json({ eventTypes: await adminRepo.eventTypes([req.params.appId], status) });
407
+ } catch (error) {
408
+ return adminError(req, res, error, 'list event types');
409
+ }
410
+ });
411
+
412
+ api.get('/event-types', requireSession, validateEventTypes(), handleAdminValidation, async (req, res) => {
413
+ try {
414
+ const apps = await adminRepo.existingTables(allowed.appId);
415
+ const status = req.query.status || VIEW_STATUS.ACTIVE;
416
+ return res.json({ eventTypes: apps.length ? await adminRepo.eventTypes(apps, status) : [] });
417
+ } catch (error) {
418
+ return adminError(req, res, error, 'list all event types');
419
+ }
420
+ });
421
+
422
+ // Right now: visitors in the last few minutes and views per minute.
423
+ api.get('/apps/:appId/realtime', requireSession, validateRealtime(allowed), handleAdminValidation, async (req, res) => {
424
+ try {
425
+ return res.json({ apps: [req.params.appId], ...await adminRepo.realtime([req.params.appId]) });
426
+ } catch (error) {
427
+ return adminError(req, res, error, 'read realtime');
428
+ }
429
+ });
430
+
431
+ api.get('/realtime', requireSession, async (req, res) => {
432
+ try {
433
+ const apps = await adminRepo.existingTables(allowed.appId);
434
+ return res.json({ apps, ...await adminRepo.realtime(apps) });
435
+ } catch (error) {
436
+ return adminError(req, res, error, 'read realtime');
437
+ }
438
+ });
439
+
350
440
  api.patch('/apps/:appId/views', authed, validateEdit(allowed), handleAdminValidation, async (req, res) => {
351
441
  try {
352
442
  const { appId } = req.params;
@@ -373,8 +463,8 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
373
463
  });
374
464
 
375
465
  /** delete / restore / purge share one shape: IDs in, changed IDs out. */
376
- const batchRoute = (suffix, operation, action) => {
377
- api.post(`/apps/:appId/views/${suffix}`, authed, validateBatch(allowed), handleAdminValidation,
466
+ const batchRoute = (suffix, operation, action, guards = []) => {
467
+ api.post(`/apps/:appId/views/${suffix}`, authed, ...guards, validateBatch(allowed), handleAdminValidation,
378
468
  async (req, res) => {
379
469
  try {
380
470
  const { appId } = req.params;
@@ -389,7 +479,9 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
389
479
 
390
480
  batchRoute('delete', 'softDelete', ADMIN_ACTION.VIEWS_DELETED);
391
481
  batchRoute('restore', 'restore', ADMIN_ACTION.VIEWS_RESTORED);
392
- batchRoute('purge', 'purge', ADMIN_ACTION.VIEWS_PURGED);
482
+ // Erasing for good cannot be undone, so it wants the password from the
483
+ // last few minutes, not just a session that may be weeks old.
484
+ batchRoute('purge', 'purge', ADMIN_ACTION.VIEWS_PURGED, [requireRecentPassword()]);
393
485
 
394
486
  // ---- Logs -------------------------------------------------------------
395
487
 
@@ -408,18 +500,29 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
408
500
  }
409
501
  });
410
502
 
411
- api.get('/logs/views', requireSession, validateViewLogListing(allowed), handleAdminValidation,
503
+ // The tracking log: every tracking request and what became of it.
504
+ api.get('/logs/tracking', requireSession, validateTrackingLogListing(allowed), handleAdminValidation,
412
505
  async (req, res) => {
413
506
  try {
414
- const result = await logRepo.listViewLog({
507
+ const result = await logRepo.listTrackingLog({
415
508
  page: intParam(req, 'page', 1),
416
509
  pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
417
510
  appId: req.query.appId,
418
511
  source: req.query.source,
512
+ outcome: req.query.outcome,
419
513
  });
420
514
  return res.json(result);
421
515
  } catch (error) {
422
- return adminError(req, res, error, 'list view log');
516
+ return adminError(req, res, error, 'list tracking log');
517
+ }
518
+ });
519
+
520
+ api.get('/logs/tracking/summary', requireSession, validateTrackingSummary(allowed), handleAdminValidation,
521
+ async (req, res) => {
522
+ try {
523
+ return res.json(await logRepo.trackingSummary({ hours: ADMIN.TRACKING_SUMMARY_HOURS, appId: req.query.appId }));
524
+ } catch (error) {
525
+ return adminError(req, res, error, 'summarize tracking log');
423
526
  }
424
527
  });
425
528
 
@@ -428,8 +531,13 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
428
531
  // Malformed or oversized JSON bodies, in the admin error shape.
429
532
  // eslint-disable-next-line no-unused-vars
430
533
  api.use((err, req, res, next) => {
431
- logger.warn(`admin request rejected: ${err.message}`, logContext(req));
432
- return res.status(HTTP_STATUS.BAD_REQUEST).json({ code: ADMIN_ERROR_CODE.VALIDATION_FAILED });
534
+ const status = err.status || err.statusCode;
535
+ if (status >= 400 && status < 500) {
536
+ logger.warn(`admin request rejected: ${err.message}`, logContext(req));
537
+ return res.status(HTTP_STATUS.BAD_REQUEST).json({ code: ADMIN_ERROR_CODE.VALIDATION_FAILED });
538
+ }
539
+ // Anything else is ours (a session store that cannot be read, say).
540
+ return adminError(req, res, err, 'handle request');
433
541
  });
434
542
 
435
543
  return api;