@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
@@ -9,19 +9,36 @@ const {
9
9
  QUERY_LIMITS,
10
10
  SERVER,
11
11
  TOP_N_RESULTS,
12
+ TRACKING,
12
13
  TREND_PERIOD,
14
+ VIEW_LOG_SOURCE,
13
15
  } = require('../constants');
16
+
17
+ /**
18
+ * Every analytics read and the duplicate check see only live rows. A view an
19
+ * admin has moved to the trash no longer counts anywhere, and one that is
20
+ * restored counts again, without any stored aggregate needing to be rebuilt.
21
+ */
22
+ const LIVE_ROW = 'deleted_at IS NULL';
14
23
  const PrivacyUtils = require('../utils/privacyUtils');
15
24
  const logger = require('../utils/logger');
16
25
  const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
17
26
  const { truncate } = require('../utils/stringUtils');
18
27
  const { isValidAppId } = require('../utils/appIdUtils');
28
+ const {
29
+ NEW_TABLE_ADMIN_COLUMNS,
30
+ NEW_TABLE_ADMIN_INDEXES,
31
+ ensureLogTables,
32
+ migrateAppTable,
33
+ } = require('./adminSchema');
34
+ const LogRepository = require('./LogRepository');
35
+ const AdminRepository = require('./AdminRepository');
19
36
 
20
37
  /**
21
38
  * Columns returned for a session lookup.
22
39
  *
23
40
  * Deliberately explicit rather than `SELECT *`. The previous wildcard returned
24
- * `visitor_hash` — the pseudonymous visitor identifier itself — to any caller
41
+ * `visitor_hash` (the pseudonymous visitor identifier itself) to any caller
25
42
  * of the sessions endpoint.
26
43
  */
27
44
  const SESSION_COLUMNS = [
@@ -49,7 +66,7 @@ const SESSION_COLUMNS = [
49
66
  *
50
67
  * `id` is a generated UUID and is the row's identity; `app_id` is a uniqueness
51
68
  * *constraint*, not an identity (CODE_STANDARDS.md §8). The distinction matters
52
- * the first time an app is renamed, or deleted and a later one reuses the name —
69
+ * the first time an app is renamed, or deleted and a later one reuses the name:
53
70
  * with the natural key as the primary key, anything referencing the old row
54
71
  * would silently re-point at the new one.
55
72
  */
@@ -66,7 +83,7 @@ const APP_REGISTRY_DDL = `
66
83
  * DDL for one app's event table.
67
84
  *
68
85
  * `appId` is interpolated because MySQL cannot bind an identifier as a
69
- * parameter. Every caller must have passed it through `isValidAppId` first —
86
+ * parameter. Every caller must have passed it through `isValidAppId` first;
70
87
  * the assertion below is the backstop, not the primary gate.
71
88
  *
72
89
  * @param {string} appId
@@ -99,6 +116,7 @@ function appTableDDL(appId) {
99
116
  \`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT '${EVENT_TYPE.PAGEVIEW}',
100
117
  \`event_data\` JSON DEFAULT NULL,
101
118
  \`is_unique\` TINYINT(1) DEFAULT 1,
119
+ ${NEW_TABLE_ADMIN_COLUMNS.join(',\n ')},
102
120
  INDEX \`idx_timestamp\` (\`timestamp\`),
103
121
  INDEX \`idx_visitor_timestamp\` (\`visitor_hash\`, \`timestamp\`),
104
122
  INDEX \`idx_masked_ip\` (\`masked_ip\`),
@@ -112,7 +130,8 @@ function appTableDDL(appId) {
112
130
  INDEX \`idx_device_type\` (\`device_type\`),
113
131
  INDEX \`idx_session_id\` (\`session_id\`),
114
132
  INDEX \`idx_event_type\` (\`event_type\`),
115
- INDEX \`idx_is_unique\` (\`is_unique\`)
133
+ INDEX \`idx_is_unique\` (\`is_unique\`),
134
+ ${NEW_TABLE_ADMIN_INDEXES.join(',\n ')}
116
135
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
117
136
  `;
118
137
  }
@@ -123,7 +142,7 @@ function appTableDDL(appId) {
123
142
  * `mysql2/promise`'s pool emits the RAW callback-style connection on its
124
143
  * `connection` event, not the promise-wrapped one. Its `query()` returns a
125
144
  * `Query`, and mysql2 deliberately makes `.then()`/`.catch()` on a `Query`
126
- * throw — so treating it as a promise crashes the process on the very first
145
+ * throw, so treating it as a promise crashes the process on the very first
127
146
  * database connection. The callback form is the correct API for that object.
128
147
  *
129
148
  * Failure is swallowed on purpose: MariaDB and MySQL < 5.7.8 have no
@@ -161,6 +180,29 @@ class DatabaseManager {
161
180
  this.config = config;
162
181
  this.pool = null;
163
182
  this.mode = config.mode || 'connect';
183
+ this.logs = new LogRepository(this);
184
+ this.admin = new AdminRepository(this);
185
+ }
186
+
187
+ /**
188
+ * Bring the service's own tables and every app table up to the current
189
+ * schema. Idempotent, and run on every start, so an upgrade needs no manual
190
+ * migration step. Runs in `connect` mode too: the admin columns and log
191
+ * tables are the service's bookkeeping, like the app registry.
192
+ *
193
+ * @param {string[]} appIds
194
+ * @throws {Error} ErrorType.MIGRATION_FAILED
195
+ */
196
+ async migrate(appIds = []) {
197
+ this.assertReady();
198
+ await ensureLogTables(this.pool);
199
+
200
+ for (const appId of appIds) {
201
+ const result = await migrateAppTable(this.pool, appId);
202
+ if (result.backfilled > 0) {
203
+ logger.info(`Assigned public IDs to ${result.backfilled} existing row(s) in '${appId}'`);
204
+ }
205
+ }
164
206
  }
165
207
 
166
208
  /** @throws {Error} when a query is attempted before initialize() */
@@ -171,7 +213,14 @@ class DatabaseManager {
171
213
  }
172
214
 
173
215
  /**
174
- * Initialize database connection and optionally create schema
216
+ * Connect, create the schema in create mode, and bring the given apps'
217
+ * tables up to the current schema in either mode.
218
+ *
219
+ * The migration belongs here, not only at server startup: every query this
220
+ * manager runs assumes the current schema (public_id, deleted_at, the log
221
+ * tables), so an application that mounts the routers and calls only
222
+ * initialize() must still get tables those queries can run against.
223
+ * Idempotent: a table already in shape is read and left alone.
175
224
  */
176
225
  async initialize(allowedAppIds = []) {
177
226
  try {
@@ -206,8 +255,6 @@ class DatabaseManager {
206
255
 
207
256
  await this.pool.query('SELECT 1');
208
257
  logger.info(`Database connected (mode: ${this.mode})`);
209
-
210
- return true;
211
258
  } catch (cause) {
212
259
  throw getError(ErrorType.DATABASE_CONNECTION_FAILED, {
213
260
  host: this.config.host,
@@ -216,6 +263,11 @@ class DatabaseManager {
216
263
  cause: cause.message,
217
264
  });
218
265
  }
266
+
267
+ // Outside the try: a failed migration is reported as MIGRATION_FAILED
268
+ // naming the table, not as a connection failure.
269
+ await this.migrate(allowedAppIds);
270
+ return true;
219
271
  }
220
272
 
221
273
  /**
@@ -296,7 +348,7 @@ class DatabaseManager {
296
348
  /**
297
349
  * Provision a new app: validate, create its table, record it.
298
350
  *
299
- * Idempotent — re-registering an existing app is a no-op rather than an
351
+ * Idempotent: re-registering an existing app is a no-op rather than an
300
352
  * error, so a retried provisioning call cannot fail halfway.
301
353
  *
302
354
  * @param {string} appId
@@ -320,8 +372,10 @@ class DatabaseManager {
320
372
  );
321
373
 
322
374
  // The table is (re)created regardless, so an app registered before its
323
- // table existed still converges to a working state.
375
+ // table existed still converges to a working state. A table that
376
+ // already existed in an older shape is migrated in place.
324
377
  await this.pool.query(appTableDDL(appId));
378
+ await migrateAppTable(this.pool, appId);
325
379
 
326
380
  if (existing.length > 0) {
327
381
  logWarning(WarningType.APP_ALREADY_REGISTERED, { appId });
@@ -386,9 +440,19 @@ class DatabaseManager {
386
440
  sessionId,
387
441
  eventType = EVENT_TYPE.PAGEVIEW,
388
442
  eventData,
443
+ hostname = null,
444
+ language = null,
445
+ utmSource = null,
446
+ utmMedium = null,
447
+ utmCampaign = null,
448
+ utmTerm = null,
449
+ utmContent = null,
450
+ region = null,
451
+ city = null,
389
452
  uniqueWindowHours = SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS,
390
453
  userAgent = '',
391
454
  visitorSecret,
455
+ source = VIEW_LOG_SOURCE.REGISTER_VIEW,
392
456
  } = data;
393
457
 
394
458
  // Privacy boundary. Neither the raw IP nor the raw User-Agent is bound
@@ -407,6 +471,7 @@ class DatabaseManager {
407
471
  const [existing] = await this.pool.query(
408
472
  `SELECT id FROM \`${appId}\`
409
473
  WHERE visitor_hash = ? AND event_type = ? AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)
474
+ AND ${LIVE_ROW}
410
475
  LIMIT 1`,
411
476
  [hashedVisitor, EVENT_TYPE.PAGEVIEW, uniqueWindowHours]
412
477
  );
@@ -416,15 +481,21 @@ class DatabaseManager {
416
481
  }
417
482
  }
418
483
 
484
+ const publicId = crypto.randomUUID();
485
+ const storedEventType = truncate(eventType, FIELD_MAX_LENGTH.EVENT_TYPE);
486
+
419
487
  const [result] = await this.pool.query(
420
488
  `INSERT INTO \`${appId}\` (
421
- masked_ip, visitor_hash, country, timestamp, devicesize,
489
+ public_id, masked_ip, visitor_hash, country, timestamp, devicesize,
422
490
  page_path, page_title,
423
491
  referrer, referrer_domain, source_type,
424
492
  browser, browser_version, os, os_version, device_type,
425
- session_id, event_type, event_data, is_unique
426
- ) VALUES (?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
493
+ session_id, event_type, event_data, is_unique,
494
+ hostname, language, utm_source, utm_medium, utm_campaign, utm_term, utm_content,
495
+ region, city
496
+ ) VALUES (?, ?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
427
497
  [
498
+ publicId,
428
499
  truncate(maskedIp, FIELD_MAX_LENGTH.MASKED_IP),
429
500
  hashedVisitor,
430
501
  truncate(country, FIELD_MAX_LENGTH.COUNTRY),
@@ -440,19 +511,66 @@ class DatabaseManager {
440
511
  truncate(osVersion, FIELD_MAX_LENGTH.OS_VERSION),
441
512
  truncate(deviceType, FIELD_MAX_LENGTH.DEVICE_TYPE),
442
513
  truncate(sessionId, FIELD_MAX_LENGTH.SESSION_ID),
443
- truncate(eventType, FIELD_MAX_LENGTH.EVENT_TYPE),
514
+ storedEventType,
444
515
  eventData ? JSON.stringify(eventData) : null,
445
516
  isUnique,
517
+ truncate(hostname, FIELD_MAX_LENGTH.HOSTNAME),
518
+ truncate(language, FIELD_MAX_LENGTH.LANGUAGE),
519
+ truncate(utmSource, FIELD_MAX_LENGTH.UTM),
520
+ truncate(utmMedium, FIELD_MAX_LENGTH.UTM),
521
+ truncate(utmCampaign, FIELD_MAX_LENGTH.UTM),
522
+ truncate(utmTerm, FIELD_MAX_LENGTH.UTM),
523
+ truncate(utmContent, FIELD_MAX_LENGTH.UTM),
524
+ truncate(region, FIELD_MAX_LENGTH.REGION),
525
+ truncate(city, FIELD_MAX_LENGTH.CITY),
446
526
  ]
447
527
  );
448
528
 
529
+ // The view register log. Written after the row, and never able to fail
530
+ // the request: the view is already stored.
531
+ await this.logs.writeViewLog({
532
+ appId,
533
+ source,
534
+ viewId: publicId,
535
+ eventType: storedEventType,
536
+ isUnique: isUnique === 1,
537
+ hostname,
538
+ });
539
+
449
540
  return {
450
541
  duplicate: isUnique === 0,
451
542
  insertId: result.insertId,
543
+ publicId,
452
544
  isUnique: isUnique === 1,
453
545
  };
454
546
  }
455
547
 
548
+ /**
549
+ * Record how long a view's page was visible and how far it was scrolled.
550
+ *
551
+ * A page reports this when it is hidden or left, possibly more than once
552
+ * (hidden, shown again, then left), each time with its running total, so
553
+ * the larger value always wins. Only a live view from the last
554
+ * TRACKING.ENGAGE_WINDOW_HOURS is updated: an old or trashed view keeps
555
+ * what it had.
556
+ *
557
+ * @param {string} appId already validated
558
+ * @param {{ viewId: string, engagedMs: number, scrollDepth: number }} engagement
559
+ * @returns {Promise<boolean>} whether a view was updated
560
+ */
561
+ async addEngagement(appId, { viewId, engagedMs, scrollDepth }) {
562
+ this.assertReady();
563
+ const [result] = await this.pool.query(
564
+ `UPDATE \`${appId}\`
565
+ SET engaged_ms = GREATEST(COALESCE(engaged_ms, 0), ?),
566
+ scroll_depth = GREATEST(COALESCE(scroll_depth, 0), ?)
567
+ WHERE public_id = ? AND ${LIVE_ROW}
568
+ AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
569
+ [engagedMs, scrollDepth, viewId, TRACKING.ENGAGE_WINDOW_HOURS]
570
+ );
571
+ return result.affectedRows > 0;
572
+ }
573
+
456
574
  /**
457
575
  * Register a view (backward compatible wrapper)
458
576
  */
@@ -477,14 +595,15 @@ class DatabaseManager {
477
595
  COUNT(*) as total_views,
478
596
  SUM(CASE WHEN is_unique = 1 THEN 1 ELSE 0 END) as unique_views,
479
597
  COUNT(DISTINCT visitor_hash) as unique_visitors
480
- FROM \`${appId}\``
598
+ FROM \`${appId}\`
599
+ WHERE ${LIVE_ROW}`
481
600
  );
482
601
 
483
602
  const stats = totalStats[0];
484
603
 
485
604
  const [byCountry] = await this.pool.query(
486
605
  `SELECT country, COUNT(*) as count FROM \`${appId}\`
487
- WHERE country IS NOT NULL
606
+ WHERE country IS NOT NULL AND ${LIVE_ROW}
488
607
  GROUP BY country
489
608
  ORDER BY count DESC
490
609
  LIMIT ?`,
@@ -493,13 +612,14 @@ class DatabaseManager {
493
612
 
494
613
  const [byDevice] = await this.pool.query(
495
614
  `SELECT devicesize, COUNT(*) as count FROM \`${appId}\`
615
+ WHERE ${LIVE_ROW}
496
616
  GROUP BY devicesize
497
617
  ORDER BY count DESC`
498
618
  );
499
619
 
500
620
  const [recent] = await this.pool.query(
501
621
  `SELECT COUNT(*) as count FROM \`${appId}\`
502
- WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
622
+ WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR) AND ${LIVE_ROW}`,
503
623
  [SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS]
504
624
  );
505
625
 
@@ -522,13 +642,14 @@ class DatabaseManager {
522
642
  const [views] = await this.pool.query(
523
643
  `SELECT masked_ip, country, timestamp, devicesize
524
644
  FROM \`${appId}\`
645
+ WHERE ${LIVE_ROW}
525
646
  ORDER BY timestamp DESC
526
647
  LIMIT ? OFFSET ?`,
527
648
  [limit, offset]
528
649
  );
529
650
 
530
651
  const [total] = await this.pool.query(
531
- `SELECT COUNT(*) as count FROM \`${appId}\``
652
+ `SELECT COUNT(*) as count FROM \`${appId}\` WHERE ${LIVE_ROW}`
532
653
  );
533
654
 
534
655
  return {
@@ -560,7 +681,7 @@ class DatabaseManager {
560
681
  const [trends] = await this.pool.query(
561
682
  `SELECT ${groupBy} as period, COUNT(*) as count
562
683
  FROM \`${appId}\`
563
- WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? DAY)
684
+ WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? DAY) AND ${LIVE_ROW}
564
685
  GROUP BY period
565
686
  ORDER BY period ASC`,
566
687
  [days]
@@ -578,7 +699,7 @@ class DatabaseManager {
578
699
  const [bySource] = await this.pool.query(
579
700
  `SELECT source_type, COUNT(*) as count
580
701
  FROM \`${appId}\`
581
- WHERE source_type IS NOT NULL
702
+ WHERE source_type IS NOT NULL AND ${LIVE_ROW}
582
703
  GROUP BY source_type
583
704
  ORDER BY count DESC`
584
705
  );
@@ -586,7 +707,7 @@ class DatabaseManager {
586
707
  const [byDomain] = await this.pool.query(
587
708
  `SELECT referrer_domain, COUNT(*) as count
588
709
  FROM \`${appId}\`
589
- WHERE referrer_domain IS NOT NULL
710
+ WHERE referrer_domain IS NOT NULL AND ${LIVE_ROW}
590
711
  GROUP BY referrer_domain
591
712
  ORDER BY count DESC
592
713
  LIMIT ?`,
@@ -605,7 +726,7 @@ class DatabaseManager {
605
726
  const [byBrowser] = await this.pool.query(
606
727
  `SELECT browser, COUNT(*) as count
607
728
  FROM \`${appId}\`
608
- WHERE browser IS NOT NULL
729
+ WHERE browser IS NOT NULL AND ${LIVE_ROW}
609
730
  GROUP BY browser
610
731
  ORDER BY count DESC
611
732
  LIMIT ?`,
@@ -615,7 +736,7 @@ class DatabaseManager {
615
736
  const [byOS] = await this.pool.query(
616
737
  `SELECT os, COUNT(*) as count
617
738
  FROM \`${appId}\`
618
- WHERE os IS NOT NULL
739
+ WHERE os IS NOT NULL AND ${LIVE_ROW}
619
740
  GROUP BY os
620
741
  ORDER BY count DESC
621
742
  LIMIT ?`,
@@ -625,7 +746,7 @@ class DatabaseManager {
625
746
  const [byDeviceType] = await this.pool.query(
626
747
  `SELECT device_type, COUNT(*) as count
627
748
  FROM \`${appId}\`
628
- WHERE device_type IS NOT NULL
749
+ WHERE device_type IS NOT NULL AND ${LIVE_ROW}
629
750
  GROUP BY device_type
630
751
  ORDER BY count DESC`
631
752
  );
@@ -642,7 +763,7 @@ class DatabaseManager {
642
763
  const [pages] = await this.pool.query(
643
764
  `SELECT page_path, page_title, COUNT(*) as views
644
765
  FROM \`${appId}\`
645
- WHERE page_path IS NOT NULL
766
+ WHERE page_path IS NOT NULL AND ${LIVE_ROW}
646
767
  GROUP BY page_path, page_title
647
768
  ORDER BY views DESC
648
769
  LIMIT ?`,
@@ -662,7 +783,7 @@ class DatabaseManager {
662
783
  const [events] = await this.pool.query(
663
784
  `SELECT ${SESSION_COLUMNS}
664
785
  FROM \`${appId}\`
665
- WHERE session_id = ?
786
+ WHERE session_id = ? AND ${LIVE_ROW}
666
787
  ORDER BY timestamp ASC`,
667
788
  [sessionId]
668
789
  );
@@ -702,3 +823,4 @@ module.exports = DatabaseManager;
702
823
  module.exports.SESSION_COLUMNS = SESSION_COLUMNS;
703
824
  module.exports.appTableDDL = appTableDDL;
704
825
  module.exports.APP_REGISTRY_DDL = APP_REGISTRY_DDL;
826
+ module.exports.LIVE_ROW = LIVE_ROW;