@harshankur/viewcounter 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * The two service logs: the admin operation log and the view register log.
3
+ *
4
+ * Both are append-only from the application's point of view. Nothing here
5
+ * updates or deletes an entry.
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+
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');
18
+ const { logWarning, WarningType } = require('../utils/errorUtils');
19
+
20
+ /**
21
+ * Columns returned by an admin-log listing. `session_id` identifies which
22
+ * admin session acted without revealing its token.
23
+ */
24
+ const ADMIN_LOG_COLUMNS = [
25
+ 'id', 'created_at', 'action', 'session_id', 'masked_ip',
26
+ 'app_id', 'target_count', 'target_ids', 'fields',
27
+ ].join(', ');
28
+
29
+ const VIEW_LOG_COLUMNS = [
30
+ 'id', 'created_at', 'app_id', 'source', 'view_id', 'event_type', 'is_unique', 'hostname',
31
+ ].join(', ');
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
+
53
+ /** mysql2 returns JSON columns parsed; tolerate a string from other drivers. */
54
+ function parseJson(value) {
55
+ if (value === null || value === undefined) return null;
56
+ if (typeof value !== 'string') return value;
57
+ try {
58
+ return JSON.parse(value);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ class LogRepository {
65
+ /**
66
+ * @param {{ pool: object, assertReady: () => void }} db a DatabaseManager
67
+ */
68
+ constructor(db) {
69
+ this.db = db;
70
+ }
71
+
72
+ get pool() {
73
+ this.db.assertReady();
74
+ return this.db.pool;
75
+ }
76
+
77
+ /**
78
+ * Record an admin operation. Never throws: an operation that succeeded must
79
+ * not be reported as failed because its log line could not be written
80
+ * (LOGGING.md §4). The failure is surfaced as a warning instead.
81
+ *
82
+ * `targetCount` defaults to the number of IDs; the automatic trash purge
83
+ * passes a count with no IDs, since it erases by age rather than by ID.
84
+ *
85
+ * @param {{ action: string, sessionId?: string|null, maskedIp?: string|null,
86
+ * appId?: string|null, targetIds?: string[], targetCount?: number,
87
+ * fields?: string[] }} entry
88
+ * @returns {Promise<boolean>} whether the entry was written
89
+ */
90
+ async writeAdminLog({
91
+ action,
92
+ sessionId = null,
93
+ maskedIp = null,
94
+ appId = null,
95
+ targetIds = [],
96
+ targetCount = targetIds.length,
97
+ fields = [],
98
+ }) {
99
+ try {
100
+ await this.pool.query(
101
+ `INSERT INTO \`${ADMIN_LOG_TABLE}\`
102
+ (id, created_at, action, session_id, masked_ip, app_id, target_count, target_ids, fields)
103
+ VALUES (?, NOW(3), ?, ?, ?, ?, ?, ?, ?)`,
104
+ [
105
+ crypto.randomUUID(),
106
+ action,
107
+ sessionId,
108
+ maskedIp,
109
+ appId,
110
+ targetCount,
111
+ targetIds.length ? JSON.stringify(targetIds) : null,
112
+ fields.length ? JSON.stringify(fields) : null,
113
+ ]
114
+ );
115
+ return true;
116
+ } catch (cause) {
117
+ logWarning(WarningType.ADMIN_LOG_WRITE_FAILED, { action, cause: cause.message });
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Record one accepted view or event. Never throws, for the same reason as
124
+ * writeAdminLog: the view itself was stored, so the visitor's request
125
+ * succeeded.
126
+ *
127
+ * @param {{ appId: string, source: string, viewId: string, eventType: string,
128
+ * isUnique: boolean }} entry
129
+ * @returns {Promise<boolean>}
130
+ */
131
+ async writeViewLog({ appId, source, viewId, eventType, isUnique, hostname = null }) {
132
+ try {
133
+ await this.pool.query(
134
+ `INSERT INTO \`${VIEW_LOG_TABLE}\`
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]
138
+ );
139
+ return true;
140
+ } catch (cause) {
141
+ logWarning(WarningType.VIEW_LOG_WRITE_FAILED, { appId, cause: cause.message });
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Remove view-log entries older than `days`, a batch at a time so no single
148
+ * statement holds its locks for long. Ordered by the indexed `created_at`,
149
+ * so each batch reads only what it deletes.
150
+ *
151
+ * @param {number} days
152
+ * @returns {Promise<number>} entries removed
153
+ */
154
+ async pruneViewLog(days) {
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
+ ])
195
+ );
196
+ return true;
197
+ } catch (cause) {
198
+ logWarning(WarningType.TRACKING_LOG_WRITE_FAILED, { cause: cause.message });
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * @param {{ page: number, pageSize: number, action?: string, appId?: string }} query
205
+ * @returns {Promise<{ entries: object[], total: number }>}
206
+ */
207
+ async listAdminLog({ page, pageSize, action, appId }) {
208
+ const where = [];
209
+ const params = [];
210
+ if (action) { where.push('action = ?'); params.push(action); }
211
+ if (appId) { where.push('app_id = ?'); params.push(appId); }
212
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
213
+
214
+ const [rows] = await this.pool.query(
215
+ `SELECT ${ADMIN_LOG_COLUMNS} FROM \`${ADMIN_LOG_TABLE}\` ${clause}
216
+ ORDER BY created_at DESC, id ASC LIMIT ? OFFSET ?`,
217
+ [...params, pageSize, (page - 1) * pageSize]
218
+ );
219
+ const [count] = await this.pool.query(
220
+ `SELECT COUNT(*) AS count FROM \`${ADMIN_LOG_TABLE}\` ${clause}`,
221
+ params
222
+ );
223
+
224
+ return {
225
+ entries: rows.map((row) => ({
226
+ id: row.id,
227
+ createdAt: row.created_at,
228
+ action: row.action,
229
+ sessionId: row.session_id,
230
+ maskedIp: row.masked_ip,
231
+ appId: row.app_id,
232
+ targetCount: row.target_count,
233
+ targetIds: parseJson(row.target_ids) || [],
234
+ fields: parseJson(row.fields) || [],
235
+ })),
236
+ total: Number(count[0]?.count || 0),
237
+ };
238
+ }
239
+
240
+ /**
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
246
+ * @returns {Promise<{ entries: object[], total: number }>}
247
+ */
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 = [];
270
+ const params = [];
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
+ }
276
+
277
+ const [rows] = await this.pool.query(
278
+ `SELECT * FROM (${branches.join(' UNION ALL ')}) AS entries
279
+ ${ORDER} LIMIT ? OFFSET ?`,
280
+ [...params, pageSize, offset]
281
+ );
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
+
295
+ return {
296
+ entries: rows.map((row) => ({
297
+ id: row.entry_id,
298
+ at: row.at,
299
+ appId: row.app_id ?? null,
300
+ source: row.source,
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),
308
+ })),
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,
347
+ };
348
+ }
349
+ }
350
+
351
+ module.exports = LogRepository;
352
+ module.exports.ADMIN_LOG_COLUMNS = ADMIN_LOG_COLUMNS;
353
+ module.exports.VIEW_LOG_COLUMNS = VIEW_LOG_COLUMNS;
354
+ module.exports.parseJson = parseJson;
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Schema for the admin feature: per-app admin columns, the two log tables, and
3
+ * the idempotent migration that brings an existing deployment up to date.
4
+ *
5
+ * Every statement here is additive. Nothing is dropped or rewritten, so a
6
+ * deployment can move to this version and back without losing a row.
7
+ */
8
+
9
+ const crypto = require('crypto');
10
+
11
+ const {
12
+ ADMIN_LOG_TABLE,
13
+ ADMIN_SESSIONS_TABLE,
14
+ DATABASE,
15
+ FIELD_MAX_LENGTH,
16
+ TRACKING_REJECTIONS_TABLE,
17
+ VIEW_LOG_TABLE,
18
+ } = require('../constants');
19
+ const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
20
+ const { isValidAppId } = require('../utils/appIdUtils');
21
+
22
+ /**
23
+ * Columns added to every app table.
24
+ *
25
+ * `public_id` is the identity the admin API and UI use (CODE_STANDARDS.md §8):
26
+ * the auto-increment `id` is enumerable and reveals how many rows exist, so it
27
+ * never leaves the server. It starts nullable only so an existing table can be
28
+ * backfilled; the migration then makes it NOT NULL and unique.
29
+ *
30
+ * `admin_modified_at` is the "modified by an admin" marker: NULL means the row
31
+ * is exactly as observed, a timestamp says when an admin last changed its
32
+ * content. `deleted_at` is the soft-delete marker.
33
+ */
34
+ const ADMIN_COLUMNS = [
35
+ { name: 'public_id', ddl: `CHAR(${FIELD_MAX_LENGTH.UUID}) DEFAULT NULL` },
36
+ { name: 'note', ddl: `VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL` },
37
+ { name: 'admin_modified_at', ddl: 'DATETIME DEFAULT NULL' },
38
+ { name: 'deleted_at', ddl: 'DATETIME DEFAULT NULL' },
39
+ ];
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
+
61
+ /** Indexes the admin columns need, keyed by index name. */
62
+ const ADMIN_INDEXES = {
63
+ uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
64
+ idx_deleted_at: 'INDEX `idx_deleted_at` (`deleted_at`)',
65
+ idx_admin_modified_at: 'INDEX `idx_admin_modified_at` (`admin_modified_at`)',
66
+ };
67
+
68
+ /**
69
+ * The admin operation log: who did what, when, to which rows.
70
+ *
71
+ * Deliberately records WHICH fields an edit touched, never their values, and
72
+ * never an IP beyond its masked form. A log that kept copies of row content
73
+ * would survive the row's permanent erasure and defeat it (GDPR Art. 17).
74
+ */
75
+ const ADMIN_LOG_DDL = `
76
+ CREATE TABLE IF NOT EXISTS \`${ADMIN_LOG_TABLE}\` (
77
+ \`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) PRIMARY KEY,
78
+ \`created_at\` DATETIME(3) NOT NULL,
79
+ \`action\` VARCHAR(32) NOT NULL,
80
+ \`session_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) DEFAULT NULL,
81
+ \`masked_ip\` VARCHAR(${FIELD_MAX_LENGTH.MASKED_IP}) DEFAULT NULL,
82
+ \`app_id\` VARCHAR(64) DEFAULT NULL,
83
+ \`target_count\` INT NOT NULL DEFAULT 0,
84
+ \`target_ids\` JSON DEFAULT NULL,
85
+ \`fields\` JSON DEFAULT NULL,
86
+ INDEX \`idx_created_at\` (\`created_at\`),
87
+ INDEX \`idx_action\` (\`action\`),
88
+ INDEX \`idx_app_id\` (\`app_id\`)
89
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
90
+ `;
91
+
92
+ /**
93
+ * The view register log: one entry per accepted view or event.
94
+ *
95
+ * Independent of the app tables on purpose, so it still says a view was
96
+ * reported, and when, after an admin edits, deletes, or erases that view. It
97
+ * holds no IP, hash, or User-Agent, so it contains no personal data to erase.
98
+ */
99
+ const VIEW_LOG_DDL = `
100
+ CREATE TABLE IF NOT EXISTS \`${VIEW_LOG_TABLE}\` (
101
+ \`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) PRIMARY KEY,
102
+ \`created_at\` DATETIME(3) NOT NULL,
103
+ \`app_id\` VARCHAR(64) NOT NULL,
104
+ \`source\` VARCHAR(16) NOT NULL,
105
+ \`view_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL,
106
+ \`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT NULL,
107
+ \`is_unique\` TINYINT(1) NOT NULL,
108
+ \`hostname\` VARCHAR(${FIELD_MAX_LENGTH.HOSTNAME}) DEFAULT NULL,
109
+ INDEX \`idx_created_at\` (\`created_at\`),
110
+ INDEX \`idx_app_created\` (\`app_id\`, \`created_at\`)
111
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
112
+ `;
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
+
162
+ /**
163
+ * Column and index definitions for a brand-new app table, spliced into
164
+ * `appTableDDL` so a fresh table is born in the migrated shape.
165
+ */
166
+ const NEW_TABLE_ADMIN_COLUMNS = [
167
+ `\`public_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL`,
168
+ `\`note\` VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL`,
169
+ '`admin_modified_at` DATETIME DEFAULT NULL',
170
+ '`deleted_at` DATETIME DEFAULT NULL',
171
+ ...TRACKING_COLUMNS.map((column) => `\`${column.name}\` ${column.ddl}`),
172
+ ];
173
+
174
+ const NEW_TABLE_ADMIN_INDEXES = Object.values(ADMIN_INDEXES);
175
+
176
+ /**
177
+ * @param {object} pool mysql2 promise pool
178
+ * @param {string} table
179
+ * @returns {Promise<Map<string, {nullable: boolean}>>} existing columns
180
+ */
181
+ async function readColumns(pool, table) {
182
+ const [rows] = await pool.query(
183
+ `SELECT COLUMN_NAME AS name, IS_NULLABLE AS nullable
184
+ FROM information_schema.COLUMNS
185
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
186
+ [table]
187
+ );
188
+ return new Map(rows.map((row) => [row.name, { nullable: row.nullable === 'YES' }]));
189
+ }
190
+
191
+ /**
192
+ * @param {object} pool
193
+ * @param {string} table
194
+ * @returns {Promise<Set<string>>} existing index names
195
+ */
196
+ async function readIndexes(pool, table) {
197
+ const [rows] = await pool.query(
198
+ `SELECT DISTINCT INDEX_NAME AS name
199
+ FROM information_schema.STATISTICS
200
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
201
+ [table]
202
+ );
203
+ return new Set(rows.map((row) => row.name));
204
+ }
205
+
206
+ /**
207
+ * Give every row without a public_id a fresh UUID, a batch at a time.
208
+ *
209
+ * Generated in Node with a CSPRNG rather than MySQL's UUID(), which is a
210
+ * time-and-host based v1 value (CODE_STANDARDS.md §8), and batched so a large
211
+ * table is not rewritten in one giant statement. Each batch resumes after the
212
+ * last id it saw (keyset pagination on the primary key), so the whole backfill
213
+ * reads every row once: restarting from the top each time would rescan all
214
+ * rows already done, quadratic in table size, and on a large table a single
215
+ * scan would outlast the pool's statement timeout and fail the migration.
216
+ *
217
+ * @param {object} pool
218
+ * @param {string} table already validated
219
+ * @returns {Promise<number>} rows backfilled
220
+ */
221
+ async function backfillPublicIds(pool, table) {
222
+ let total = 0;
223
+ let lastId = 0;
224
+
225
+ for (;;) {
226
+ const [rows] = await pool.query(
227
+ `SELECT id FROM \`${table}\` WHERE id > ? AND public_id IS NULL ORDER BY id LIMIT ?`,
228
+ [lastId, DATABASE.BACKFILL_BATCH_SIZE]
229
+ );
230
+ if (rows.length === 0) return total;
231
+
232
+ const ids = rows.map((row) => row.id);
233
+ lastId = ids[ids.length - 1];
234
+ const cases = ids.map(() => 'WHEN ? THEN ?').join(' ');
235
+ const params = ids.flatMap((id) => [id, crypto.randomUUID()]);
236
+
237
+ await pool.query(
238
+ `UPDATE \`${table}\` SET public_id = CASE id ${cases} END WHERE id IN (?)`,
239
+ [...params, ids]
240
+ );
241
+ total += ids.length;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Bring one app table up to the admin schema. Idempotent: a table already in
247
+ * shape is read and left alone.
248
+ *
249
+ * @param {object} pool
250
+ * @param {string} appId
251
+ * @returns {Promise<{migrated: boolean, backfilled: number}>}
252
+ * @throws {Error} ErrorType.MIGRATION_FAILED, so startup fails loudly rather
253
+ * than serving an admin UI over a half-migrated table
254
+ */
255
+ async function migrateAppTable(pool, appId) {
256
+ if (!isValidAppId(appId)) {
257
+ throw getError(ErrorType.INVALID_APP_ID, { appId });
258
+ }
259
+
260
+ try {
261
+ const columns = await readColumns(pool, appId);
262
+ if (columns.size === 0) {
263
+ logWarning(WarningType.MIGRATION_TABLE_MISSING, { table: appId });
264
+ return { migrated: false, backfilled: 0 };
265
+ }
266
+
267
+ for (const column of [...ADMIN_COLUMNS, ...TRACKING_COLUMNS]) {
268
+ if (!columns.has(column.name)) {
269
+ await pool.query(`ALTER TABLE \`${appId}\` ADD COLUMN \`${column.name}\` ${column.ddl}`);
270
+ }
271
+ }
272
+
273
+ const backfilled = await backfillPublicIds(pool, appId);
274
+
275
+ const publicId = columns.get('public_id');
276
+ if (!publicId || publicId.nullable) {
277
+ await pool.query(
278
+ `ALTER TABLE \`${appId}\` MODIFY \`public_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL`
279
+ );
280
+ }
281
+
282
+ const indexes = await readIndexes(pool, appId);
283
+ for (const [name, definition] of Object.entries(ADMIN_INDEXES)) {
284
+ if (!indexes.has(name)) {
285
+ await pool.query(`ALTER TABLE \`${appId}\` ADD ${definition}`);
286
+ }
287
+ }
288
+
289
+ return { migrated: true, backfilled };
290
+ } catch (cause) {
291
+ throw getError(ErrorType.MIGRATION_FAILED, { table: appId, cause: cause.message });
292
+ }
293
+ }
294
+
295
+ /**
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.
299
+ * @param {object} pool
300
+ */
301
+ async function ensureLogTables(pool) {
302
+ await pool.query(ADMIN_LOG_DDL);
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
+ }
313
+ }
314
+
315
+ module.exports = {
316
+ ADMIN_COLUMNS,
317
+ TRACKING_COLUMNS,
318
+ ADMIN_INDEXES,
319
+ ADMIN_LOG_DDL,
320
+ VIEW_LOG_DDL,
321
+ VIEW_LOG_ADDED_COLUMNS,
322
+ TRACKING_REJECTIONS_DDL,
323
+ ADMIN_SESSIONS_DDL,
324
+ NEW_TABLE_ADMIN_COLUMNS,
325
+ NEW_TABLE_ADMIN_INDEXES,
326
+ backfillPublicIds,
327
+ ensureLogTables,
328
+ migrateAppTable,
329
+ };