@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.
- package/.env.example +34 -10
- package/README.md +306 -136
- package/admin/css/admin.css +891 -198
- package/admin/index.html +13 -7
- package/admin/js/api.js +52 -6
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +529 -189
- package/admin/js/constants.js +98 -9
- package/admin/js/dataTable.js +478 -0
- package/admin/js/format.js +58 -7
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +2 -1
- package/admin/js/logs.js +211 -60
- package/admin/js/main.js +201 -37
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +12 -52
- package/admin/js/viewDialogs.js +30 -14
- package/admin/js/views.js +273 -207
- package/admin/locales/en.json +352 -63
- package/config/index.js +40 -5
- package/constants.js +126 -8
- package/db/AdminRepository.js +85 -159
- package/db/DatabaseManager.js +55 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +89 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +53 -19
- package/middleware/adminAuth.js +83 -43
- package/middleware/adminValidation.js +69 -3
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +5 -2
- package/routes/admin.js +130 -22
- package/routes/analytics.js +197 -18
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +4 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
- package/admin/js/insights.js +0 -192
package/db/DatabaseManager.js
CHANGED
|
@@ -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`
|
|
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
|
|
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
|
|
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
|
-
|
|
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,32 @@ 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, 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
|
+
|
|
526
574
|
/**
|
|
527
575
|
* Register a view (backward compatible wrapper)
|
|
528
576
|
*/
|
package/db/LogRepository.js
CHANGED
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
const crypto = require('crypto');
|
|
9
9
|
|
|
10
|
-
const {
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
*
|
|
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
|
|
183
|
-
const
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 ${
|
|
191
|
-
ORDER
|
|
192
|
-
[...params, 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.
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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,6 +38,26 @@ 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.
|
|
46
|
+
*/
|
|
47
|
+
const TRACKING_COLUMNS = [
|
|
48
|
+
{ name: 'hostname', ddl: `VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL` },
|
|
49
|
+
{ name: 'language', ddl: `VARCHAR(${FIELD_MAX_LENGTH.LANGUAGE}) DEFAULT NULL` },
|
|
50
|
+
{ name: 'utm_source', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
|
|
51
|
+
{ name: 'utm_medium', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
|
|
52
|
+
{ name: 'utm_campaign', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
|
|
53
|
+
{ name: 'utm_term', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
|
|
54
|
+
{ name: 'utm_content', ddl: `VARCHAR(${FIELD_MAX_LENGTH.UTM}) DEFAULT NULL` },
|
|
55
|
+
{ name: 'region', ddl: `VARCHAR(${FIELD_MAX_LENGTH.REGION}) DEFAULT NULL` },
|
|
56
|
+
{ name: 'city', ddl: `VARCHAR(${FIELD_MAX_LENGTH.CITY}) DEFAULT NULL` },
|
|
57
|
+
{ name: 'engaged_ms', ddl: 'INT UNSIGNED DEFAULT NULL' },
|
|
58
|
+
{ name: 'scroll_depth', ddl: 'TINYINT UNSIGNED DEFAULT NULL' },
|
|
59
|
+
];
|
|
60
|
+
|
|
39
61
|
/** Indexes the admin columns need, keyed by index name. */
|
|
40
62
|
const ADMIN_INDEXES = {
|
|
41
63
|
uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
|
|
@@ -83,11 +105,60 @@ const VIEW_LOG_DDL = `
|
|
|
83
105
|
\`view_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL,
|
|
84
106
|
\`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT NULL,
|
|
85
107
|
\`is_unique\` TINYINT(1) NOT NULL,
|
|
108
|
+
\`hostname\` VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL,
|
|
86
109
|
INDEX \`idx_created_at\` (\`created_at\`),
|
|
87
110
|
INDEX \`idx_app_created\` (\`app_id\`, \`created_at\`)
|
|
88
111
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
89
112
|
`;
|
|
90
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Admin sign-ins, so a restart or deploy does not sign anyone out.
|
|
116
|
+
*
|
|
117
|
+
* Holds only the SHA-256 of each session's token (the browser has the token
|
|
118
|
+
* itself, in an HttpOnly cookie), so reading this table yields no usable
|
|
119
|
+
* session. The CSRF token is derived from the session token, not stored.
|
|
120
|
+
* `password_at` is when the password was last entered, for actions that ask
|
|
121
|
+
* for it again.
|
|
122
|
+
*/
|
|
123
|
+
const ADMIN_SESSIONS_DDL = `
|
|
124
|
+
CREATE TABLE IF NOT EXISTS \`${ADMIN_SESSIONS_TABLE}\` (
|
|
125
|
+
\`token_hash\` CHAR(64) PRIMARY KEY,
|
|
126
|
+
\`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL,
|
|
127
|
+
\`created_at\` DATETIME(3) NOT NULL,
|
|
128
|
+
\`last_seen_at\` DATETIME(3) NOT NULL,
|
|
129
|
+
\`password_at\` DATETIME(3) NOT NULL,
|
|
130
|
+
INDEX \`idx_last_seen_at\` (\`last_seen_at\`)
|
|
131
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
/** Columns the view log gained after it first shipped, for tables created by 3.1. */
|
|
135
|
+
const VIEW_LOG_ADDED_COLUMNS = [
|
|
136
|
+
{ name: 'hostname', ddl: `VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL` },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Tracking requests that were not stored: bots and rejections, with why.
|
|
141
|
+
*
|
|
142
|
+
* Counted per minute rather than kept one by one, so a flood of bad requests
|
|
143
|
+
* adds to a counter instead of a row each, and nothing about the requester
|
|
144
|
+
* (no IP, hash, or User-Agent) is kept. The key columns use '' rather than
|
|
145
|
+
* NULL for "none", because NULL never matches itself in a unique key and every
|
|
146
|
+
* NULL would start a new row.
|
|
147
|
+
*/
|
|
148
|
+
const TRACKING_REJECTIONS_DDL = `
|
|
149
|
+
CREATE TABLE IF NOT EXISTS \`${TRACKING_REJECTIONS_TABLE}\` (
|
|
150
|
+
\`minute\` DATETIME NOT NULL,
|
|
151
|
+
\`source\` VARCHAR(16) NOT NULL,
|
|
152
|
+
\`reason\` VARCHAR(32) NOT NULL,
|
|
153
|
+
\`app_id\` VARCHAR(64) NOT NULL DEFAULT '',
|
|
154
|
+
\`detail\` VARCHAR(64) NOT NULL DEFAULT '',
|
|
155
|
+
\`hostname\` VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) NOT NULL DEFAULT '',
|
|
156
|
+
\`requests\` INT UNSIGNED NOT NULL,
|
|
157
|
+
PRIMARY KEY (\`minute\`, \`source\`, \`reason\`, \`app_id\`, \`detail\`, \`hostname\`),
|
|
158
|
+
INDEX \`idx_app_minute\` (\`app_id\`, \`minute\`)
|
|
159
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
160
|
+
`;
|
|
161
|
+
|
|
91
162
|
/**
|
|
92
163
|
* Column and index definitions for a brand-new app table, spliced into
|
|
93
164
|
* `appTableDDL` so a fresh table is born in the migrated shape.
|
|
@@ -97,6 +168,7 @@ const NEW_TABLE_ADMIN_COLUMNS = [
|
|
|
97
168
|
`\`note\` VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL`,
|
|
98
169
|
'`admin_modified_at` DATETIME DEFAULT NULL',
|
|
99
170
|
'`deleted_at` DATETIME DEFAULT NULL',
|
|
171
|
+
...TRACKING_COLUMNS.map((column) => `\`${column.name}\` ${column.ddl}`),
|
|
100
172
|
];
|
|
101
173
|
|
|
102
174
|
const NEW_TABLE_ADMIN_INDEXES = Object.values(ADMIN_INDEXES);
|
|
@@ -192,7 +264,7 @@ async function migrateAppTable(pool, appId) {
|
|
|
192
264
|
return { migrated: false, backfilled: 0 };
|
|
193
265
|
}
|
|
194
266
|
|
|
195
|
-
for (const column of ADMIN_COLUMNS) {
|
|
267
|
+
for (const column of [...ADMIN_COLUMNS, ...TRACKING_COLUMNS]) {
|
|
196
268
|
if (!columns.has(column.name)) {
|
|
197
269
|
await pool.query(`ALTER TABLE \`${appId}\` ADD COLUMN \`${column.name}\` ${column.ddl}`);
|
|
198
270
|
}
|
|
@@ -221,21 +293,34 @@ async function migrateAppTable(pool, appId) {
|
|
|
221
293
|
}
|
|
222
294
|
|
|
223
295
|
/**
|
|
224
|
-
* Create the
|
|
225
|
-
*
|
|
226
|
-
* schema.
|
|
296
|
+
* Create the service's own tables: the logs and the admin sessions. Safe in
|
|
297
|
+
* `connect` mode for the same reason the app registry is: they are the
|
|
298
|
+
* service's own bookkeeping, not the operator's schema.
|
|
227
299
|
* @param {object} pool
|
|
228
300
|
*/
|
|
229
301
|
async function ensureLogTables(pool) {
|
|
230
302
|
await pool.query(ADMIN_LOG_DDL);
|
|
231
303
|
await pool.query(VIEW_LOG_DDL);
|
|
304
|
+
await pool.query(TRACKING_REJECTIONS_DDL);
|
|
305
|
+
await pool.query(ADMIN_SESSIONS_DDL);
|
|
306
|
+
|
|
307
|
+
const viewLogColumns = await readColumns(pool, VIEW_LOG_TABLE);
|
|
308
|
+
for (const column of VIEW_LOG_ADDED_COLUMNS) {
|
|
309
|
+
if (!viewLogColumns.has(column.name)) {
|
|
310
|
+
await pool.query(`ALTER TABLE \`${VIEW_LOG_TABLE}\` ADD COLUMN \`${column.name}\` ${column.ddl}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
232
313
|
}
|
|
233
314
|
|
|
234
315
|
module.exports = {
|
|
235
316
|
ADMIN_COLUMNS,
|
|
317
|
+
TRACKING_COLUMNS,
|
|
236
318
|
ADMIN_INDEXES,
|
|
237
319
|
ADMIN_LOG_DDL,
|
|
238
320
|
VIEW_LOG_DDL,
|
|
321
|
+
VIEW_LOG_ADDED_COLUMNS,
|
|
322
|
+
TRACKING_REJECTIONS_DDL,
|
|
323
|
+
ADMIN_SESSIONS_DDL,
|
|
239
324
|
NEW_TABLE_ADMIN_COLUMNS,
|
|
240
325
|
NEW_TABLE_ADMIN_INDEXES,
|
|
241
326
|
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 };
|