@harshankur/viewcounter 3.1.0 → 3.3.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 +37 -11
  2. package/README.md +330 -138
  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 +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  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 +236 -18
  39. package/tracker/tracker.js +240 -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
@@ -9,6 +9,7 @@ const {
9
9
  QUERY_LIMITS,
10
10
  SERVER,
11
11
  TOP_N_RESULTS,
12
+ TRACKING,
12
13
  TREND_PERIOD,
13
14
  VIEW_LOG_SOURCE,
14
15
  } = require('../constants');
@@ -37,7 +38,7 @@ const AdminRepository = require('./AdminRepository');
37
38
  * Columns returned for a session lookup.
38
39
  *
39
40
  * Deliberately explicit rather than `SELECT *`. The previous wildcard returned
40
- * `visitor_hash` — the pseudonymous visitor identifier itself — to any caller
41
+ * `visitor_hash` (the pseudonymous visitor identifier itself) to any caller
41
42
  * of the sessions endpoint.
42
43
  */
43
44
  const SESSION_COLUMNS = [
@@ -65,7 +66,7 @@ const SESSION_COLUMNS = [
65
66
  *
66
67
  * `id` is a generated UUID and is the row's identity; `app_id` is a uniqueness
67
68
  * *constraint*, not an identity (CODE_STANDARDS.md §8). The distinction matters
68
- * 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:
69
70
  * with the natural key as the primary key, anything referencing the old row
70
71
  * would silently re-point at the new one.
71
72
  */
@@ -82,7 +83,7 @@ const APP_REGISTRY_DDL = `
82
83
  * DDL for one app's event table.
83
84
  *
84
85
  * `appId` is interpolated because MySQL cannot bind an identifier as a
85
- * parameter. Every caller must have passed it through `isValidAppId` first —
86
+ * parameter. Every caller must have passed it through `isValidAppId` first;
86
87
  * the assertion below is the backstop, not the primary gate.
87
88
  *
88
89
  * @param {string} appId
@@ -141,7 +142,7 @@ function appTableDDL(appId) {
141
142
  * `mysql2/promise`'s pool emits the RAW callback-style connection on its
142
143
  * `connection` event, not the promise-wrapped one. Its `query()` returns a
143
144
  * `Query`, and mysql2 deliberately makes `.then()`/`.catch()` on a `Query`
144
- * 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
145
146
  * database connection. The callback form is the correct API for that object.
146
147
  *
147
148
  * Failure is swallowed on purpose: MariaDB and MySQL < 5.7.8 have no
@@ -347,7 +348,7 @@ class DatabaseManager {
347
348
  /**
348
349
  * Provision a new app: validate, create its table, record it.
349
350
  *
350
- * 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
351
352
  * error, so a retried provisioning call cannot fail halfway.
352
353
  *
353
354
  * @param {string} appId
@@ -439,6 +440,15 @@ class DatabaseManager {
439
440
  sessionId,
440
441
  eventType = EVENT_TYPE.PAGEVIEW,
441
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,
442
452
  uniqueWindowHours = SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS,
443
453
  userAgent = '',
444
454
  visitorSecret,
@@ -480,8 +490,10 @@ class DatabaseManager {
480
490
  page_path, page_title,
481
491
  referrer, referrer_domain, source_type,
482
492
  browser, browser_version, os, os_version, device_type,
483
- session_id, event_type, event_data, is_unique
484
- ) 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(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
485
497
  [
486
498
  publicId,
487
499
  truncate(maskedIp, FIELD_MAX_LENGTH.MASKED_IP),
@@ -502,6 +514,15 @@ class DatabaseManager {
502
514
  storedEventType,
503
515
  eventData ? JSON.stringify(eventData) : null,
504
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),
505
526
  ]
506
527
  );
507
528
 
@@ -513,6 +534,7 @@ class DatabaseManager {
513
534
  viewId: publicId,
514
535
  eventType: storedEventType,
515
536
  isUnique: isUnique === 1,
537
+ hostname,
516
538
  });
517
539
 
518
540
  return {
@@ -523,6 +545,34 @@ class DatabaseManager {
523
545
  };
524
546
  }
525
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, and every half minute
552
+ * while it is being read, each time with its running total, so the larger
553
+ * value always wins. Each report also marks the view as seen just now,
554
+ * which is what keeps its visitor in "right now" between page views. Only a live view from the last
555
+ * TRACKING.ENGAGE_WINDOW_HOURS is updated: an old or trashed view keeps
556
+ * what it had.
557
+ *
558
+ * @param {string} appId already validated
559
+ * @param {{ viewId: string, engagedMs: number, scrollDepth: number }} engagement
560
+ * @returns {Promise<boolean>} whether a view was updated
561
+ */
562
+ async addEngagement(appId, { viewId, engagedMs, scrollDepth }) {
563
+ this.assertReady();
564
+ const [result] = await this.pool.query(
565
+ `UPDATE \`${appId}\`
566
+ SET engaged_ms = GREATEST(COALESCE(engaged_ms, 0), ?),
567
+ scroll_depth = GREATEST(COALESCE(scroll_depth, 0), ?),
568
+ last_seen_at = NOW()
569
+ WHERE public_id = ? AND ${LIVE_ROW}
570
+ AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
571
+ [engagedMs, scrollDepth, viewId, TRACKING.ENGAGE_WINDOW_HOURS]
572
+ );
573
+ return result.affectedRows > 0;
574
+ }
575
+
526
576
  /**
527
577
  * Register a view (backward compatible wrapper)
528
578
  */
@@ -7,7 +7,14 @@
7
7
 
8
8
  const crypto = require('crypto');
9
9
 
10
- const { ADMIN, ADMIN_LOG_TABLE, VIEW_LOG_TABLE } = require('../constants');
10
+ const {
11
+ ADMIN,
12
+ ADMIN_LOG_TABLE,
13
+ REJECTION_REASON,
14
+ TRACKING_OUTCOME,
15
+ TRACKING_REJECTIONS_TABLE,
16
+ VIEW_LOG_TABLE,
17
+ } = require('../constants');
11
18
  const { logWarning, WarningType } = require('../utils/errorUtils');
12
19
 
13
20
  /**
@@ -20,9 +27,29 @@ const ADMIN_LOG_COLUMNS = [
20
27
  ].join(', ');
21
28
 
22
29
  const VIEW_LOG_COLUMNS = [
23
- 'id', 'created_at', 'app_id', 'source', 'view_id', 'event_type', 'is_unique',
30
+ 'id', 'created_at', 'app_id', 'source', 'view_id', 'event_type', 'is_unique', 'hostname',
24
31
  ].join(', ');
25
32
 
33
+ /**
34
+ * The tracking log is two streams read as one: accepted views, one row each
35
+ * in the view log, and requests that were not stored, counted per minute.
36
+ * Both branches select the same columns so they can be merged and paged
37
+ * together, newest first.
38
+ */
39
+ const ACCEPTED_BRANCH = `
40
+ SELECT created_at AS at, id AS entry_id, app_id, source,
41
+ IF(is_unique = 1, '${TRACKING_OUTCOME.RECORDED}', '${TRACKING_OUTCOME.REPEAT}') AS outcome,
42
+ CAST(NULL AS CHAR) AS reason, CAST(NULL AS CHAR) AS detail, hostname,
43
+ view_id, event_type, 1 AS requests
44
+ FROM \`${VIEW_LOG_TABLE}\``;
45
+ const REJECTED_BRANCH = `
46
+ SELECT minute AS at, CONCAT_WS('|', minute, source, reason, app_id, detail, hostname) AS entry_id,
47
+ NULLIF(app_id, '') AS app_id, source,
48
+ IF(reason = '${REJECTION_REASON.BOT}', '${TRACKING_OUTCOME.BOT}', '${TRACKING_OUTCOME.REJECTED}') AS outcome,
49
+ reason, NULLIF(detail, '') AS detail, NULLIF(hostname, '') AS hostname,
50
+ CAST(NULL AS CHAR) AS view_id, CAST(NULL AS CHAR) AS event_type, requests
51
+ FROM \`${TRACKING_REJECTIONS_TABLE}\``;
52
+
26
53
  /** mysql2 returns JSON columns parsed; tolerate a string from other drivers. */
27
54
  function parseJson(value) {
28
55
  if (value === null || value === undefined) return null;
@@ -101,13 +128,13 @@ class LogRepository {
101
128
  * isUnique: boolean }} entry
102
129
  * @returns {Promise<boolean>}
103
130
  */
104
- async writeViewLog({ appId, source, viewId, eventType, isUnique }) {
131
+ async writeViewLog({ appId, source, viewId, eventType, isUnique, hostname = null }) {
105
132
  try {
106
133
  await this.pool.query(
107
134
  `INSERT INTO \`${VIEW_LOG_TABLE}\`
108
- (id, created_at, app_id, source, view_id, event_type, is_unique)
109
- VALUES (?, NOW(3), ?, ?, ?, ?, ?)`,
110
- [crypto.randomUUID(), appId, source, viewId, eventType, isUnique ? 1 : 0]
135
+ (id, created_at, app_id, source, view_id, event_type, is_unique, hostname)
136
+ VALUES (?, NOW(3), ?, ?, ?, ?, ?, ?)`,
137
+ [crypto.randomUUID(), appId, source, viewId, eventType, isUnique ? 1 : 0, hostname]
111
138
  );
112
139
  return true;
113
140
  } catch (cause) {
@@ -125,16 +152,51 @@ class LogRepository {
125
152
  * @returns {Promise<number>} entries removed
126
153
  */
127
154
  async pruneViewLog(days) {
128
- let total = 0;
129
- for (;;) {
130
- const [result] = await this.pool.query(
131
- `DELETE FROM \`${VIEW_LOG_TABLE}\` WHERE created_at < DATE_SUB(NOW(3), INTERVAL ? DAY)
132
- ORDER BY created_at LIMIT ?`,
133
- [days, ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE]
155
+ const prune = async (table, column) => {
156
+ let total = 0;
157
+ for (;;) {
158
+ const [result] = await this.pool.query(
159
+ `DELETE FROM \`${table}\` WHERE \`${column}\` < DATE_SUB(NOW(3), INTERVAL ? DAY)
160
+ ORDER BY \`${column}\` LIMIT ?`,
161
+ [days, ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE]
162
+ );
163
+ const removed = Number(result?.affectedRows || 0);
164
+ total += removed;
165
+ if (removed < ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE) return total;
166
+ }
167
+ };
168
+ return await prune(VIEW_LOG_TABLE, 'created_at') + await prune(TRACKING_REJECTIONS_TABLE, 'minute');
169
+ }
170
+
171
+ /**
172
+ * Add counted rejections, merging into any count already stored for the
173
+ * same minute and key. Never throws: losing a count must not fail the
174
+ * request that produced it, or the flush that carries many.
175
+ *
176
+ * @param {{ minute: Date, source: string, reason: string, appId?: string,
177
+ * detail?: string, hostname?: string, requests: number }[]} rows
178
+ * @returns {Promise<boolean>}
179
+ */
180
+ async recordRejections(rows) {
181
+ if (rows.length === 0) return true;
182
+ try {
183
+ await this.pool.query(
184
+ `INSERT INTO \`${TRACKING_REJECTIONS_TABLE}\`
185
+ (minute, source, reason, app_id, detail, hostname, requests)
186
+ VALUES ${rows.map(() => '(FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)').join(', ')}
187
+ ON DUPLICATE KEY UPDATE requests = requests + VALUES(requests)`,
188
+ // The minute as seconds since the epoch, placed in the database's
189
+ // own time zone like every NOW() it is compared with, whatever
190
+ // zone this process runs in.
191
+ rows.flatMap((row) => [
192
+ Math.floor(new Date(row.minute).getTime() / 1000), row.source, row.reason,
193
+ row.appId || '', row.detail || '', row.hostname || '', row.requests,
194
+ ])
134
195
  );
135
- const removed = Number(result?.affectedRows || 0);
136
- total += removed;
137
- if (removed < ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE) return total;
196
+ return true;
197
+ } catch (cause) {
198
+ logWarning(WarningType.TRACKING_LOG_WRITE_FAILED, { cause: cause.message });
199
+ return false;
138
200
  }
139
201
  }
140
202
 
@@ -176,37 +238,112 @@ class LogRepository {
176
238
  }
177
239
 
178
240
  /**
179
- * @param {{ page: number, pageSize: number, appId?: string, source?: string }} query
241
+ * The tracking log: every tracking request and what became of it, newest
242
+ * first. Accepted views are listed one by one; bots and rejections as one
243
+ * entry per minute and key, with how many requests it stands for.
244
+ *
245
+ * @param {{ page: number, pageSize: number, appId?: string, source?: string, outcome?: string }} query
180
246
  * @returns {Promise<{ entries: object[], total: number }>}
181
247
  */
182
- async listViewLog({ page, pageSize, appId, source }) {
183
- const where = [];
248
+ async listTrackingLog({ page, pageSize, appId, source, outcome }) {
249
+ const accepted = { where: [], params: [] };
250
+ const rejected = { where: [], params: [] };
251
+ for (const branch of [accepted, rejected]) {
252
+ if (appId) { branch.where.push('app_id = ?'); branch.params.push(appId); }
253
+ if (source) { branch.where.push('source = ?'); branch.params.push(source); }
254
+ }
255
+ if (outcome === TRACKING_OUTCOME.RECORDED) accepted.where.push('is_unique = 1');
256
+ if (outcome === TRACKING_OUTCOME.REPEAT) accepted.where.push('is_unique = 0');
257
+ if (outcome === TRACKING_OUTCOME.BOT) { rejected.where.push('reason = ?'); rejected.params.push(REJECTION_REASON.BOT); }
258
+ if (outcome === TRACKING_OUTCOME.REJECTED) { rejected.where.push('reason <> ?'); rejected.params.push(REJECTION_REASON.BOT); }
259
+
260
+ const wantAccepted = !outcome || outcome === TRACKING_OUTCOME.RECORDED || outcome === TRACKING_OUTCOME.REPEAT;
261
+ const wantRejected = !outcome || outcome === TRACKING_OUTCOME.BOT || outcome === TRACKING_OUTCOME.REJECTED;
262
+ const clause = (branch) => (branch.where.length ? ` WHERE ${branch.where.join(' AND ')}` : '');
263
+
264
+ // Each table gives at most the rows the page could need, in the same
265
+ // order as the whole, so a page never sorts either table in full; the
266
+ // view log reads its created_at index backwards to do it.
267
+ const offset = (page - 1) * pageSize;
268
+ const ORDER = 'ORDER BY at DESC, entry_id DESC';
269
+ const branches = [];
184
270
  const params = [];
185
- if (appId) { where.push('app_id = ?'); params.push(appId); }
186
- if (source) { where.push('source = ?'); params.push(source); }
187
- const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
271
+ for (const [want, branch, sql] of [[wantAccepted, accepted, ACCEPTED_BRANCH], [wantRejected, rejected, REJECTED_BRANCH]]) {
272
+ if (!want) continue;
273
+ branches.push(`(${sql}${clause(branch)} ${ORDER} LIMIT ?)`);
274
+ params.push(...branch.params, offset + pageSize);
275
+ }
188
276
 
189
277
  const [rows] = await this.pool.query(
190
- `SELECT ${VIEW_LOG_COLUMNS} FROM \`${VIEW_LOG_TABLE}\` ${clause}
191
- ORDER BY created_at DESC, id ASC LIMIT ? OFFSET ?`,
192
- [...params, pageSize, (page - 1) * pageSize]
193
- );
194
- const [count] = await this.pool.query(
195
- `SELECT COUNT(*) AS count FROM \`${VIEW_LOG_TABLE}\` ${clause}`,
196
- params
278
+ `SELECT * FROM (${branches.join(' UNION ALL ')}) AS entries
279
+ ${ORDER} LIMIT ? OFFSET ?`,
280
+ [...params, pageSize, offset]
197
281
  );
198
282
 
283
+ let total = 0;
284
+ if (wantAccepted) {
285
+ const [count] = await this.pool.query(
286
+ `SELECT COUNT(*) AS count FROM \`${VIEW_LOG_TABLE}\`${clause(accepted)}`, accepted.params);
287
+ total += Number(count[0]?.count || 0);
288
+ }
289
+ if (wantRejected) {
290
+ const [count] = await this.pool.query(
291
+ `SELECT COUNT(*) AS count FROM \`${TRACKING_REJECTIONS_TABLE}\`${clause(rejected)}`, rejected.params);
292
+ total += Number(count[0]?.count || 0);
293
+ }
294
+
199
295
  return {
200
296
  entries: rows.map((row) => ({
201
- id: row.id,
202
- createdAt: row.created_at,
203
- appId: row.app_id,
297
+ id: row.entry_id,
298
+ at: row.at,
299
+ appId: row.app_id ?? null,
204
300
  source: row.source,
205
- viewId: row.view_id,
206
- eventType: row.event_type,
207
- isUnique: row.is_unique === 1 || row.is_unique === true,
301
+ outcome: row.outcome,
302
+ reason: row.reason ?? null,
303
+ detail: row.detail ?? null,
304
+ hostname: row.hostname ?? null,
305
+ viewId: row.view_id ?? null,
306
+ eventType: row.event_type ?? null,
307
+ requests: Number(row.requests),
208
308
  })),
209
- total: Number(count[0]?.count || 0),
309
+ total,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * How the last `hours` of tracking requests turned out: requests per
315
+ * outcome, and per reason for the ones not stored.
316
+ *
317
+ * @param {{ hours: number, appId?: string }} query
318
+ * @returns {Promise<{ hours: number, outcomes: Record<string, number>, reasons: Record<string, number> }>}
319
+ */
320
+ async trackingSummary({ hours, appId }) {
321
+ const appClause = appId ? ' AND app_id = ?' : '';
322
+ const appParams = appId ? [appId] : [];
323
+ const [acceptedRows] = await this.pool.query(
324
+ `SELECT COALESCE(SUM(is_unique = 1), 0) AS recorded, COALESCE(SUM(is_unique = 0), 0) AS repeats
325
+ FROM \`${VIEW_LOG_TABLE}\` WHERE created_at >= DATE_SUB(NOW(3), INTERVAL ? HOUR)${appClause}`,
326
+ [hours, ...appParams]
327
+ );
328
+ const [reasonRows] = await this.pool.query(
329
+ `SELECT reason, SUM(requests) AS requests FROM \`${TRACKING_REJECTIONS_TABLE}\`
330
+ WHERE minute >= DATE_SUB(NOW(), INTERVAL ? HOUR)${appClause}
331
+ GROUP BY reason ORDER BY reason`,
332
+ [hours, ...appParams]
333
+ );
334
+
335
+ const reasons = Object.fromEntries(reasonRows.map((row) => [row.reason, Number(row.requests)]));
336
+ const bots = reasons[REJECTION_REASON.BOT] || 0;
337
+ const rejectedTotal = Object.values(reasons).reduce((sum, value) => sum + value, 0) - bots;
338
+ return {
339
+ hours,
340
+ outcomes: {
341
+ [TRACKING_OUTCOME.RECORDED]: Number(acceptedRows[0]?.recorded || 0),
342
+ [TRACKING_OUTCOME.REPEAT]: Number(acceptedRows[0]?.repeats || 0),
343
+ [TRACKING_OUTCOME.BOT]: bots,
344
+ [TRACKING_OUTCOME.REJECTED]: rejectedTotal,
345
+ },
346
+ reasons,
210
347
  };
211
348
  }
212
349
  }
package/db/adminSchema.js CHANGED
@@ -10,8 +10,10 @@ const crypto = require('crypto');
10
10
 
11
11
  const {
12
12
  ADMIN_LOG_TABLE,
13
+ ADMIN_SESSIONS_TABLE,
13
14
  DATABASE,
14
15
  FIELD_MAX_LENGTH,
16
+ TRACKING_REJECTIONS_TABLE,
15
17
  VIEW_LOG_TABLE,
16
18
  } = require('../constants');
17
19
  const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
@@ -36,11 +38,35 @@ const ADMIN_COLUMNS = [
36
38
  { name: 'deleted_at', ddl: 'DATETIME DEFAULT NULL' },
37
39
  ];
38
40
 
41
+ /**
42
+ * Columns 3.2 added for richer, still identifier-free analysis: which of the
43
+ * app's sites and which language, the campaign tags of the landing URL, an
44
+ * optional region and city (only with a city database configured), and how
45
+ * long the page was visible and how far it was scrolled. 3.3 added
46
+ * `last_seen_at`: when the page last reported its engagement, which is how
47
+ * "right now" still counts a visitor who has been reading one page for a while.
48
+ */
49
+ const TRACKING_COLUMNS = [
50
+ { name: 'hostname', ddl: `VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL` },
51
+ { name: 'language', ddl: `VARCHAR(${FIELD_MAX_LENGTH.LANGUAGE}) DEFAULT NULL` },
52
+ { name: 'utm_source', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
53
+ { name: 'utm_medium', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
54
+ { name: 'utm_campaign', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
55
+ { name: 'utm_term', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
56
+ { name: 'utm_content', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
57
+ { name: 'region', ddl: `VARCHAR(${FIELD_MAX_LENGTH.REGION}) DEFAULT NULL` },
58
+ { name: 'city', ddl: `VARCHAR(${FIELD_MAX_LENGTH.CITY}) DEFAULT NULL` },
59
+ { name: 'engaged_ms', ddl: 'INT UNSIGNED DEFAULT NULL' },
60
+ { name: 'scroll_depth', ddl: 'TINYINT UNSIGNED DEFAULT NULL' },
61
+ { name: 'last_seen_at', ddl: 'DATETIME DEFAULT NULL' },
62
+ ];
63
+
39
64
  /** Indexes the admin columns need, keyed by index name. */
40
65
  const ADMIN_INDEXES = {
41
66
  uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
42
67
  idx_deleted_at: 'INDEX `idx_deleted_at` (`deleted_at`)',
43
68
  idx_admin_modified_at: 'INDEX `idx_admin_modified_at` (`admin_modified_at`)',
69
+ idx_last_seen_at: 'INDEX `idx_last_seen_at` (`last_seen_at`)',
44
70
  };
45
71
 
46
72
  /**
@@ -83,11 +109,60 @@ const VIEW_LOG_DDL = `
83
109
  \`view_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL,
84
110
  \`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT NULL,
85
111
  \`is_unique\` TINYINT(1) NOT NULL,
112
+ \`hostname\` VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL,
86
113
  INDEX \`idx_created_at\` (\`created_at\`),
87
114
  INDEX \`idx_app_created\` (\`app_id\`, \`created_at\`)
88
115
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
89
116
  `;
90
117
 
118
+ /**
119
+ * Admin sign-ins, so a restart or deploy does not sign anyone out.
120
+ *
121
+ * Holds only the SHA-256 of each session's token (the browser has the token
122
+ * itself, in an HttpOnly cookie), so reading this table yields no usable
123
+ * session. The CSRF token is derived from the session token, not stored.
124
+ * `password_at` is when the password was last entered, for actions that ask
125
+ * for it again.
126
+ */
127
+ const ADMIN_SESSIONS_DDL = `
128
+ CREATE TABLE IF NOT EXISTS \`${ADMIN_SESSIONS_TABLE}\` (
129
+ \`token_hash\` CHAR(64) PRIMARY KEY,
130
+ \`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL,
131
+ \`created_at\` DATETIME(3) NOT NULL,
132
+ \`last_seen_at\` DATETIME(3) NOT NULL,
133
+ \`password_at\` DATETIME(3) NOT NULL,
134
+ INDEX \`idx_last_seen_at\` (\`last_seen_at\`)
135
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
136
+ `;
137
+
138
+ /** Columns the view log gained after it first shipped, for tables created by 3.1. */
139
+ const VIEW_LOG_ADDED_COLUMNS = [
140
+ { name: 'hostname', ddl: `VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL` },
141
+ ];
142
+
143
+ /**
144
+ * Tracking requests that were not stored: bots and rejections, with why.
145
+ *
146
+ * Counted per minute rather than kept one by one, so a flood of bad requests
147
+ * adds to a counter instead of a row each, and nothing about the requester
148
+ * (no IP, hash, or User-Agent) is kept. The key columns use '' rather than
149
+ * NULL for "none", because NULL never matches itself in a unique key and every
150
+ * NULL would start a new row.
151
+ */
152
+ const TRACKING_REJECTIONS_DDL = `
153
+ CREATE TABLE IF NOT EXISTS \`${TRACKING_REJECTIONS_TABLE}\` (
154
+ \`minute\` DATETIME NOT NULL,
155
+ \`source\` VARCHAR(16) NOT NULL,
156
+ \`reason\` VARCHAR(32) NOT NULL,
157
+ \`app_id\` VARCHAR(64) NOT NULL DEFAULT '',
158
+ \`detail\` VARCHAR(64) NOT NULL DEFAULT '',
159
+ \`hostname\` VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) NOT NULL DEFAULT '',
160
+ \`requests\` INT UNSIGNED NOT NULL,
161
+ PRIMARY KEY (\`minute\`, \`source\`, \`reason\`, \`app_id\`, \`detail\`, \`hostname\`),
162
+ INDEX \`idx_app_minute\` (\`app_id\`, \`minute\`)
163
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
164
+ `;
165
+
91
166
  /**
92
167
  * Column and index definitions for a brand-new app table, spliced into
93
168
  * `appTableDDL` so a fresh table is born in the migrated shape.
@@ -97,6 +172,7 @@ const NEW_TABLE_ADMIN_COLUMNS = [
97
172
  `\`note\` VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL`,
98
173
  '`admin_modified_at` DATETIME DEFAULT NULL',
99
174
  '`deleted_at` DATETIME DEFAULT NULL',
175
+ ...TRACKING_COLUMNS.map((column) => `\`${column.name}\` ${column.ddl}`),
100
176
  ];
101
177
 
102
178
  const NEW_TABLE_ADMIN_INDEXES = Object.values(ADMIN_INDEXES);
@@ -192,7 +268,7 @@ async function migrateAppTable(pool, appId) {
192
268
  return { migrated: false, backfilled: 0 };
193
269
  }
194
270
 
195
- for (const column of ADMIN_COLUMNS) {
271
+ for (const column of [...ADMIN_COLUMNS, ...TRACKING_COLUMNS]) {
196
272
  if (!columns.has(column.name)) {
197
273
  await pool.query(`ALTER TABLE \`${appId}\` ADD COLUMN \`${column.name}\` ${column.ddl}`);
198
274
  }
@@ -221,21 +297,34 @@ async function migrateAppTable(pool, appId) {
221
297
  }
222
298
 
223
299
  /**
224
- * Create the log tables. Safe in `connect` mode for the same reason the app
225
- * registry is: they are the service's own bookkeeping, not the operator's
226
- * schema.
300
+ * Create the service's own tables: the logs and the admin sessions. Safe in
301
+ * `connect` mode for the same reason the app registry is: they are the
302
+ * service's own bookkeeping, not the operator's schema.
227
303
  * @param {object} pool
228
304
  */
229
305
  async function ensureLogTables(pool) {
230
306
  await pool.query(ADMIN_LOG_DDL);
231
307
  await pool.query(VIEW_LOG_DDL);
308
+ await pool.query(TRACKING_REJECTIONS_DDL);
309
+ await pool.query(ADMIN_SESSIONS_DDL);
310
+
311
+ const viewLogColumns = await readColumns(pool, VIEW_LOG_TABLE);
312
+ for (const column of VIEW_LOG_ADDED_COLUMNS) {
313
+ if (!viewLogColumns.has(column.name)) {
314
+ await pool.query(`ALTER TABLE \`${VIEW_LOG_TABLE}\` ADD COLUMN \`${column.name}\` ${column.ddl}`);
315
+ }
316
+ }
232
317
  }
233
318
 
234
319
  module.exports = {
235
320
  ADMIN_COLUMNS,
321
+ TRACKING_COLUMNS,
236
322
  ADMIN_INDEXES,
237
323
  ADMIN_LOG_DDL,
238
324
  VIEW_LOG_DDL,
325
+ VIEW_LOG_ADDED_COLUMNS,
326
+ TRACKING_REJECTIONS_DDL,
327
+ ADMIN_SESSIONS_DDL,
239
328
  NEW_TABLE_ADMIN_COLUMNS,
240
329
  NEW_TABLE_ADMIN_INDEXES,
241
330
  backfillPublicIds,
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Admin sessions in the database, so a restart or deploy signs nobody out.
3
+ *
4
+ * Same interface as the in-memory store in middleware/adminAuth.js. The table
5
+ * holds each token's SHA-256, never the token. Ages are computed by the
6
+ * database's clock, the same clock that wrote the timestamps, and a session's
7
+ * last-seen time is written at most once a minute however busy the admin is.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ const { ADMIN, ADMIN_SESSIONS_TABLE } = require('../constants');
13
+ const { hashToken, newToken } = require('../middleware/adminAuth');
14
+
15
+ const TABLE = `\`${ADMIN_SESSIONS_TABLE}\``;
16
+ const seconds = (ms) => Math.floor(ms / 1000);
17
+
18
+ /**
19
+ * @param {{ pool: object }} db a DatabaseManager; its pool exists once initialized
20
+ * @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number }} [options]
21
+ */
22
+ function createDbSessionStore(db, {
23
+ idleMs = ADMIN.SESSION_IDLE_TIMEOUT_MS,
24
+ absoluteMs = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
25
+ maxSessions = ADMIN.MAX_SESSIONS,
26
+ } = {}) {
27
+ const query = (sql, params) => db.pool.query(sql, params);
28
+
29
+ return {
30
+ idleMs,
31
+ absoluteMs,
32
+
33
+ async create() {
34
+ // Expired sessions go first, so the table never outgrows the
35
+ // sessions that could still be used.
36
+ await query(
37
+ `DELETE FROM ${TABLE}
38
+ WHERE last_seen_at < DATE_SUB(NOW(3), INTERVAL ? SECOND)
39
+ OR created_at < DATE_SUB(NOW(3), INTERVAL ? SECOND)`,
40
+ [seconds(idleMs), seconds(absoluteMs)]
41
+ );
42
+ const token = newToken();
43
+ const id = crypto.randomUUID();
44
+ await query(
45
+ `INSERT INTO ${TABLE} (token_hash, id, created_at, last_seen_at, password_at)
46
+ VALUES (?, ?, NOW(3), NOW(3), NOW(3))`,
47
+ [hashToken(token), id]
48
+ );
49
+ // Bounded like the in-memory store: beyond the limit, the least
50
+ // recently used sessions end. The derived table lets MySQL read
51
+ // the table it is deleting from.
52
+ await query(
53
+ `DELETE FROM ${TABLE} WHERE token_hash NOT IN (
54
+ SELECT token_hash FROM (
55
+ SELECT token_hash FROM ${TABLE} ORDER BY last_seen_at DESC, created_at DESC LIMIT ?
56
+ ) AS newest
57
+ )`,
58
+ [maxSessions]
59
+ );
60
+ return { token, session: { id, passwordAgeMs: 0 } };
61
+ },
62
+
63
+ async get(token) {
64
+ if (typeof token !== 'string' || token.length === 0) return null;
65
+ const key = hashToken(token);
66
+ const [rows] = await query(
67
+ `SELECT id,
68
+ TIMESTAMPDIFF(SECOND, created_at, NOW(3)) AS age_s,
69
+ TIMESTAMPDIFF(SECOND, last_seen_at, NOW(3)) AS idle_s,
70
+ TIMESTAMPDIFF(SECOND, password_at, NOW(3)) AS password_age_s
71
+ FROM ${TABLE} WHERE token_hash = ?`,
72
+ [key]
73
+ );
74
+ const row = rows[0];
75
+ if (!row) return null;
76
+
77
+ const idle = Number(row.idle_s) * 1000;
78
+ if (idle > idleMs || Number(row.age_s) * 1000 > absoluteMs) {
79
+ await query(`DELETE FROM ${TABLE} WHERE token_hash = ?`, [key]);
80
+ return null;
81
+ }
82
+ if (idle >= ADMIN.SESSION_TOUCH_INTERVAL_MS) {
83
+ await query(`UPDATE ${TABLE} SET last_seen_at = NOW(3) WHERE token_hash = ?`, [key]);
84
+ }
85
+ return { id: row.id, passwordAgeMs: Number(row.password_age_s) * 1000 };
86
+ },
87
+
88
+ async destroy(token) {
89
+ if (typeof token !== 'string' || token.length === 0) return false;
90
+ const [result] = await query(`DELETE FROM ${TABLE} WHERE token_hash = ?`, [hashToken(token)]);
91
+ return result.affectedRows > 0;
92
+ },
93
+
94
+ async confirmPassword(token) {
95
+ if (typeof token !== 'string' || token.length === 0) return;
96
+ await query(
97
+ `UPDATE ${TABLE} SET password_at = NOW(3), last_seen_at = NOW(3) WHERE token_hash = ?`,
98
+ [hashToken(token)]
99
+ );
100
+ },
101
+ };
102
+ }
103
+
104
+ module.exports = { createDbSessionStore };