@harshankur/viewcounter 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -11
- package/README.md +330 -138
- 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 +57 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +93 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +484 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +49 -26
- 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 +236 -18
- package/tracker/tracker.js +240 -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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harshankur/viewcounter",
|
|
3
3
|
"description": "A middleware backend server that registers views to my db server when requested to register a view from my other projects.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.3.0",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"index.js",
|
|
11
11
|
"constants.js",
|
|
12
12
|
"admin/",
|
|
13
|
+
"tracker/",
|
|
13
14
|
"config/",
|
|
14
15
|
"db/",
|
|
15
16
|
"middleware/",
|
|
@@ -78,8 +79,10 @@
|
|
|
78
79
|
"express-validator": "^7.3.2",
|
|
79
80
|
"geoip-country": "^5.0.202609230144",
|
|
80
81
|
"helmet": "^8.3.0",
|
|
82
|
+
"isbot": "^5.2.2",
|
|
83
|
+
"maxmind": "^5.0.7",
|
|
81
84
|
"mysql2": "^3.24.4",
|
|
82
|
-
"ua-parser-js": "^
|
|
85
|
+
"ua-parser-js": "^1.0.41",
|
|
83
86
|
"url-parse": "^1.5.10"
|
|
84
87
|
},
|
|
85
88
|
"overrides": {
|
package/routes/admin.js
CHANGED
|
@@ -25,11 +25,14 @@ const {
|
|
|
25
25
|
ADMIN_ERROR_CODE,
|
|
26
26
|
ADMIN_RANGE,
|
|
27
27
|
ADMIN_SORT_COLUMNS,
|
|
28
|
+
ANALYSIS,
|
|
28
29
|
EDITABLE_FIELDS,
|
|
29
30
|
FIELD_MAX_LENGTH,
|
|
30
31
|
HTTP_STATUS,
|
|
31
32
|
MODIFIED_FILTER,
|
|
33
|
+
REJECTION_REASON,
|
|
32
34
|
SORT_ORDER,
|
|
35
|
+
TRACKING_OUTCOME,
|
|
33
36
|
VIEW_LOG_SOURCE,
|
|
34
37
|
VIEW_STATUS,
|
|
35
38
|
} = require('../constants');
|
|
@@ -41,25 +44,35 @@ const {
|
|
|
41
44
|
requireAdminSession,
|
|
42
45
|
requireCsrf,
|
|
43
46
|
verifyPassword,
|
|
47
|
+
requireRecentPassword,
|
|
48
|
+
sessionCsrfToken,
|
|
44
49
|
cookieOptions,
|
|
45
50
|
originAllowed,
|
|
46
51
|
readToken,
|
|
47
52
|
} = require('../middleware/adminAuth');
|
|
48
53
|
const {
|
|
54
|
+
parseWhere,
|
|
49
55
|
validateLogin,
|
|
50
56
|
validateViewListing,
|
|
51
57
|
validateAnalysis,
|
|
58
|
+
validateEventTypes,
|
|
59
|
+
validateRealtime,
|
|
52
60
|
validateEdit,
|
|
53
61
|
validateNote,
|
|
54
62
|
validateBatch,
|
|
55
63
|
validateAdminLogListing,
|
|
56
|
-
|
|
64
|
+
validateTrackingLogListing,
|
|
65
|
+
validateTrackingSummary,
|
|
57
66
|
handleAdminValidation,
|
|
58
67
|
} = require('../middleware/adminValidation');
|
|
59
68
|
const { logContext, withRequestId } = require('./analytics');
|
|
69
|
+
const { attributions } = require('../utils/geoCity');
|
|
70
|
+
const { ACQUISITION_DIMENSIONS } = require('../db/analysis');
|
|
60
71
|
|
|
61
72
|
/** The UI's static files, shipped in the package. */
|
|
62
73
|
const ADMIN_UI_DIR = path.join(__dirname, '..', 'admin');
|
|
74
|
+
/** Shown in the UI's header and footer, so an admin knows what they are running. */
|
|
75
|
+
const PACKAGE_VERSION = require('../package.json').version;
|
|
63
76
|
|
|
64
77
|
/**
|
|
65
78
|
* Content Security Policy for the admin surface. Stricter than helmet's
|
|
@@ -94,6 +107,8 @@ function filterQuery(req) {
|
|
|
94
107
|
range: req.query.range || ADMIN_RANGE.ALL,
|
|
95
108
|
search: req.query.search || '',
|
|
96
109
|
eventType: req.query.eventType || '',
|
|
110
|
+
// Validated already; parsed again here since Express 5 recomputes req.query.
|
|
111
|
+
where: parseWhere(req.query.where),
|
|
97
112
|
};
|
|
98
113
|
}
|
|
99
114
|
|
|
@@ -108,16 +123,19 @@ function adminError(req, res, error, operation) {
|
|
|
108
123
|
|
|
109
124
|
/**
|
|
110
125
|
* @param {{ config: object, adminRepo: object, logRepo: object,
|
|
111
|
-
* sessionStore?: object, isReady?: () => boolean, uiDir?: string
|
|
126
|
+
* sessionStore?: object, isReady?: () => boolean, uiDir?: string,
|
|
127
|
+
* geo?: { city: object|null } }} deps
|
|
128
|
+
* `geo.city` is the optional city database, whose credit the UI shows
|
|
112
129
|
* @returns {import('express').Router}
|
|
113
130
|
*/
|
|
114
131
|
function createAdminRouter({
|
|
115
132
|
config,
|
|
116
133
|
adminRepo,
|
|
117
134
|
logRepo,
|
|
118
|
-
sessionStore = createSessionStore(),
|
|
135
|
+
sessionStore = createSessionStore({ idleMs: config.admin.sessionIdleMs, absoluteMs: config.admin.sessionMaxAgeMs }),
|
|
119
136
|
isReady = () => true,
|
|
120
137
|
uiDir = ADMIN_UI_DIR,
|
|
138
|
+
geo = { city: null },
|
|
121
139
|
}) {
|
|
122
140
|
// The standalone server checks this when it loads its config; an app
|
|
123
141
|
// embedding the router gets the same floor, not a weaker admin surface.
|
|
@@ -152,7 +170,7 @@ function createAdminRouter({
|
|
|
152
170
|
handler: (req, res) => res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({ code: ADMIN_ERROR_CODE.RATE_LIMITED }),
|
|
153
171
|
}));
|
|
154
172
|
|
|
155
|
-
router.use(ADMIN.API_PATH, createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }));
|
|
173
|
+
router.use(ADMIN.API_PATH, createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady, geo }));
|
|
156
174
|
|
|
157
175
|
// Relative asset URLs in the UI only resolve under a trailing slash.
|
|
158
176
|
router.get('/', (req, res, next) => {
|
|
@@ -168,7 +186,7 @@ function createAdminRouter({
|
|
|
168
186
|
/**
|
|
169
187
|
* The JSON API behind the UI.
|
|
170
188
|
*/
|
|
171
|
-
function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
189
|
+
function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady, geo }) {
|
|
172
190
|
const api = express.Router();
|
|
173
191
|
const allowed = config.allowed;
|
|
174
192
|
|
|
@@ -196,10 +214,15 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
196
214
|
|
|
197
215
|
// ---- Session ----------------------------------------------------------
|
|
198
216
|
|
|
199
|
-
api.get('/session', (req, res) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
217
|
+
api.get('/session', async (req, res) => {
|
|
218
|
+
try {
|
|
219
|
+
const token = readToken(req);
|
|
220
|
+
const session = await sessionStore.get(token);
|
|
221
|
+
if (!session) return res.json({ authenticated: false });
|
|
222
|
+
return res.json({ authenticated: true, csrfToken: sessionCsrfToken(token) });
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return adminError(req, res, error, 'read session');
|
|
225
|
+
}
|
|
203
226
|
});
|
|
204
227
|
|
|
205
228
|
const loginLimiter = rateLimit({
|
|
@@ -232,22 +255,38 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
232
255
|
|
|
233
256
|
// A fresh token on every login; any previous session in this
|
|
234
257
|
// browser is ended rather than left valid alongside the new one.
|
|
235
|
-
sessionStore.destroy(readToken(req));
|
|
236
|
-
const { token, session } = sessionStore.create();
|
|
237
|
-
res.cookie(ADMIN.SESSION_COOKIE, token, cookieOptions(req));
|
|
258
|
+
await sessionStore.destroy(readToken(req));
|
|
259
|
+
const { token, session } = await sessionStore.create();
|
|
260
|
+
res.cookie(ADMIN.SESSION_COOKIE, token, cookieOptions(req, sessionStore.absoluteMs));
|
|
238
261
|
|
|
239
262
|
req.adminSession = session;
|
|
240
263
|
await record(req, ADMIN_ACTION.LOGIN_SUCCEEDED);
|
|
241
|
-
return res.json({ authenticated: true, csrfToken:
|
|
264
|
+
return res.json({ authenticated: true, csrfToken: sessionCsrfToken(token) });
|
|
242
265
|
} catch (error) {
|
|
243
266
|
return adminError(req, res, error, 'login');
|
|
244
267
|
}
|
|
245
268
|
});
|
|
246
269
|
|
|
270
|
+
// The password again, for actions that cannot be undone. Rate limited
|
|
271
|
+
// with sign-in, since it is the same guess.
|
|
272
|
+
api.post('/reauth', authed, loginLimiter, validateLogin(), handleAdminValidation, async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
if (!verifyPassword(req.body.password, config.admin.password)) {
|
|
275
|
+
await record(req, ADMIN_ACTION.LOGIN_FAILED);
|
|
276
|
+
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.INVALID_PASSWORD });
|
|
277
|
+
}
|
|
278
|
+
await sessionStore.confirmPassword(req.adminToken);
|
|
279
|
+
await record(req, ADMIN_ACTION.PASSWORD_CONFIRMED);
|
|
280
|
+
return res.status(HTTP_STATUS.NO_CONTENT).end();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return adminError(req, res, error, 'confirm password');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
247
286
|
api.post('/logout', authed, async (req, res) => {
|
|
248
287
|
try {
|
|
249
288
|
await record(req, ADMIN_ACTION.LOGOUT);
|
|
250
|
-
sessionStore.destroy(req.adminToken);
|
|
289
|
+
await sessionStore.destroy(req.adminToken);
|
|
251
290
|
res.clearCookie(ADMIN.SESSION_COOKIE, { ...cookieOptions(req), maxAge: undefined });
|
|
252
291
|
return res.status(HTTP_STATUS.NO_CONTENT).end();
|
|
253
292
|
} catch (error) {
|
|
@@ -259,6 +298,7 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
259
298
|
|
|
260
299
|
api.get('/meta', requireSession, (req, res) => {
|
|
261
300
|
res.json({
|
|
301
|
+
version: PACKAGE_VERSION,
|
|
262
302
|
deviceSizes: allowed.deviceSize,
|
|
263
303
|
editableFields: Object.keys(EDITABLE_FIELDS),
|
|
264
304
|
sortFields: Object.keys(ADMIN_SORT_COLUMNS),
|
|
@@ -267,11 +307,21 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
267
307
|
modifiedFilters: Object.values(MODIFIED_FILTER),
|
|
268
308
|
actions: Object.values(ADMIN_ACTION),
|
|
269
309
|
sources: Object.values(VIEW_LOG_SOURCE),
|
|
310
|
+
outcomes: Object.values(TRACKING_OUTCOME),
|
|
311
|
+
rejectionReasons: Object.values(REJECTION_REASON),
|
|
270
312
|
maxBatchIds: ADMIN.MAX_BATCH_IDS,
|
|
271
313
|
pageSizes: ADMIN.PAGE_SIZES,
|
|
272
314
|
pageSizeDefault: ADMIN.PAGE_SIZE_DEFAULT,
|
|
273
315
|
searchMaxLength: ADMIN.SEARCH_MAX_LENGTH,
|
|
274
316
|
trashRetentionDays: config.admin.trashRetentionDays,
|
|
317
|
+
reauthWindowMs: ADMIN.REAUTH_WINDOW_MS,
|
|
318
|
+
// "Right now" counts visitors seen within this many minutes.
|
|
319
|
+
realtimeVisitorMinutes: ANALYSIS.REALTIME_VISITOR_MINUTES,
|
|
320
|
+
// Breakdowns of how visits arrived, which count page views only.
|
|
321
|
+
acquisitionDimensions: [...ACQUISITION_DIMENSIONS],
|
|
322
|
+
// Credits the location data's licences ask for, shown in the UI.
|
|
323
|
+
attributions: attributions(geo.city),
|
|
324
|
+
hasCityData: Boolean(geo.city),
|
|
275
325
|
viewLogRetentionDays: config.admin.viewLogRetentionDays,
|
|
276
326
|
maxLength: {
|
|
277
327
|
note: FIELD_MAX_LENGTH.NOTE,
|
|
@@ -347,6 +397,46 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
347
397
|
}
|
|
348
398
|
});
|
|
349
399
|
|
|
400
|
+
// Every event type the apps hold in a status, whatever other filter is
|
|
401
|
+
// set, for the event-type filter above a listing.
|
|
402
|
+
api.get('/apps/:appId/event-types', requireSession, validateEventTypes(allowed), handleAdminValidation,
|
|
403
|
+
async (req, res) => {
|
|
404
|
+
try {
|
|
405
|
+
const status = req.query.status || VIEW_STATUS.ACTIVE;
|
|
406
|
+
return res.json({ eventTypes: await adminRepo.eventTypes([req.params.appId], status) });
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return adminError(req, res, error, 'list event types');
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
api.get('/event-types', requireSession, validateEventTypes(), handleAdminValidation, async (req, res) => {
|
|
413
|
+
try {
|
|
414
|
+
const apps = await adminRepo.existingTables(allowed.appId);
|
|
415
|
+
const status = req.query.status || VIEW_STATUS.ACTIVE;
|
|
416
|
+
return res.json({ eventTypes: apps.length ? await adminRepo.eventTypes(apps, status) : [] });
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return adminError(req, res, error, 'list all event types');
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Right now: visitors in the last few minutes and views per minute.
|
|
423
|
+
api.get('/apps/:appId/realtime', requireSession, validateRealtime(allowed), handleAdminValidation, async (req, res) => {
|
|
424
|
+
try {
|
|
425
|
+
return res.json({ apps: [req.params.appId], ...await adminRepo.realtime([req.params.appId]) });
|
|
426
|
+
} catch (error) {
|
|
427
|
+
return adminError(req, res, error, 'read realtime');
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
api.get('/realtime', requireSession, async (req, res) => {
|
|
432
|
+
try {
|
|
433
|
+
const apps = await adminRepo.existingTables(allowed.appId);
|
|
434
|
+
return res.json({ apps, ...await adminRepo.realtime(apps) });
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return adminError(req, res, error, 'read realtime');
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
350
440
|
api.patch('/apps/:appId/views', authed, validateEdit(allowed), handleAdminValidation, async (req, res) => {
|
|
351
441
|
try {
|
|
352
442
|
const { appId } = req.params;
|
|
@@ -373,8 +463,8 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
373
463
|
});
|
|
374
464
|
|
|
375
465
|
/** delete / restore / purge share one shape: IDs in, changed IDs out. */
|
|
376
|
-
const batchRoute = (suffix, operation, action) => {
|
|
377
|
-
api.post(`/apps/:appId/views/${suffix}`, authed, validateBatch(allowed), handleAdminValidation,
|
|
466
|
+
const batchRoute = (suffix, operation, action, guards = []) => {
|
|
467
|
+
api.post(`/apps/:appId/views/${suffix}`, authed, ...guards, validateBatch(allowed), handleAdminValidation,
|
|
378
468
|
async (req, res) => {
|
|
379
469
|
try {
|
|
380
470
|
const { appId } = req.params;
|
|
@@ -389,7 +479,9 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
389
479
|
|
|
390
480
|
batchRoute('delete', 'softDelete', ADMIN_ACTION.VIEWS_DELETED);
|
|
391
481
|
batchRoute('restore', 'restore', ADMIN_ACTION.VIEWS_RESTORED);
|
|
392
|
-
|
|
482
|
+
// Erasing for good cannot be undone, so it wants the password from the
|
|
483
|
+
// last few minutes, not just a session that may be weeks old.
|
|
484
|
+
batchRoute('purge', 'purge', ADMIN_ACTION.VIEWS_PURGED, [requireRecentPassword()]);
|
|
393
485
|
|
|
394
486
|
// ---- Logs -------------------------------------------------------------
|
|
395
487
|
|
|
@@ -408,18 +500,29 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
408
500
|
}
|
|
409
501
|
});
|
|
410
502
|
|
|
411
|
-
|
|
503
|
+
// The tracking log: every tracking request and what became of it.
|
|
504
|
+
api.get('/logs/tracking', requireSession, validateTrackingLogListing(allowed), handleAdminValidation,
|
|
412
505
|
async (req, res) => {
|
|
413
506
|
try {
|
|
414
|
-
const result = await logRepo.
|
|
507
|
+
const result = await logRepo.listTrackingLog({
|
|
415
508
|
page: intParam(req, 'page', 1),
|
|
416
509
|
pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
|
|
417
510
|
appId: req.query.appId,
|
|
418
511
|
source: req.query.source,
|
|
512
|
+
outcome: req.query.outcome,
|
|
419
513
|
});
|
|
420
514
|
return res.json(result);
|
|
421
515
|
} catch (error) {
|
|
422
|
-
return adminError(req, res, error, 'list
|
|
516
|
+
return adminError(req, res, error, 'list tracking log');
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
api.get('/logs/tracking/summary', requireSession, validateTrackingSummary(allowed), handleAdminValidation,
|
|
521
|
+
async (req, res) => {
|
|
522
|
+
try {
|
|
523
|
+
return res.json(await logRepo.trackingSummary({ hours: ADMIN.TRACKING_SUMMARY_HOURS, appId: req.query.appId }));
|
|
524
|
+
} catch (error) {
|
|
525
|
+
return adminError(req, res, error, 'summarize tracking log');
|
|
423
526
|
}
|
|
424
527
|
});
|
|
425
528
|
|
|
@@ -428,8 +531,13 @@ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
|
|
|
428
531
|
// Malformed or oversized JSON bodies, in the admin error shape.
|
|
429
532
|
// eslint-disable-next-line no-unused-vars
|
|
430
533
|
api.use((err, req, res, next) => {
|
|
431
|
-
|
|
432
|
-
|
|
534
|
+
const status = err.status || err.statusCode;
|
|
535
|
+
if (status >= 400 && status < 500) {
|
|
536
|
+
logger.warn(`admin request rejected: ${err.message}`, logContext(req));
|
|
537
|
+
return res.status(HTTP_STATUS.BAD_REQUEST).json({ code: ADMIN_ERROR_CODE.VALIDATION_FAILED });
|
|
538
|
+
}
|
|
539
|
+
// Anything else is ours (a session store that cannot be read, say).
|
|
540
|
+
return adminError(req, res, err, 'handle request');
|
|
433
541
|
});
|
|
434
542
|
|
|
435
543
|
return api;
|