@harshankur/viewcounter 3.0.0 → 3.1.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 +20 -0
- package/README.md +189 -14
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +1875 -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 +89 -0
- package/admin/js/api.js +100 -0
- package/admin/js/charts.js +502 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +150 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +79 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/insights.js +192 -0
- package/admin/js/listbox.js +144 -0
- package/admin/js/logs.js +167 -0
- package/admin/js/main.js +235 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/table.js +134 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +208 -0
- package/admin/js/views.js +685 -0
- package/admin/locales/en.json +394 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +83 -0
- package/constants.js +215 -2
- package/db/AdminRepository.js +562 -0
- package/db/DatabaseManager.js +94 -20
- package/db/LogRepository.js +217 -0
- package/db/adminSchema.js +244 -0
- package/db/retention.js +97 -0
- package/index.js +39 -6
- package/middleware/adminAuth.js +204 -0
- package/middleware/adminValidation.js +253 -0
- package/package.json +17 -10
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- package/utils/errorUtils.js +35 -0
package/db/DatabaseManager.js
CHANGED
|
@@ -10,12 +10,28 @@ const {
|
|
|
10
10
|
SERVER,
|
|
11
11
|
TOP_N_RESULTS,
|
|
12
12
|
TREND_PERIOD,
|
|
13
|
+
VIEW_LOG_SOURCE,
|
|
13
14
|
} = require('../constants');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every analytics read and the duplicate check see only live rows. A view an
|
|
18
|
+
* admin has moved to the trash no longer counts anywhere, and one that is
|
|
19
|
+
* restored counts again, without any stored aggregate needing to be rebuilt.
|
|
20
|
+
*/
|
|
21
|
+
const LIVE_ROW = 'deleted_at IS NULL';
|
|
14
22
|
const PrivacyUtils = require('../utils/privacyUtils');
|
|
15
23
|
const logger = require('../utils/logger');
|
|
16
24
|
const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
|
|
17
25
|
const { truncate } = require('../utils/stringUtils');
|
|
18
26
|
const { isValidAppId } = require('../utils/appIdUtils');
|
|
27
|
+
const {
|
|
28
|
+
NEW_TABLE_ADMIN_COLUMNS,
|
|
29
|
+
NEW_TABLE_ADMIN_INDEXES,
|
|
30
|
+
ensureLogTables,
|
|
31
|
+
migrateAppTable,
|
|
32
|
+
} = require('./adminSchema');
|
|
33
|
+
const LogRepository = require('./LogRepository');
|
|
34
|
+
const AdminRepository = require('./AdminRepository');
|
|
19
35
|
|
|
20
36
|
/**
|
|
21
37
|
* Columns returned for a session lookup.
|
|
@@ -99,6 +115,7 @@ function appTableDDL(appId) {
|
|
|
99
115
|
\`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT '${EVENT_TYPE.PAGEVIEW}',
|
|
100
116
|
\`event_data\` JSON DEFAULT NULL,
|
|
101
117
|
\`is_unique\` TINYINT(1) DEFAULT 1,
|
|
118
|
+
${NEW_TABLE_ADMIN_COLUMNS.join(',\n ')},
|
|
102
119
|
INDEX \`idx_timestamp\` (\`timestamp\`),
|
|
103
120
|
INDEX \`idx_visitor_timestamp\` (\`visitor_hash\`, \`timestamp\`),
|
|
104
121
|
INDEX \`idx_masked_ip\` (\`masked_ip\`),
|
|
@@ -112,7 +129,8 @@ function appTableDDL(appId) {
|
|
|
112
129
|
INDEX \`idx_device_type\` (\`device_type\`),
|
|
113
130
|
INDEX \`idx_session_id\` (\`session_id\`),
|
|
114
131
|
INDEX \`idx_event_type\` (\`event_type\`),
|
|
115
|
-
INDEX \`idx_is_unique\` (\`is_unique\`)
|
|
132
|
+
INDEX \`idx_is_unique\` (\`is_unique\`),
|
|
133
|
+
${NEW_TABLE_ADMIN_INDEXES.join(',\n ')}
|
|
116
134
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
117
135
|
`;
|
|
118
136
|
}
|
|
@@ -161,6 +179,29 @@ class DatabaseManager {
|
|
|
161
179
|
this.config = config;
|
|
162
180
|
this.pool = null;
|
|
163
181
|
this.mode = config.mode || 'connect';
|
|
182
|
+
this.logs = new LogRepository(this);
|
|
183
|
+
this.admin = new AdminRepository(this);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Bring the service's own tables and every app table up to the current
|
|
188
|
+
* schema. Idempotent, and run on every start, so an upgrade needs no manual
|
|
189
|
+
* migration step. Runs in `connect` mode too: the admin columns and log
|
|
190
|
+
* tables are the service's bookkeeping, like the app registry.
|
|
191
|
+
*
|
|
192
|
+
* @param {string[]} appIds
|
|
193
|
+
* @throws {Error} ErrorType.MIGRATION_FAILED
|
|
194
|
+
*/
|
|
195
|
+
async migrate(appIds = []) {
|
|
196
|
+
this.assertReady();
|
|
197
|
+
await ensureLogTables(this.pool);
|
|
198
|
+
|
|
199
|
+
for (const appId of appIds) {
|
|
200
|
+
const result = await migrateAppTable(this.pool, appId);
|
|
201
|
+
if (result.backfilled > 0) {
|
|
202
|
+
logger.info(`Assigned public IDs to ${result.backfilled} existing row(s) in '${appId}'`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
164
205
|
}
|
|
165
206
|
|
|
166
207
|
/** @throws {Error} when a query is attempted before initialize() */
|
|
@@ -171,7 +212,14 @@ class DatabaseManager {
|
|
|
171
212
|
}
|
|
172
213
|
|
|
173
214
|
/**
|
|
174
|
-
*
|
|
215
|
+
* Connect, create the schema in create mode, and bring the given apps'
|
|
216
|
+
* tables up to the current schema in either mode.
|
|
217
|
+
*
|
|
218
|
+
* The migration belongs here, not only at server startup: every query this
|
|
219
|
+
* manager runs assumes the current schema (public_id, deleted_at, the log
|
|
220
|
+
* tables), so an application that mounts the routers and calls only
|
|
221
|
+
* initialize() must still get tables those queries can run against.
|
|
222
|
+
* Idempotent: a table already in shape is read and left alone.
|
|
175
223
|
*/
|
|
176
224
|
async initialize(allowedAppIds = []) {
|
|
177
225
|
try {
|
|
@@ -206,8 +254,6 @@ class DatabaseManager {
|
|
|
206
254
|
|
|
207
255
|
await this.pool.query('SELECT 1');
|
|
208
256
|
logger.info(`Database connected (mode: ${this.mode})`);
|
|
209
|
-
|
|
210
|
-
return true;
|
|
211
257
|
} catch (cause) {
|
|
212
258
|
throw getError(ErrorType.DATABASE_CONNECTION_FAILED, {
|
|
213
259
|
host: this.config.host,
|
|
@@ -216,6 +262,11 @@ class DatabaseManager {
|
|
|
216
262
|
cause: cause.message,
|
|
217
263
|
});
|
|
218
264
|
}
|
|
265
|
+
|
|
266
|
+
// Outside the try: a failed migration is reported as MIGRATION_FAILED
|
|
267
|
+
// naming the table, not as a connection failure.
|
|
268
|
+
await this.migrate(allowedAppIds);
|
|
269
|
+
return true;
|
|
219
270
|
}
|
|
220
271
|
|
|
221
272
|
/**
|
|
@@ -320,8 +371,10 @@ class DatabaseManager {
|
|
|
320
371
|
);
|
|
321
372
|
|
|
322
373
|
// The table is (re)created regardless, so an app registered before its
|
|
323
|
-
// table existed still converges to a working state.
|
|
374
|
+
// table existed still converges to a working state. A table that
|
|
375
|
+
// already existed in an older shape is migrated in place.
|
|
324
376
|
await this.pool.query(appTableDDL(appId));
|
|
377
|
+
await migrateAppTable(this.pool, appId);
|
|
325
378
|
|
|
326
379
|
if (existing.length > 0) {
|
|
327
380
|
logWarning(WarningType.APP_ALREADY_REGISTERED, { appId });
|
|
@@ -389,6 +442,7 @@ class DatabaseManager {
|
|
|
389
442
|
uniqueWindowHours = SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS,
|
|
390
443
|
userAgent = '',
|
|
391
444
|
visitorSecret,
|
|
445
|
+
source = VIEW_LOG_SOURCE.REGISTER_VIEW,
|
|
392
446
|
} = data;
|
|
393
447
|
|
|
394
448
|
// Privacy boundary. Neither the raw IP nor the raw User-Agent is bound
|
|
@@ -407,6 +461,7 @@ class DatabaseManager {
|
|
|
407
461
|
const [existing] = await this.pool.query(
|
|
408
462
|
`SELECT id FROM \`${appId}\`
|
|
409
463
|
WHERE visitor_hash = ? AND event_type = ? AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)
|
|
464
|
+
AND ${LIVE_ROW}
|
|
410
465
|
LIMIT 1`,
|
|
411
466
|
[hashedVisitor, EVENT_TYPE.PAGEVIEW, uniqueWindowHours]
|
|
412
467
|
);
|
|
@@ -416,15 +471,19 @@ class DatabaseManager {
|
|
|
416
471
|
}
|
|
417
472
|
}
|
|
418
473
|
|
|
474
|
+
const publicId = crypto.randomUUID();
|
|
475
|
+
const storedEventType = truncate(eventType, FIELD_MAX_LENGTH.EVENT_TYPE);
|
|
476
|
+
|
|
419
477
|
const [result] = await this.pool.query(
|
|
420
478
|
`INSERT INTO \`${appId}\` (
|
|
421
|
-
masked_ip, visitor_hash, country, timestamp, devicesize,
|
|
479
|
+
public_id, masked_ip, visitor_hash, country, timestamp, devicesize,
|
|
422
480
|
page_path, page_title,
|
|
423
481
|
referrer, referrer_domain, source_type,
|
|
424
482
|
browser, browser_version, os, os_version, device_type,
|
|
425
483
|
session_id, event_type, event_data, is_unique
|
|
426
|
-
) VALUES (?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
484
|
+
) VALUES (?, ?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
427
485
|
[
|
|
486
|
+
publicId,
|
|
428
487
|
truncate(maskedIp, FIELD_MAX_LENGTH.MASKED_IP),
|
|
429
488
|
hashedVisitor,
|
|
430
489
|
truncate(country, FIELD_MAX_LENGTH.COUNTRY),
|
|
@@ -440,15 +499,26 @@ class DatabaseManager {
|
|
|
440
499
|
truncate(osVersion, FIELD_MAX_LENGTH.OS_VERSION),
|
|
441
500
|
truncate(deviceType, FIELD_MAX_LENGTH.DEVICE_TYPE),
|
|
442
501
|
truncate(sessionId, FIELD_MAX_LENGTH.SESSION_ID),
|
|
443
|
-
|
|
502
|
+
storedEventType,
|
|
444
503
|
eventData ? JSON.stringify(eventData) : null,
|
|
445
504
|
isUnique,
|
|
446
505
|
]
|
|
447
506
|
);
|
|
448
507
|
|
|
508
|
+
// The view register log. Written after the row, and never able to fail
|
|
509
|
+
// the request: the view is already stored.
|
|
510
|
+
await this.logs.writeViewLog({
|
|
511
|
+
appId,
|
|
512
|
+
source,
|
|
513
|
+
viewId: publicId,
|
|
514
|
+
eventType: storedEventType,
|
|
515
|
+
isUnique: isUnique === 1,
|
|
516
|
+
});
|
|
517
|
+
|
|
449
518
|
return {
|
|
450
519
|
duplicate: isUnique === 0,
|
|
451
520
|
insertId: result.insertId,
|
|
521
|
+
publicId,
|
|
452
522
|
isUnique: isUnique === 1,
|
|
453
523
|
};
|
|
454
524
|
}
|
|
@@ -477,14 +547,15 @@ class DatabaseManager {
|
|
|
477
547
|
COUNT(*) as total_views,
|
|
478
548
|
SUM(CASE WHEN is_unique = 1 THEN 1 ELSE 0 END) as unique_views,
|
|
479
549
|
COUNT(DISTINCT visitor_hash) as unique_visitors
|
|
480
|
-
FROM \`${appId}
|
|
550
|
+
FROM \`${appId}\`
|
|
551
|
+
WHERE ${LIVE_ROW}`
|
|
481
552
|
);
|
|
482
553
|
|
|
483
554
|
const stats = totalStats[0];
|
|
484
555
|
|
|
485
556
|
const [byCountry] = await this.pool.query(
|
|
486
557
|
`SELECT country, COUNT(*) as count FROM \`${appId}\`
|
|
487
|
-
WHERE country IS NOT NULL
|
|
558
|
+
WHERE country IS NOT NULL AND ${LIVE_ROW}
|
|
488
559
|
GROUP BY country
|
|
489
560
|
ORDER BY count DESC
|
|
490
561
|
LIMIT ?`,
|
|
@@ -493,13 +564,14 @@ class DatabaseManager {
|
|
|
493
564
|
|
|
494
565
|
const [byDevice] = await this.pool.query(
|
|
495
566
|
`SELECT devicesize, COUNT(*) as count FROM \`${appId}\`
|
|
567
|
+
WHERE ${LIVE_ROW}
|
|
496
568
|
GROUP BY devicesize
|
|
497
569
|
ORDER BY count DESC`
|
|
498
570
|
);
|
|
499
571
|
|
|
500
572
|
const [recent] = await this.pool.query(
|
|
501
573
|
`SELECT COUNT(*) as count FROM \`${appId}\`
|
|
502
|
-
WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
|
|
574
|
+
WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR) AND ${LIVE_ROW}`,
|
|
503
575
|
[SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS]
|
|
504
576
|
);
|
|
505
577
|
|
|
@@ -522,13 +594,14 @@ class DatabaseManager {
|
|
|
522
594
|
const [views] = await this.pool.query(
|
|
523
595
|
`SELECT masked_ip, country, timestamp, devicesize
|
|
524
596
|
FROM \`${appId}\`
|
|
597
|
+
WHERE ${LIVE_ROW}
|
|
525
598
|
ORDER BY timestamp DESC
|
|
526
599
|
LIMIT ? OFFSET ?`,
|
|
527
600
|
[limit, offset]
|
|
528
601
|
);
|
|
529
602
|
|
|
530
603
|
const [total] = await this.pool.query(
|
|
531
|
-
`SELECT COUNT(*) as count FROM \`${appId}
|
|
604
|
+
`SELECT COUNT(*) as count FROM \`${appId}\` WHERE ${LIVE_ROW}`
|
|
532
605
|
);
|
|
533
606
|
|
|
534
607
|
return {
|
|
@@ -560,7 +633,7 @@ class DatabaseManager {
|
|
|
560
633
|
const [trends] = await this.pool.query(
|
|
561
634
|
`SELECT ${groupBy} as period, COUNT(*) as count
|
|
562
635
|
FROM \`${appId}\`
|
|
563
|
-
WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
636
|
+
WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? DAY) AND ${LIVE_ROW}
|
|
564
637
|
GROUP BY period
|
|
565
638
|
ORDER BY period ASC`,
|
|
566
639
|
[days]
|
|
@@ -578,7 +651,7 @@ class DatabaseManager {
|
|
|
578
651
|
const [bySource] = await this.pool.query(
|
|
579
652
|
`SELECT source_type, COUNT(*) as count
|
|
580
653
|
FROM \`${appId}\`
|
|
581
|
-
WHERE source_type IS NOT NULL
|
|
654
|
+
WHERE source_type IS NOT NULL AND ${LIVE_ROW}
|
|
582
655
|
GROUP BY source_type
|
|
583
656
|
ORDER BY count DESC`
|
|
584
657
|
);
|
|
@@ -586,7 +659,7 @@ class DatabaseManager {
|
|
|
586
659
|
const [byDomain] = await this.pool.query(
|
|
587
660
|
`SELECT referrer_domain, COUNT(*) as count
|
|
588
661
|
FROM \`${appId}\`
|
|
589
|
-
WHERE referrer_domain IS NOT NULL
|
|
662
|
+
WHERE referrer_domain IS NOT NULL AND ${LIVE_ROW}
|
|
590
663
|
GROUP BY referrer_domain
|
|
591
664
|
ORDER BY count DESC
|
|
592
665
|
LIMIT ?`,
|
|
@@ -605,7 +678,7 @@ class DatabaseManager {
|
|
|
605
678
|
const [byBrowser] = await this.pool.query(
|
|
606
679
|
`SELECT browser, COUNT(*) as count
|
|
607
680
|
FROM \`${appId}\`
|
|
608
|
-
WHERE browser IS NOT NULL
|
|
681
|
+
WHERE browser IS NOT NULL AND ${LIVE_ROW}
|
|
609
682
|
GROUP BY browser
|
|
610
683
|
ORDER BY count DESC
|
|
611
684
|
LIMIT ?`,
|
|
@@ -615,7 +688,7 @@ class DatabaseManager {
|
|
|
615
688
|
const [byOS] = await this.pool.query(
|
|
616
689
|
`SELECT os, COUNT(*) as count
|
|
617
690
|
FROM \`${appId}\`
|
|
618
|
-
WHERE os IS NOT NULL
|
|
691
|
+
WHERE os IS NOT NULL AND ${LIVE_ROW}
|
|
619
692
|
GROUP BY os
|
|
620
693
|
ORDER BY count DESC
|
|
621
694
|
LIMIT ?`,
|
|
@@ -625,7 +698,7 @@ class DatabaseManager {
|
|
|
625
698
|
const [byDeviceType] = await this.pool.query(
|
|
626
699
|
`SELECT device_type, COUNT(*) as count
|
|
627
700
|
FROM \`${appId}\`
|
|
628
|
-
WHERE device_type IS NOT NULL
|
|
701
|
+
WHERE device_type IS NOT NULL AND ${LIVE_ROW}
|
|
629
702
|
GROUP BY device_type
|
|
630
703
|
ORDER BY count DESC`
|
|
631
704
|
);
|
|
@@ -642,7 +715,7 @@ class DatabaseManager {
|
|
|
642
715
|
const [pages] = await this.pool.query(
|
|
643
716
|
`SELECT page_path, page_title, COUNT(*) as views
|
|
644
717
|
FROM \`${appId}\`
|
|
645
|
-
WHERE page_path IS NOT NULL
|
|
718
|
+
WHERE page_path IS NOT NULL AND ${LIVE_ROW}
|
|
646
719
|
GROUP BY page_path, page_title
|
|
647
720
|
ORDER BY views DESC
|
|
648
721
|
LIMIT ?`,
|
|
@@ -662,7 +735,7 @@ class DatabaseManager {
|
|
|
662
735
|
const [events] = await this.pool.query(
|
|
663
736
|
`SELECT ${SESSION_COLUMNS}
|
|
664
737
|
FROM \`${appId}\`
|
|
665
|
-
WHERE session_id = ?
|
|
738
|
+
WHERE session_id = ? AND ${LIVE_ROW}
|
|
666
739
|
ORDER BY timestamp ASC`,
|
|
667
740
|
[sessionId]
|
|
668
741
|
);
|
|
@@ -702,3 +775,4 @@ module.exports = DatabaseManager;
|
|
|
702
775
|
module.exports.SESSION_COLUMNS = SESSION_COLUMNS;
|
|
703
776
|
module.exports.appTableDDL = appTableDDL;
|
|
704
777
|
module.exports.APP_REGISTRY_DDL = APP_REGISTRY_DDL;
|
|
778
|
+
module.exports.LIVE_ROW = LIVE_ROW;
|
|
@@ -0,0 +1,217 @@
|
|
|
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 { ADMIN, ADMIN_LOG_TABLE, VIEW_LOG_TABLE } = require('../constants');
|
|
11
|
+
const { logWarning, WarningType } = require('../utils/errorUtils');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Columns returned by an admin-log listing. `session_id` identifies which
|
|
15
|
+
* admin session acted without revealing its token.
|
|
16
|
+
*/
|
|
17
|
+
const ADMIN_LOG_COLUMNS = [
|
|
18
|
+
'id', 'created_at', 'action', 'session_id', 'masked_ip',
|
|
19
|
+
'app_id', 'target_count', 'target_ids', 'fields',
|
|
20
|
+
].join(', ');
|
|
21
|
+
|
|
22
|
+
const VIEW_LOG_COLUMNS = [
|
|
23
|
+
'id', 'created_at', 'app_id', 'source', 'view_id', 'event_type', 'is_unique',
|
|
24
|
+
].join(', ');
|
|
25
|
+
|
|
26
|
+
/** mysql2 returns JSON columns parsed; tolerate a string from other drivers. */
|
|
27
|
+
function parseJson(value) {
|
|
28
|
+
if (value === null || value === undefined) return null;
|
|
29
|
+
if (typeof value !== 'string') return value;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(value);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class LogRepository {
|
|
38
|
+
/**
|
|
39
|
+
* @param {{ pool: object, assertReady: () => void }} db a DatabaseManager
|
|
40
|
+
*/
|
|
41
|
+
constructor(db) {
|
|
42
|
+
this.db = db;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get pool() {
|
|
46
|
+
this.db.assertReady();
|
|
47
|
+
return this.db.pool;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record an admin operation. Never throws: an operation that succeeded must
|
|
52
|
+
* not be reported as failed because its log line could not be written
|
|
53
|
+
* (LOGGING.md §4). The failure is surfaced as a warning instead.
|
|
54
|
+
*
|
|
55
|
+
* `targetCount` defaults to the number of IDs; the automatic trash purge
|
|
56
|
+
* passes a count with no IDs, since it erases by age rather than by ID.
|
|
57
|
+
*
|
|
58
|
+
* @param {{ action: string, sessionId?: string|null, maskedIp?: string|null,
|
|
59
|
+
* appId?: string|null, targetIds?: string[], targetCount?: number,
|
|
60
|
+
* fields?: string[] }} entry
|
|
61
|
+
* @returns {Promise<boolean>} whether the entry was written
|
|
62
|
+
*/
|
|
63
|
+
async writeAdminLog({
|
|
64
|
+
action,
|
|
65
|
+
sessionId = null,
|
|
66
|
+
maskedIp = null,
|
|
67
|
+
appId = null,
|
|
68
|
+
targetIds = [],
|
|
69
|
+
targetCount = targetIds.length,
|
|
70
|
+
fields = [],
|
|
71
|
+
}) {
|
|
72
|
+
try {
|
|
73
|
+
await this.pool.query(
|
|
74
|
+
`INSERT INTO \`${ADMIN_LOG_TABLE}\`
|
|
75
|
+
(id, created_at, action, session_id, masked_ip, app_id, target_count, target_ids, fields)
|
|
76
|
+
VALUES (?, NOW(3), ?, ?, ?, ?, ?, ?, ?)`,
|
|
77
|
+
[
|
|
78
|
+
crypto.randomUUID(),
|
|
79
|
+
action,
|
|
80
|
+
sessionId,
|
|
81
|
+
maskedIp,
|
|
82
|
+
appId,
|
|
83
|
+
targetCount,
|
|
84
|
+
targetIds.length ? JSON.stringify(targetIds) : null,
|
|
85
|
+
fields.length ? JSON.stringify(fields) : null,
|
|
86
|
+
]
|
|
87
|
+
);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
logWarning(WarningType.ADMIN_LOG_WRITE_FAILED, { action, cause: cause.message });
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Record one accepted view or event. Never throws, for the same reason as
|
|
97
|
+
* writeAdminLog: the view itself was stored, so the visitor's request
|
|
98
|
+
* succeeded.
|
|
99
|
+
*
|
|
100
|
+
* @param {{ appId: string, source: string, viewId: string, eventType: string,
|
|
101
|
+
* isUnique: boolean }} entry
|
|
102
|
+
* @returns {Promise<boolean>}
|
|
103
|
+
*/
|
|
104
|
+
async writeViewLog({ appId, source, viewId, eventType, isUnique }) {
|
|
105
|
+
try {
|
|
106
|
+
await this.pool.query(
|
|
107
|
+
`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]
|
|
111
|
+
);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
logWarning(WarningType.VIEW_LOG_WRITE_FAILED, { appId, cause: cause.message });
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Remove view-log entries older than `days`, a batch at a time so no single
|
|
121
|
+
* statement holds its locks for long. Ordered by the indexed `created_at`,
|
|
122
|
+
* so each batch reads only what it deletes.
|
|
123
|
+
*
|
|
124
|
+
* @param {number} days
|
|
125
|
+
* @returns {Promise<number>} entries removed
|
|
126
|
+
*/
|
|
127
|
+
async pruneViewLog(days) {
|
|
128
|
+
let total = 0;
|
|
129
|
+
for (;;) {
|
|
130
|
+
const [result] = await this.pool.query(
|
|
131
|
+
`DELETE FROM \`${VIEW_LOG_TABLE}\` WHERE created_at < DATE_SUB(NOW(3), INTERVAL ? DAY)
|
|
132
|
+
ORDER BY created_at LIMIT ?`,
|
|
133
|
+
[days, ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE]
|
|
134
|
+
);
|
|
135
|
+
const removed = Number(result?.affectedRows || 0);
|
|
136
|
+
total += removed;
|
|
137
|
+
if (removed < ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE) return total;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {{ page: number, pageSize: number, action?: string, appId?: string }} query
|
|
143
|
+
* @returns {Promise<{ entries: object[], total: number }>}
|
|
144
|
+
*/
|
|
145
|
+
async listAdminLog({ page, pageSize, action, appId }) {
|
|
146
|
+
const where = [];
|
|
147
|
+
const params = [];
|
|
148
|
+
if (action) { where.push('action = ?'); params.push(action); }
|
|
149
|
+
if (appId) { where.push('app_id = ?'); params.push(appId); }
|
|
150
|
+
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
151
|
+
|
|
152
|
+
const [rows] = await this.pool.query(
|
|
153
|
+
`SELECT ${ADMIN_LOG_COLUMNS} FROM \`${ADMIN_LOG_TABLE}\` ${clause}
|
|
154
|
+
ORDER BY created_at DESC, id ASC LIMIT ? OFFSET ?`,
|
|
155
|
+
[...params, pageSize, (page - 1) * pageSize]
|
|
156
|
+
);
|
|
157
|
+
const [count] = await this.pool.query(
|
|
158
|
+
`SELECT COUNT(*) AS count FROM \`${ADMIN_LOG_TABLE}\` ${clause}`,
|
|
159
|
+
params
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
entries: rows.map((row) => ({
|
|
164
|
+
id: row.id,
|
|
165
|
+
createdAt: row.created_at,
|
|
166
|
+
action: row.action,
|
|
167
|
+
sessionId: row.session_id,
|
|
168
|
+
maskedIp: row.masked_ip,
|
|
169
|
+
appId: row.app_id,
|
|
170
|
+
targetCount: row.target_count,
|
|
171
|
+
targetIds: parseJson(row.target_ids) || [],
|
|
172
|
+
fields: parseJson(row.fields) || [],
|
|
173
|
+
})),
|
|
174
|
+
total: Number(count[0]?.count || 0),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {{ page: number, pageSize: number, appId?: string, source?: string }} query
|
|
180
|
+
* @returns {Promise<{ entries: object[], total: number }>}
|
|
181
|
+
*/
|
|
182
|
+
async listViewLog({ page, pageSize, appId, source }) {
|
|
183
|
+
const where = [];
|
|
184
|
+
const params = [];
|
|
185
|
+
if (appId) { where.push('app_id = ?'); params.push(appId); }
|
|
186
|
+
if (source) { where.push('source = ?'); params.push(source); }
|
|
187
|
+
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
188
|
+
|
|
189
|
+
const [rows] = await this.pool.query(
|
|
190
|
+
`SELECT ${VIEW_LOG_COLUMNS} FROM \`${VIEW_LOG_TABLE}\` ${clause}
|
|
191
|
+
ORDER BY created_at DESC, id ASC LIMIT ? OFFSET ?`,
|
|
192
|
+
[...params, pageSize, (page - 1) * pageSize]
|
|
193
|
+
);
|
|
194
|
+
const [count] = await this.pool.query(
|
|
195
|
+
`SELECT COUNT(*) AS count FROM \`${VIEW_LOG_TABLE}\` ${clause}`,
|
|
196
|
+
params
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
entries: rows.map((row) => ({
|
|
201
|
+
id: row.id,
|
|
202
|
+
createdAt: row.created_at,
|
|
203
|
+
appId: row.app_id,
|
|
204
|
+
source: row.source,
|
|
205
|
+
viewId: row.view_id,
|
|
206
|
+
eventType: row.event_type,
|
|
207
|
+
isUnique: row.is_unique === 1 || row.is_unique === true,
|
|
208
|
+
})),
|
|
209
|
+
total: Number(count[0]?.count || 0),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = LogRepository;
|
|
215
|
+
module.exports.ADMIN_LOG_COLUMNS = ADMIN_LOG_COLUMNS;
|
|
216
|
+
module.exports.VIEW_LOG_COLUMNS = VIEW_LOG_COLUMNS;
|
|
217
|
+
module.exports.parseJson = parseJson;
|