@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.
- package/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -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
|
@@ -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
|
+
};
|