@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts tracking requests that were not stored (bots and rejections) and
|
|
3
|
+
* writes the counts to the tracking log in batches.
|
|
4
|
+
*
|
|
5
|
+
* A rejected request must cost less than an accepted one, or the tracking log
|
|
6
|
+
* would turn every flood the rate limiter turns away into database writes. So
|
|
7
|
+
* nothing is written per request: counts accumulate in memory, keyed by
|
|
8
|
+
* minute, endpoint, reason, app, detail, and hostname, and one upsert every
|
|
9
|
+
* TRACKING.REJECTION_FLUSH_MS adds them to what is stored.
|
|
10
|
+
*
|
|
11
|
+
* The key space is bounded as well, and so are the rows written. App IDs,
|
|
12
|
+
* details, and hostnames can be made up by whoever sends the request, so only
|
|
13
|
+
* TRACKING.REJECTION_MAX_KEYS_PER_MINUTE distinct keys a minute, and
|
|
14
|
+
* TRACKING.REJECTION_MAX_KEYS_PER_HOUR an hour, keep them. The budgets hold
|
|
15
|
+
* across flushes: writing the counts does not start a new allowance. Beyond
|
|
16
|
+
* them a key keeps only its minute, endpoint, and reason. The count stays
|
|
17
|
+
* exact; the made-up values are dropped.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { TRACKING } = require('../constants');
|
|
21
|
+
|
|
22
|
+
/** The shape an app ID must have to be kept; anything else is counted as blank. */
|
|
23
|
+
const APP_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
24
|
+
/** Details are our own short labels (a field name, a bot's name), but are bounded anyway. */
|
|
25
|
+
const DETAIL_MAX = 64;
|
|
26
|
+
|
|
27
|
+
const cleanAppId = (value) => (typeof value === 'string' && APP_ID_SHAPE.test(value) ? value : '');
|
|
28
|
+
const cleanDetail = (value) => (typeof value === 'string' ? value.replace(/[^\x20-\x7e]/g, '').slice(0, DETAIL_MAX) : '');
|
|
29
|
+
const cleanHostname = (value) => (typeof value === 'string' ? value : '');
|
|
30
|
+
|
|
31
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ write: (rows: object[]) => Promise<unknown>, now?: () => number,
|
|
35
|
+
* flushMs?: number, maxKeysPerMinute?: number, maxKeysPerHour?: number }} options
|
|
36
|
+
*/
|
|
37
|
+
function createRejectionCounter({
|
|
38
|
+
write,
|
|
39
|
+
now = Date.now,
|
|
40
|
+
flushMs = TRACKING.REJECTION_FLUSH_MS,
|
|
41
|
+
maxKeysPerMinute = TRACKING.REJECTION_MAX_KEYS_PER_MINUTE,
|
|
42
|
+
maxKeysPerHour = TRACKING.REJECTION_MAX_KEYS_PER_HOUR,
|
|
43
|
+
}) {
|
|
44
|
+
const pending = new Map();
|
|
45
|
+
/** Keys already kept whole, by minute: this minute's and the one before. */
|
|
46
|
+
const keptByMinute = new Map();
|
|
47
|
+
/** How many keys were kept whole, by hour: this hour's. */
|
|
48
|
+
const keptByHour = new Map();
|
|
49
|
+
let timer = null;
|
|
50
|
+
|
|
51
|
+
/** Whether a key may keep its app, detail, and hostname, spending the budgets if new. */
|
|
52
|
+
function keepWhole(minute, key) {
|
|
53
|
+
for (const old of keptByMinute.keys()) if (old < minute - TRACKING.REJECTION_BUCKET_MS) keptByMinute.delete(old);
|
|
54
|
+
const hour = Math.floor(minute / HOUR_MS) * HOUR_MS;
|
|
55
|
+
for (const old of keptByHour.keys()) if (old < hour) keptByHour.delete(old);
|
|
56
|
+
|
|
57
|
+
const kept = keptByMinute.get(minute) || new Set();
|
|
58
|
+
keptByMinute.set(minute, kept);
|
|
59
|
+
if (kept.has(key)) return true;
|
|
60
|
+
const hourCount = keptByHour.get(hour) || 0;
|
|
61
|
+
if (kept.size >= maxKeysPerMinute || hourCount >= maxKeysPerHour) return false;
|
|
62
|
+
kept.add(key);
|
|
63
|
+
keptByHour.set(hour, hourCount + 1);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function schedule() {
|
|
68
|
+
if (timer) return;
|
|
69
|
+
timer = setTimeout(() => {
|
|
70
|
+
timer = null;
|
|
71
|
+
flush();
|
|
72
|
+
}, flushMs);
|
|
73
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Count one request.
|
|
78
|
+
* @param {{ source: string, reason: string, appId?: string, detail?: string, hostname?: string }} rejection
|
|
79
|
+
*/
|
|
80
|
+
function count({ source, reason, appId, detail, hostname }) {
|
|
81
|
+
const minute = Math.floor(now() / TRACKING.REJECTION_BUCKET_MS) * TRACKING.REJECTION_BUCKET_MS;
|
|
82
|
+
let entry = { source, reason, appId: cleanAppId(appId), detail: cleanDetail(detail), hostname: cleanHostname(hostname) };
|
|
83
|
+
let key = [minute, entry.source, entry.reason, entry.appId, entry.detail, entry.hostname].join('\u0000');
|
|
84
|
+
const plain = !entry.appId && !entry.detail && !entry.hostname;
|
|
85
|
+
if (!plain && !keepWhole(minute, key)) {
|
|
86
|
+
entry = { source, reason, appId: '', detail: '', hostname: '' };
|
|
87
|
+
key = [minute, source, reason, '', '', ''].join('\u0000');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const row = pending.get(key) || { minute: new Date(minute), ...entry, requests: 0 };
|
|
91
|
+
row.requests += 1;
|
|
92
|
+
pending.set(key, row);
|
|
93
|
+
schedule();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Write everything pending now; also used on shutdown. */
|
|
97
|
+
async function flush() {
|
|
98
|
+
if (timer) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
timer = null;
|
|
101
|
+
}
|
|
102
|
+
if (pending.size === 0) return;
|
|
103
|
+
const rows = [...pending.values()];
|
|
104
|
+
pending.clear();
|
|
105
|
+
await write(rows);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
count,
|
|
110
|
+
flush,
|
|
111
|
+
get pendingKeys() {
|
|
112
|
+
return pending.size;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { createRejectionCounter };
|
package/db/retention.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled retention: expired trash, and old view-log entries.
|
|
3
|
+
*
|
|
4
|
+
* Trash (GDPR Art. 5(1)(e), storage limitation): a soft-deleted view is kept
|
|
5
|
+
* for TRASH_RETENTION_DAYS so a mistaken delete can be undone, then erased for
|
|
6
|
+
* good.
|
|
7
|
+
*
|
|
8
|
+
* View log: the register of accepted views gains a row per view. It holds no
|
|
9
|
+
* personal data, but unbounded it grows as large as every app table together,
|
|
10
|
+
* so entries older than VIEW_LOG_RETENTION_DAYS are removed.
|
|
11
|
+
*
|
|
12
|
+
* Every run that removes anything leaves an entry in the admin operation log,
|
|
13
|
+
* attributed to the system rather than a session, so nothing is removed
|
|
14
|
+
* silently. The admin log itself is never pruned.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { ADMIN, ADMIN_ACTION } = require('../constants');
|
|
18
|
+
const { logWarning, WarningType } = require('../utils/errorUtils');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Erase expired trash in every app once.
|
|
22
|
+
*
|
|
23
|
+
* One app failing does not stop the others; the failure is reported and the
|
|
24
|
+
* next run tries again.
|
|
25
|
+
*
|
|
26
|
+
* @param {{ adminRepo: object, logRepo: object, appIds: string[], days: number }} deps
|
|
27
|
+
* @returns {Promise<Record<string, number>>} rows erased per app
|
|
28
|
+
*/
|
|
29
|
+
async function purgeExpiredTrash({ adminRepo, logRepo, appIds, days }) {
|
|
30
|
+
const erased = {};
|
|
31
|
+
if (!(days > 0)) return erased;
|
|
32
|
+
|
|
33
|
+
for (const appId of appIds) {
|
|
34
|
+
try {
|
|
35
|
+
const count = await adminRepo.purgeExpired(appId, days);
|
|
36
|
+
erased[appId] = count;
|
|
37
|
+
if (count > 0) {
|
|
38
|
+
await logRepo.writeAdminLog({
|
|
39
|
+
action: ADMIN_ACTION.TRASH_AUTO_PURGED,
|
|
40
|
+
appId,
|
|
41
|
+
targetCount: count,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
logWarning(WarningType.TRASH_PURGE_FAILED, { appId, cause: cause.message });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return erased;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove view-log entries older than `days` once. A failure is reported and
|
|
53
|
+
* the next run tries again.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ logRepo: object, days: number }} deps
|
|
56
|
+
* @returns {Promise<number>} entries removed
|
|
57
|
+
*/
|
|
58
|
+
async function pruneViewLog({ logRepo, days }) {
|
|
59
|
+
if (!(days > 0)) return 0;
|
|
60
|
+
try {
|
|
61
|
+
const count = await logRepo.pruneViewLog(days);
|
|
62
|
+
if (count > 0) {
|
|
63
|
+
await logRepo.writeAdminLog({ action: ADMIN_ACTION.VIEW_LOG_PRUNED, targetCount: count });
|
|
64
|
+
}
|
|
65
|
+
return count;
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
logWarning(WarningType.VIEW_LOG_PRUNE_FAILED, { cause: cause.message });
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run both jobs now and then on an interval. A job whose retention is zero
|
|
74
|
+
* (keep forever) is skipped; when both are, nothing is scheduled.
|
|
75
|
+
*
|
|
76
|
+
* `getAppIds` is called on every run, so apps provisioned at runtime are
|
|
77
|
+
* covered without a restart. The timer is unref'd so it never keeps the
|
|
78
|
+
* process alive on its own.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ adminRepo: object, logRepo: object, getAppIds: () => string[],
|
|
81
|
+
* trashDays: number, viewLogDays: number, intervalMs?: number }} deps
|
|
82
|
+
* @returns {() => void} stops the schedule
|
|
83
|
+
*/
|
|
84
|
+
function startRetention({ adminRepo, logRepo, getAppIds, trashDays, viewLogDays, intervalMs = ADMIN.RETENTION_INTERVAL_MS }) {
|
|
85
|
+
if (!(trashDays > 0) && !(viewLogDays > 0)) return () => {};
|
|
86
|
+
|
|
87
|
+
const run = async () => {
|
|
88
|
+
await purgeExpiredTrash({ adminRepo, logRepo, appIds: getAppIds(), days: trashDays });
|
|
89
|
+
await pruneViewLog({ logRepo, days: viewLogDays });
|
|
90
|
+
};
|
|
91
|
+
run();
|
|
92
|
+
const timer = setInterval(run, intervalMs);
|
|
93
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
94
|
+
return () => clearInterval(timer);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { purgeExpiredTrash, pruneViewLog, startRetention };
|
package/index.js
CHANGED
|
@@ -3,18 +3,26 @@ const cors = require('cors');
|
|
|
3
3
|
const helmet = require('helmet');
|
|
4
4
|
const rateLimit = require('express-rate-limit');
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { ADMIN, APP_NAME, PAYLOAD_LIMITS, REJECTION_REASON, SERVER } = require('./constants');
|
|
7
7
|
const config = require('./config');
|
|
8
8
|
const DatabaseManager = require('./db/DatabaseManager');
|
|
9
9
|
const logger = require('./utils/logger');
|
|
10
|
-
const { buildCorsOptions } = require('./middleware/security');
|
|
11
|
-
const { createAnalyticsRouter } = require('./routes/analytics');
|
|
10
|
+
const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
|
|
11
|
+
const { createAnalyticsRouter, trackingSourceFor } = require('./routes/analytics');
|
|
12
|
+
const { createAdminRouter } = require('./routes/admin');
|
|
13
|
+
const { startRetention } = require('./db/retention');
|
|
14
|
+
const { createDbSessionStore } = require('./db/adminSessionStore');
|
|
15
|
+
const { openCityLookup } = require('./utils/geoCity');
|
|
12
16
|
|
|
13
17
|
logger.configure({ level: config.server.logLevel });
|
|
14
18
|
|
|
15
19
|
const dbManager = new DatabaseManager(config.dbInfo);
|
|
16
20
|
let isServerReady = false;
|
|
17
21
|
let httpServer = null;
|
|
22
|
+
let stopRetention = () => {};
|
|
23
|
+
let analyticsRouter = null;
|
|
24
|
+
/** The optional city database, opened at startup; both routers read it per request. */
|
|
25
|
+
const geo = { city: null };
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Build the Express application.
|
|
@@ -27,39 +35,71 @@ function createApp() {
|
|
|
27
35
|
|
|
28
36
|
app.disable('x-powered-by');
|
|
29
37
|
app.use(helmet());
|
|
38
|
+
|
|
39
|
+
// Trusting the proxy is needed before the admin router sees a request: it
|
|
40
|
+
// decides whether the session cookie is marked Secure from req.secure.
|
|
41
|
+
app.set('trust proxy', config.server.trustProxy);
|
|
42
|
+
|
|
43
|
+
// The admin surface exists only when a password is configured, and is
|
|
44
|
+
// mounted ahead of CORS and the public rate limit: it is same-origin only,
|
|
45
|
+
// must never carry the analytics sites' CORS headers, and has its own
|
|
46
|
+
// limits.
|
|
47
|
+
if (config.admin.enabled) {
|
|
48
|
+
app.use(ADMIN.PATH_PREFIX, createAdminRouter({
|
|
49
|
+
config,
|
|
50
|
+
adminRepo: dbManager.admin,
|
|
51
|
+
logRepo: dbManager.logs,
|
|
52
|
+
// In the database, so a restart or deploy signs nobody out.
|
|
53
|
+
sessionStore: createDbSessionStore(dbManager, {
|
|
54
|
+
idleMs: config.admin.sessionIdleMs,
|
|
55
|
+
absoluteMs: config.admin.sessionMaxAgeMs,
|
|
56
|
+
}),
|
|
57
|
+
isReady: () => isServerReady,
|
|
58
|
+
geo,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const router = createAnalyticsRouter({
|
|
63
|
+
config,
|
|
64
|
+
dbManager,
|
|
65
|
+
isReady: () => isServerReady,
|
|
66
|
+
geo,
|
|
67
|
+
});
|
|
68
|
+
analyticsRouter = router;
|
|
69
|
+
|
|
70
|
+
// A site missing from CORS_ORIGINS is refused at the browser's preflight;
|
|
71
|
+
// counted, so the tracking log shows it.
|
|
72
|
+
app.use(countRefusedPreflights(config.server.corsOrigins, {
|
|
73
|
+
isTrackingPath: (path) => Boolean(trackingSourceFor(path)),
|
|
74
|
+
onRefused: (req) => router.countRejection(req, REJECTION_REASON.ORIGIN_NOT_ALLOWED, { detail: 'CORS_ORIGINS' }),
|
|
75
|
+
}));
|
|
30
76
|
app.use(cors(buildCorsOptions(config.server.corsOrigins)));
|
|
31
77
|
|
|
32
78
|
// Bounded well below body-parser's 100kb default; /event is the only
|
|
33
79
|
// endpoint taking a body and its payload is small.
|
|
34
80
|
app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
|
|
35
81
|
|
|
36
|
-
// Never bare `true`. Trusting every hop lets any caller set
|
|
37
|
-
// X-Forwarded-For and be believed, which forges geolocation and rotates
|
|
38
|
-
// the rate-limiter key at will.
|
|
39
|
-
app.set('trust proxy', config.server.trustProxy);
|
|
40
|
-
|
|
41
82
|
app.use(rateLimit({
|
|
42
83
|
windowMs: config.server.rateLimit.windowMs,
|
|
43
84
|
limit: config.server.rateLimit.max,
|
|
44
85
|
message: { message: 'Too many requests, please try again later.' },
|
|
45
86
|
standardHeaders: true,
|
|
46
87
|
legacyHeaders: false,
|
|
88
|
+
// A tracking request turned away here is counted in the tracking log
|
|
89
|
+
// like any other refusal (in memory, written in batches).
|
|
90
|
+
handler: (req, res, next, options) => {
|
|
91
|
+
if (trackingSourceFor(req.path)) {
|
|
92
|
+
router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
|
|
93
|
+
}
|
|
94
|
+
res.status(options.statusCode).json(options.message);
|
|
95
|
+
},
|
|
47
96
|
}));
|
|
48
97
|
|
|
49
|
-
app.use(
|
|
50
|
-
config,
|
|
51
|
-
dbManager,
|
|
52
|
-
isReady: () => isServerReady,
|
|
53
|
-
}));
|
|
98
|
+
app.use(router);
|
|
54
99
|
|
|
55
|
-
// Malformed JSON and payloads over the limit surface here
|
|
56
|
-
//
|
|
57
|
-
app.use(
|
|
58
|
-
const status = err.status || err.statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR;
|
|
59
|
-
logger.warn(`Request rejected: ${err.message}`, { requestId: req.id });
|
|
60
|
-
res.status(status === HTTP_STATUS.INTERNAL_SERVER_ERROR ? HTTP_STATUS.BAD_REQUEST : status)
|
|
61
|
-
.json({ message: 'Malformed or oversized request' });
|
|
62
|
-
});
|
|
100
|
+
// Malformed JSON and payloads over the limit surface here, and are
|
|
101
|
+
// counted in the tracking log when they were sent to a tracking endpoint.
|
|
102
|
+
app.use(router.bodyErrorHandler);
|
|
63
103
|
|
|
64
104
|
return app;
|
|
65
105
|
}
|
|
@@ -72,7 +112,7 @@ const app = createApp();
|
|
|
72
112
|
* Config-declared apps and registry-declared apps are unioned: the file stays
|
|
73
113
|
* authoritative for a fixed single-operator deployment, while the registry
|
|
74
114
|
* carries tenants provisioned at runtime. A registry that cannot be read is a
|
|
75
|
-
* warning rather than a startup failure
|
|
115
|
+
* warning rather than a startup failure: config-declared apps still work.
|
|
76
116
|
*/
|
|
77
117
|
async function mergeRegisteredApps() {
|
|
78
118
|
try {
|
|
@@ -101,6 +141,14 @@ async function mergeRegisteredApps() {
|
|
|
101
141
|
const initializeServer = async () => {
|
|
102
142
|
try {
|
|
103
143
|
config.validate();
|
|
144
|
+
|
|
145
|
+
// A configured city database that cannot be opened stops startup: the
|
|
146
|
+
// operator asked for it, and running without it would hide that.
|
|
147
|
+
if (config.geo?.cityDatabase) {
|
|
148
|
+
geo.city = await openCityLookup(config.geo.cityDatabase);
|
|
149
|
+
logger.info(`City database loaded (${geo.city.databaseType})`);
|
|
150
|
+
}
|
|
151
|
+
|
|
104
152
|
await dbManager.initialize(config.allowed.appId);
|
|
105
153
|
|
|
106
154
|
// Merge dynamically registered tenants into the live allowlist, so
|
|
@@ -108,6 +156,20 @@ const initializeServer = async () => {
|
|
|
108
156
|
// anyone editing allowed.json.
|
|
109
157
|
await mergeRegisteredApps();
|
|
110
158
|
|
|
159
|
+
// initialize() migrated the configured apps; this also covers the ones
|
|
160
|
+
// registered at runtime and merged in above. Idempotent, so apps
|
|
161
|
+
// already in shape cost one information_schema read. Fails startup
|
|
162
|
+
// rather than serving over a half-migrated table.
|
|
163
|
+
await dbManager.migrate(config.allowed.appId);
|
|
164
|
+
|
|
165
|
+
stopRetention = startRetention({
|
|
166
|
+
adminRepo: dbManager.admin,
|
|
167
|
+
logRepo: dbManager.logs,
|
|
168
|
+
getAppIds: () => config.allowed.appId,
|
|
169
|
+
trashDays: config.admin.trashRetentionDays,
|
|
170
|
+
viewLogDays: config.admin.viewLogRetentionDays,
|
|
171
|
+
});
|
|
172
|
+
|
|
111
173
|
isServerReady = true;
|
|
112
174
|
|
|
113
175
|
if (require.main === module) {
|
|
@@ -123,8 +185,8 @@ const initializeServer = async () => {
|
|
|
123
185
|
}
|
|
124
186
|
};
|
|
125
187
|
|
|
126
|
-
// Only when run directly. Requiring this module as a library
|
|
127
|
-
// createAnalyticsRouter into an existing app
|
|
188
|
+
// Only when run directly. Requiring this module as a library (to mount
|
|
189
|
+
// createAnalyticsRouter into an existing app) must not validate config,
|
|
128
190
|
// connect to a database, or bind a port as a side effect of the import.
|
|
129
191
|
if (require.main === module) {
|
|
130
192
|
initializeServer();
|
|
@@ -147,6 +209,8 @@ const shutdown = async (signal, exitCode = 0) => {
|
|
|
147
209
|
forceExit.unref();
|
|
148
210
|
|
|
149
211
|
try {
|
|
212
|
+
stopRetention();
|
|
213
|
+
await analyticsRouter?.flushRejections();
|
|
150
214
|
if (httpServer) {
|
|
151
215
|
await new Promise((resolve) => httpServer.close(resolve));
|
|
152
216
|
}
|
|
@@ -179,6 +243,9 @@ process.on('uncaughtException', (error) => {
|
|
|
179
243
|
module.exports = app;
|
|
180
244
|
module.exports.createApp = createApp;
|
|
181
245
|
module.exports.createAnalyticsRouter = createAnalyticsRouter;
|
|
246
|
+
module.exports.createAdminRouter = createAdminRouter;
|
|
247
|
+
module.exports.startRetention = startRetention;
|
|
248
|
+
module.exports.createDbSessionStore = createDbSessionStore;
|
|
182
249
|
module.exports.DatabaseManager = DatabaseManager;
|
|
183
250
|
module.exports.dbManager = dbManager;
|
|
184
251
|
module.exports.initializeServer = initializeServer;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin UI authentication: password login, server-side sessions, CSRF, and a
|
|
3
|
+
* fresh password for the actions that cannot be undone.
|
|
4
|
+
*
|
|
5
|
+
* The browser holds only an opaque random token in an HttpOnly cookie. Stores
|
|
6
|
+
* are keyed by that token's SHA-256, so reading a store (the in-memory one
|
|
7
|
+
* here, or the database table the server uses so a restart signs nobody out)
|
|
8
|
+
* yields no usable session.
|
|
9
|
+
*
|
|
10
|
+
* CSRF is defeated three ways, each sufficient on its own in a modern browser:
|
|
11
|
+
* the cookie is SameSite=Strict, every mutating request must echo a per-session
|
|
12
|
+
* token in a header that a cross-site form cannot set, and a present Origin
|
|
13
|
+
* header must match this server. The CSRF token is an HMAC of the session
|
|
14
|
+
* token, so it is never stored and cannot be computed without the cookie.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
const { ADMIN, ADMIN_ERROR_CODE, HTTP_STATUS } = require('../constants');
|
|
20
|
+
const { safeEqual } = require('./auth');
|
|
21
|
+
const { parseCookies } = require('../utils/cookieUtils');
|
|
22
|
+
const { WarningType, logWarning } = require('../utils/errorUtils');
|
|
23
|
+
|
|
24
|
+
/** Methods that never change state and so need no CSRF token. */
|
|
25
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
26
|
+
|
|
27
|
+
/** @param {string} token @returns {string} */
|
|
28
|
+
function hashToken(token) {
|
|
29
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @returns {string} a new session token, 256 random bits */
|
|
33
|
+
function newToken() {
|
|
34
|
+
return crypto.randomBytes(ADMIN.SESSION_TOKEN_BYTES).toString('base64url');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The CSRF token for a session: an HMAC of its token, so it is the same for
|
|
39
|
+
* the life of the session, is never stored, and needs the cookie to compute.
|
|
40
|
+
* @param {string} token
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function sessionCsrfToken(token) {
|
|
44
|
+
return crypto.createHmac('sha256', token).update('viewcounter-admin-csrf').digest('base64url');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The in-memory session store: used by tests and by an embedding app that
|
|
49
|
+
* passes no store of its own. A restart signs everyone out.
|
|
50
|
+
*
|
|
51
|
+
* Every store has the same async interface:
|
|
52
|
+
* create() -> { token, session }
|
|
53
|
+
* get(token) -> session or null; extends the idle window
|
|
54
|
+
* destroy(token) -> whether a session was ended
|
|
55
|
+
* confirmPassword(token) -> records that the password was just entered
|
|
56
|
+
* where a session is { id, passwordAgeMs }.
|
|
57
|
+
*
|
|
58
|
+
* @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number,
|
|
59
|
+
* now?: () => number }} [options]
|
|
60
|
+
*/
|
|
61
|
+
function createSessionStore({
|
|
62
|
+
idleMs = ADMIN.SESSION_IDLE_TIMEOUT_MS,
|
|
63
|
+
absoluteMs = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
|
|
64
|
+
maxSessions = ADMIN.MAX_SESSIONS,
|
|
65
|
+
now = Date.now,
|
|
66
|
+
} = {}) {
|
|
67
|
+
/** @type {Map<string, {id: string, createdAt: number, lastSeenAt: number, passwordAt: number}>} */
|
|
68
|
+
const sessions = new Map();
|
|
69
|
+
|
|
70
|
+
const view = (session, at) => ({ id: session.id, passwordAgeMs: at - session.passwordAt });
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
idleMs,
|
|
74
|
+
absoluteMs,
|
|
75
|
+
|
|
76
|
+
async create() {
|
|
77
|
+
const at = now();
|
|
78
|
+
const token = newToken();
|
|
79
|
+
const session = { id: crypto.randomUUID(), createdAt: at, lastSeenAt: at, passwordAt: at };
|
|
80
|
+
|
|
81
|
+
// Bounded: the oldest session goes first. Map preserves insertion
|
|
82
|
+
// order, so the first key is always the oldest.
|
|
83
|
+
while (sessions.size >= maxSessions) {
|
|
84
|
+
sessions.delete(sessions.keys().next().value);
|
|
85
|
+
}
|
|
86
|
+
sessions.set(hashToken(token), session);
|
|
87
|
+
return { token, session: view(session, at) };
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async get(token) {
|
|
91
|
+
if (typeof token !== 'string' || token.length === 0) return null;
|
|
92
|
+
const key = hashToken(token);
|
|
93
|
+
const session = sessions.get(key);
|
|
94
|
+
if (!session) return null;
|
|
95
|
+
|
|
96
|
+
const at = now();
|
|
97
|
+
if (at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs) {
|
|
98
|
+
sessions.delete(key);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
session.lastSeenAt = at;
|
|
102
|
+
return view(session, at);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async destroy(token) {
|
|
106
|
+
if (typeof token !== 'string' || token.length === 0) return false;
|
|
107
|
+
return sessions.delete(hashToken(token));
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async confirmPassword(token) {
|
|
111
|
+
const session = typeof token === 'string' ? sessions.get(hashToken(token)) : null;
|
|
112
|
+
if (session) session.passwordAt = now();
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
get size() {
|
|
116
|
+
return sessions.size;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Read the session token from the request's cookies. */
|
|
122
|
+
function readToken(req) {
|
|
123
|
+
return parseCookies(req.headers.cookie)[ADMIN.SESSION_COOKIE];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Cookie attributes. `Secure` follows the connection: a deployment behind a
|
|
128
|
+
* TLS-terminating proxy gets it through `trust proxy`, and plain-HTTP local
|
|
129
|
+
* development still works. Scoped to wherever the admin router is mounted
|
|
130
|
+
* (`req.adminBasePath`), so the cookie never travels to the public analytics
|
|
131
|
+
* endpoints and still works when another app mounts the router elsewhere.
|
|
132
|
+
*/
|
|
133
|
+
function cookieOptions(req, maxAge = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS) {
|
|
134
|
+
return {
|
|
135
|
+
httpOnly: true,
|
|
136
|
+
sameSite: 'strict',
|
|
137
|
+
secure: req.secure,
|
|
138
|
+
path: req.adminBasePath || ADMIN.PATH_PREFIX,
|
|
139
|
+
maxAge,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @returns {import('express').RequestHandler} */
|
|
144
|
+
function requireAdminSession(store) {
|
|
145
|
+
return async (req, res, next) => {
|
|
146
|
+
try {
|
|
147
|
+
const token = readToken(req);
|
|
148
|
+
const session = await store.get(token);
|
|
149
|
+
if (!session) {
|
|
150
|
+
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.UNAUTHENTICATED });
|
|
151
|
+
}
|
|
152
|
+
req.adminSession = session;
|
|
153
|
+
req.adminToken = token;
|
|
154
|
+
return next();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return next(error);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* For actions that cannot be undone: the password must have been entered in
|
|
163
|
+
* the last `windowMs`, at sign-in or through POST /reauth. Otherwise the UI is
|
|
164
|
+
* told to ask for it and retry.
|
|
165
|
+
* @returns {import('express').RequestHandler}
|
|
166
|
+
*/
|
|
167
|
+
function requireRecentPassword(windowMs = ADMIN.REAUTH_WINDOW_MS) {
|
|
168
|
+
return (req, res, next) => {
|
|
169
|
+
if (req.adminSession && req.adminSession.passwordAgeMs <= windowMs) return next();
|
|
170
|
+
return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.REAUTH_REQUIRED });
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The origin this request was addressed to, as the browser would write it.
|
|
176
|
+
* @param {import('express').Request} req
|
|
177
|
+
*/
|
|
178
|
+
function expectedOrigin(req) {
|
|
179
|
+
return `${req.protocol}://${req.get('host')}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether a browser-supplied Origin (absent for same-origin GETs and non-browser
|
|
184
|
+
* clients) matches this server. A mismatch is logged with both values: behind a
|
|
185
|
+
* misconfigured proxy every admin request is refused, and the log is the only
|
|
186
|
+
* place that says why.
|
|
187
|
+
* @param {import('express').Request} req
|
|
188
|
+
*/
|
|
189
|
+
function originAllowed(req) {
|
|
190
|
+
const presented = req.get('origin');
|
|
191
|
+
if (!presented) return true;
|
|
192
|
+
const expected = expectedOrigin(req);
|
|
193
|
+
if (presented === expected) return true;
|
|
194
|
+
logWarning(WarningType.ADMIN_ORIGIN_REJECTED, { presented, expected });
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Reject a state-changing request that does not carry this session's CSRF
|
|
200
|
+
* token, or that a browser says came from another origin.
|
|
201
|
+
* @returns {import('express').RequestHandler}
|
|
202
|
+
*/
|
|
203
|
+
function requireCsrf() {
|
|
204
|
+
return (req, res, next) => {
|
|
205
|
+
if (SAFE_METHODS.has(req.method)) return next();
|
|
206
|
+
|
|
207
|
+
const presented = req.get(ADMIN.CSRF_HEADER) || '';
|
|
208
|
+
const session = req.adminSession;
|
|
209
|
+
|
|
210
|
+
const originOk = originAllowed(req);
|
|
211
|
+
const tokenOk = Boolean(session) && typeof req.adminToken === 'string'
|
|
212
|
+
&& safeEqual(presented, sessionCsrfToken(req.adminToken));
|
|
213
|
+
|
|
214
|
+
if (!originOk || !tokenOk) {
|
|
215
|
+
return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
|
|
216
|
+
}
|
|
217
|
+
return next();
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Constant-time password check. An unset password never matches anything,
|
|
223
|
+
* including an empty submission.
|
|
224
|
+
*/
|
|
225
|
+
function verifyPassword(presented, configured) {
|
|
226
|
+
if (!configured) return false;
|
|
227
|
+
return safeEqual(String(presented ?? ''), configured);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
createSessionStore,
|
|
232
|
+
requireAdminSession,
|
|
233
|
+
requireRecentPassword,
|
|
234
|
+
requireCsrf,
|
|
235
|
+
sessionCsrfToken,
|
|
236
|
+
newToken,
|
|
237
|
+
verifyPassword,
|
|
238
|
+
cookieOptions,
|
|
239
|
+
expectedOrigin,
|
|
240
|
+
originAllowed,
|
|
241
|
+
readToken,
|
|
242
|
+
hashToken,
|
|
243
|
+
SAFE_METHODS,
|
|
244
|
+
};
|