@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/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const cors = require('cors');
|
|
3
3
|
const helmet = require('helmet');
|
|
4
|
-
const rateLimit = require('express-rate-limit');
|
|
5
4
|
|
|
6
|
-
const { ADMIN, APP_NAME,
|
|
5
|
+
const { ADMIN, APP_NAME, PAYLOAD_LIMITS, REJECTION_REASON, SERVER } = require('./constants');
|
|
7
6
|
const config = require('./config');
|
|
8
7
|
const DatabaseManager = require('./db/DatabaseManager');
|
|
9
8
|
const logger = require('./utils/logger');
|
|
10
|
-
const { buildCorsOptions } = require('./middleware/security');
|
|
11
|
-
const { createAnalyticsRouter } = require('./routes/analytics');
|
|
9
|
+
const { buildCorsOptions, countRefusedPreflights } = require('./middleware/security');
|
|
10
|
+
const { createAnalyticsRouter, buildPerIpLimiters, trackingSourceFor } = require('./routes/analytics');
|
|
12
11
|
const { createAdminRouter } = require('./routes/admin');
|
|
13
12
|
const { startRetention } = require('./db/retention');
|
|
13
|
+
const { createDbSessionStore } = require('./db/adminSessionStore');
|
|
14
|
+
const { openCityLookup } = require('./utils/geoCity');
|
|
14
15
|
|
|
15
16
|
logger.configure({ level: config.server.logLevel });
|
|
16
17
|
|
|
@@ -18,6 +19,9 @@ const dbManager = new DatabaseManager(config.dbInfo);
|
|
|
18
19
|
let isServerReady = false;
|
|
19
20
|
let httpServer = null;
|
|
20
21
|
let stopRetention = () => {};
|
|
22
|
+
let analyticsRouter = null;
|
|
23
|
+
/** The optional city database, opened at startup; both routers read it per request. */
|
|
24
|
+
const geo = { city: null };
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Build the Express application.
|
|
@@ -44,38 +48,47 @@ function createApp() {
|
|
|
44
48
|
config,
|
|
45
49
|
adminRepo: dbManager.admin,
|
|
46
50
|
logRepo: dbManager.logs,
|
|
51
|
+
// In the database, so a restart or deploy signs nobody out.
|
|
52
|
+
sessionStore: createDbSessionStore(dbManager, {
|
|
53
|
+
idleMs: config.admin.sessionIdleMs,
|
|
54
|
+
absoluteMs: config.admin.sessionMaxAgeMs,
|
|
55
|
+
}),
|
|
47
56
|
isReady: () => isServerReady,
|
|
57
|
+
geo,
|
|
48
58
|
}));
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
const router = createAnalyticsRouter({
|
|
62
|
+
config,
|
|
63
|
+
dbManager,
|
|
64
|
+
isReady: () => isServerReady,
|
|
65
|
+
geo,
|
|
66
|
+
});
|
|
67
|
+
analyticsRouter = router;
|
|
68
|
+
|
|
69
|
+
// A site missing from CORS_ORIGINS is refused at the browser's preflight;
|
|
70
|
+
// counted, so the tracking log shows it.
|
|
71
|
+
app.use(countRefusedPreflights(config.server.corsOrigins, {
|
|
72
|
+
isTrackingPath: (path) => Boolean(trackingSourceFor(path)),
|
|
73
|
+
onRefused: (req) => router.countRejection(req, REJECTION_REASON.ORIGIN_NOT_ALLOWED, { detail: 'CORS_ORIGINS' }),
|
|
74
|
+
}));
|
|
51
75
|
app.use(cors(buildCorsOptions(config.server.corsOrigins)));
|
|
52
76
|
|
|
53
77
|
// Bounded well below body-parser's 100kb default; /event is the only
|
|
54
78
|
// endpoint taking a body and its payload is small.
|
|
55
79
|
app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
|
|
56
80
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
standardHeaders: true,
|
|
62
|
-
legacyHeaders: false,
|
|
81
|
+
// A tracking request turned away here is counted in the tracking log like
|
|
82
|
+
// any other refusal (in memory, written in batches).
|
|
83
|
+
app.use(buildPerIpLimiters(config.server.rateLimit, (req) => {
|
|
84
|
+
if (trackingSourceFor(req.path)) router.countRejection(req, REJECTION_REASON.RATE_LIMITED, { detail: 'ip' });
|
|
63
85
|
}));
|
|
64
86
|
|
|
65
|
-
app.use(
|
|
66
|
-
config,
|
|
67
|
-
dbManager,
|
|
68
|
-
isReady: () => isServerReady,
|
|
69
|
-
}));
|
|
87
|
+
app.use(router);
|
|
70
88
|
|
|
71
|
-
// Malformed JSON and payloads over the limit surface here
|
|
72
|
-
//
|
|
73
|
-
app.use(
|
|
74
|
-
const status = err.status || err.statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR;
|
|
75
|
-
logger.warn(`Request rejected: ${err.message}`, { requestId: req.id });
|
|
76
|
-
res.status(status === HTTP_STATUS.INTERNAL_SERVER_ERROR ? HTTP_STATUS.BAD_REQUEST : status)
|
|
77
|
-
.json({ message: 'Malformed or oversized request' });
|
|
78
|
-
});
|
|
89
|
+
// Malformed JSON and payloads over the limit surface here, and are
|
|
90
|
+
// counted in the tracking log when they were sent to a tracking endpoint.
|
|
91
|
+
app.use(router.bodyErrorHandler);
|
|
79
92
|
|
|
80
93
|
return app;
|
|
81
94
|
}
|
|
@@ -88,7 +101,7 @@ const app = createApp();
|
|
|
88
101
|
* Config-declared apps and registry-declared apps are unioned: the file stays
|
|
89
102
|
* authoritative for a fixed single-operator deployment, while the registry
|
|
90
103
|
* carries tenants provisioned at runtime. A registry that cannot be read is a
|
|
91
|
-
* warning rather than a startup failure
|
|
104
|
+
* warning rather than a startup failure: config-declared apps still work.
|
|
92
105
|
*/
|
|
93
106
|
async function mergeRegisteredApps() {
|
|
94
107
|
try {
|
|
@@ -117,6 +130,14 @@ async function mergeRegisteredApps() {
|
|
|
117
130
|
const initializeServer = async () => {
|
|
118
131
|
try {
|
|
119
132
|
config.validate();
|
|
133
|
+
|
|
134
|
+
// A configured city database that cannot be opened stops startup: the
|
|
135
|
+
// operator asked for it, and running without it would hide that.
|
|
136
|
+
if (config.geo?.cityDatabase) {
|
|
137
|
+
geo.city = await openCityLookup(config.geo.cityDatabase);
|
|
138
|
+
logger.info(`City database loaded (${geo.city.databaseType})`);
|
|
139
|
+
}
|
|
140
|
+
|
|
120
141
|
await dbManager.initialize(config.allowed.appId);
|
|
121
142
|
|
|
122
143
|
// Merge dynamically registered tenants into the live allowlist, so
|
|
@@ -153,8 +174,8 @@ const initializeServer = async () => {
|
|
|
153
174
|
}
|
|
154
175
|
};
|
|
155
176
|
|
|
156
|
-
// Only when run directly. Requiring this module as a library
|
|
157
|
-
// createAnalyticsRouter into an existing app
|
|
177
|
+
// Only when run directly. Requiring this module as a library (to mount
|
|
178
|
+
// createAnalyticsRouter into an existing app) must not validate config,
|
|
158
179
|
// connect to a database, or bind a port as a side effect of the import.
|
|
159
180
|
if (require.main === module) {
|
|
160
181
|
initializeServer();
|
|
@@ -178,6 +199,7 @@ const shutdown = async (signal, exitCode = 0) => {
|
|
|
178
199
|
|
|
179
200
|
try {
|
|
180
201
|
stopRetention();
|
|
202
|
+
await analyticsRouter?.flushRejections();
|
|
181
203
|
if (httpServer) {
|
|
182
204
|
await new Promise((resolve) => httpServer.close(resolve));
|
|
183
205
|
}
|
|
@@ -212,6 +234,7 @@ module.exports.createApp = createApp;
|
|
|
212
234
|
module.exports.createAnalyticsRouter = createAnalyticsRouter;
|
|
213
235
|
module.exports.createAdminRouter = createAdminRouter;
|
|
214
236
|
module.exports.startRetention = startRetention;
|
|
237
|
+
module.exports.createDbSessionStore = createDbSessionStore;
|
|
215
238
|
module.exports.DatabaseManager = DatabaseManager;
|
|
216
239
|
module.exports.dbManager = dbManager;
|
|
217
240
|
module.exports.initializeServer = initializeServer;
|
package/middleware/adminAuth.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Admin UI authentication: password login, server-side sessions, and
|
|
2
|
+
* Admin UI authentication: password login, server-side sessions, CSRF, and a
|
|
3
|
+
* fresh password for the actions that cannot be undone.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
8
9
|
*
|
|
9
10
|
* CSRF is defeated three ways, each sufficient on its own in a modern browser:
|
|
10
11
|
* the cookie is SameSite=Strict, every mutating request must echo a per-session
|
|
11
12
|
* token in a header that a cross-site form cannot set, and a present Origin
|
|
12
|
-
* header must match this server.
|
|
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.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
const crypto = require('crypto');
|
|
@@ -27,7 +29,32 @@ function hashToken(token) {
|
|
|
27
29
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
28
30
|
}
|
|
29
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
|
+
|
|
30
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
|
+
*
|
|
31
58
|
* @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number,
|
|
32
59
|
* now?: () => number }} [options]
|
|
33
60
|
*/
|
|
@@ -37,27 +64,19 @@ function createSessionStore({
|
|
|
37
64
|
maxSessions = ADMIN.MAX_SESSIONS,
|
|
38
65
|
now = Date.now,
|
|
39
66
|
} = {}) {
|
|
40
|
-
/** @type {Map<string, {id: string,
|
|
67
|
+
/** @type {Map<string, {id: string, createdAt: number, lastSeenAt: number, passwordAt: number}>} */
|
|
41
68
|
const sessions = new Map();
|
|
42
69
|
|
|
43
|
-
|
|
44
|
-
return at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs;
|
|
45
|
-
}
|
|
70
|
+
const view = (session, at) => ({ id: session.id, passwordAgeMs: at - session.passwordAt });
|
|
46
71
|
|
|
47
72
|
return {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
create() {
|
|
73
|
+
idleMs,
|
|
74
|
+
absoluteMs,
|
|
75
|
+
|
|
76
|
+
async create() {
|
|
53
77
|
const at = now();
|
|
54
|
-
const token =
|
|
55
|
-
const session = {
|
|
56
|
-
id: crypto.randomUUID(),
|
|
57
|
-
csrfToken: crypto.randomBytes(ADMIN.CSRF_TOKEN_BYTES).toString('base64url'),
|
|
58
|
-
createdAt: at,
|
|
59
|
-
lastSeenAt: at,
|
|
60
|
-
};
|
|
78
|
+
const token = newToken();
|
|
79
|
+
const session = { id: crypto.randomUUID(), createdAt: at, lastSeenAt: at, passwordAt: at };
|
|
61
80
|
|
|
62
81
|
// Bounded: the oldest session goes first. Map preserves insertion
|
|
63
82
|
// order, so the first key is always the oldest.
|
|
@@ -65,34 +84,34 @@ function createSessionStore({
|
|
|
65
84
|
sessions.delete(sessions.keys().next().value);
|
|
66
85
|
}
|
|
67
86
|
sessions.set(hashToken(token), session);
|
|
68
|
-
return { token, session };
|
|
87
|
+
return { token, session: view(session, at) };
|
|
69
88
|
},
|
|
70
89
|
|
|
71
|
-
|
|
72
|
-
* Look up a live session and extend its idle window.
|
|
73
|
-
* @param {string|undefined} token
|
|
74
|
-
*/
|
|
75
|
-
get(token) {
|
|
90
|
+
async get(token) {
|
|
76
91
|
if (typeof token !== 'string' || token.length === 0) return null;
|
|
77
92
|
const key = hashToken(token);
|
|
78
93
|
const session = sessions.get(key);
|
|
79
94
|
if (!session) return null;
|
|
80
95
|
|
|
81
96
|
const at = now();
|
|
82
|
-
if (
|
|
97
|
+
if (at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs) {
|
|
83
98
|
sessions.delete(key);
|
|
84
99
|
return null;
|
|
85
100
|
}
|
|
86
101
|
session.lastSeenAt = at;
|
|
87
|
-
return session;
|
|
102
|
+
return view(session, at);
|
|
88
103
|
},
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
destroy(token) {
|
|
105
|
+
async destroy(token) {
|
|
92
106
|
if (typeof token !== 'string' || token.length === 0) return false;
|
|
93
107
|
return sessions.delete(hashToken(token));
|
|
94
108
|
},
|
|
95
109
|
|
|
110
|
+
async confirmPassword(token) {
|
|
111
|
+
const session = typeof token === 'string' ? sessions.get(hashToken(token)) : null;
|
|
112
|
+
if (session) session.passwordAt = now();
|
|
113
|
+
},
|
|
114
|
+
|
|
96
115
|
get size() {
|
|
97
116
|
return sessions.size;
|
|
98
117
|
},
|
|
@@ -111,27 +130,44 @@ function readToken(req) {
|
|
|
111
130
|
* (`req.adminBasePath`), so the cookie never travels to the public analytics
|
|
112
131
|
* endpoints and still works when another app mounts the router elsewhere.
|
|
113
132
|
*/
|
|
114
|
-
function cookieOptions(req) {
|
|
133
|
+
function cookieOptions(req, maxAge = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS) {
|
|
115
134
|
return {
|
|
116
135
|
httpOnly: true,
|
|
117
136
|
sameSite: 'strict',
|
|
118
137
|
secure: req.secure,
|
|
119
138
|
path: req.adminBasePath || ADMIN.PATH_PREFIX,
|
|
120
|
-
maxAge
|
|
139
|
+
maxAge,
|
|
121
140
|
};
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
/** @returns {import('express').RequestHandler} */
|
|
125
144
|
function requireAdminSession(store) {
|
|
126
|
-
return (req, res, next) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
131
157
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 });
|
|
135
171
|
};
|
|
136
172
|
}
|
|
137
173
|
|
|
@@ -172,7 +208,8 @@ function requireCsrf() {
|
|
|
172
208
|
const session = req.adminSession;
|
|
173
209
|
|
|
174
210
|
const originOk = originAllowed(req);
|
|
175
|
-
const tokenOk = Boolean(session) &&
|
|
211
|
+
const tokenOk = Boolean(session) && typeof req.adminToken === 'string'
|
|
212
|
+
&& safeEqual(presented, sessionCsrfToken(req.adminToken));
|
|
176
213
|
|
|
177
214
|
if (!originOk || !tokenOk) {
|
|
178
215
|
return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
|
|
@@ -193,7 +230,10 @@ function verifyPassword(presented, configured) {
|
|
|
193
230
|
module.exports = {
|
|
194
231
|
createSessionStore,
|
|
195
232
|
requireAdminSession,
|
|
233
|
+
requireRecentPassword,
|
|
196
234
|
requireCsrf,
|
|
235
|
+
sessionCsrfToken,
|
|
236
|
+
newToken,
|
|
197
237
|
verifyPassword,
|
|
198
238
|
cookieOptions,
|
|
199
239
|
expectedOrigin,
|
|
@@ -23,9 +23,11 @@ const {
|
|
|
23
23
|
SORT_ORDER,
|
|
24
24
|
UUID_PATTERN,
|
|
25
25
|
VIEW_LOG_SOURCE,
|
|
26
|
+
TRACKING_OUTCOME,
|
|
26
27
|
VIEW_STATUS,
|
|
27
28
|
} = require('../constants');
|
|
28
29
|
const ReferrerParser = require('../utils/referrerParser');
|
|
30
|
+
const { FILTER_COLUMNS } = require('../db/analysis');
|
|
29
31
|
const { jsonByteLength } = require('../utils/stringUtils');
|
|
30
32
|
|
|
31
33
|
const within = (values) => (value) => Object.values(values).includes(value);
|
|
@@ -75,6 +77,48 @@ const validateLogin = () => [
|
|
|
75
77
|
.withMessage('password is required'),
|
|
76
78
|
];
|
|
77
79
|
|
|
80
|
+
/** The longest breakdown value a `where` filter can hold: a referrer or page path. */
|
|
81
|
+
const WHERE_VALUE_MAX_LENGTH = Math.max(FIELD_MAX_LENGTH.REFERRER, FIELD_MAX_LENGTH.PAGE_PATH);
|
|
82
|
+
/** Every dimension at its longest, as JSON, with room for escaping. */
|
|
83
|
+
const WHERE_MAX_LENGTH = Object.keys(FILTER_COLUMNS).length * (WHERE_VALUE_MAX_LENGTH + 32) * 2;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read `where`: breakdown filters as a JSON object of dimension name to
|
|
87
|
+
* value, with null for rows that have none, such as {"country":"DE"}.
|
|
88
|
+
* @param {unknown} raw
|
|
89
|
+
* @returns {{ ok: true, filters: Record<string, string|null> } | { ok: false, error: string }}
|
|
90
|
+
*/
|
|
91
|
+
function readWhere(raw) {
|
|
92
|
+
if (raw === undefined || raw === '') return { ok: true, filters: {} };
|
|
93
|
+
if (typeof raw !== 'string' || raw.length > WHERE_MAX_LENGTH) return { ok: false, error: 'where must be a short JSON object' };
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(raw);
|
|
97
|
+
} catch {
|
|
98
|
+
return { ok: false, error: 'where must be a JSON object' };
|
|
99
|
+
}
|
|
100
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, error: 'where must be a JSON object' };
|
|
101
|
+
const filters = {};
|
|
102
|
+
for (const [dim, value] of Object.entries(parsed)) {
|
|
103
|
+
if (!Object.hasOwn(FILTER_COLUMNS, dim)) return { ok: false, error: 'where names a dimension that cannot be filtered' };
|
|
104
|
+
if (value !== null && (typeof value !== 'string' || value.length > WHERE_VALUE_MAX_LENGTH)) {
|
|
105
|
+
return { ok: false, error: `each where value must be null or a string of at most ${WHERE_VALUE_MAX_LENGTH} characters` };
|
|
106
|
+
}
|
|
107
|
+
filters[dim] = value;
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, filters };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The breakdown filters of a request that passed validation.
|
|
114
|
+
* @param {unknown} raw
|
|
115
|
+
* @returns {Record<string, string|null>}
|
|
116
|
+
*/
|
|
117
|
+
function parseWhere(raw) {
|
|
118
|
+
const result = readWhere(raw);
|
|
119
|
+
return result.ok ? result.filters : {};
|
|
120
|
+
}
|
|
121
|
+
|
|
78
122
|
/** Filters shared by a listing and its analysis, for one app or all. */
|
|
79
123
|
const filterQueries = () => [
|
|
80
124
|
query('status').optional().custom(within(VIEW_STATUS)).withMessage('Invalid status'),
|
|
@@ -90,6 +134,10 @@ const filterQueries = () => [
|
|
|
90
134
|
.isString().withMessage('search must be a string')
|
|
91
135
|
.isLength({ max: ADMIN.SEARCH_MAX_LENGTH })
|
|
92
136
|
.withMessage(`search must be at most ${ADMIN.SEARCH_MAX_LENGTH} characters`),
|
|
137
|
+
query('where')
|
|
138
|
+
.optional()
|
|
139
|
+
.custom((value) => readWhere(value).ok)
|
|
140
|
+
.withMessage((value) => readWhere(value).error),
|
|
93
141
|
];
|
|
94
142
|
|
|
95
143
|
/** A listing of one app (with `allowed`) or of every app (without). */
|
|
@@ -108,6 +156,15 @@ const validateAnalysis = (allowed) => [
|
|
|
108
156
|
...filterQueries(),
|
|
109
157
|
];
|
|
110
158
|
|
|
159
|
+
/** The event types of one app (with `allowed`) or of every app, in a status. */
|
|
160
|
+
const validateEventTypes = (allowed) => [
|
|
161
|
+
...(allowed ? [adminAppIdParam(allowed)] : []),
|
|
162
|
+
query('status').optional().custom(within(VIEW_STATUS)).withMessage('Invalid status'),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/** Right now takes no filters, only the app for the per-app route. */
|
|
166
|
+
const validateRealtime = (allowed) => (allowed ? [adminAppIdParam(allowed)] : []);
|
|
167
|
+
|
|
111
168
|
|
|
112
169
|
/**
|
|
113
170
|
* Validate one field of an edit and return the column value to store.
|
|
@@ -157,7 +214,9 @@ function checkField(field, value, deviceSizes) {
|
|
|
157
214
|
*
|
|
158
215
|
* Only EDITABLE_FIELDS may appear. A change to `referrer` also re-derives
|
|
159
216
|
* `referrer_domain` and `source_type` with the same parser the write path
|
|
160
|
-
* uses,
|
|
217
|
+
* uses, and stores the referrer the same way (origin and path only). Whether
|
|
218
|
+
* it is on the row's own site, and so internal, depends on each row's site,
|
|
219
|
+
* which the repository applies per row (AdminRepository.updateContent).
|
|
161
220
|
*
|
|
162
221
|
* @param {unknown} changes
|
|
163
222
|
* @param {string[]} deviceSizes
|
|
@@ -221,13 +280,16 @@ const validateAdminLogListing = (allowed) => [
|
|
|
221
280
|
query('action').optional().custom(within(ADMIN_ACTION)).withMessage('Invalid action'),
|
|
222
281
|
];
|
|
223
282
|
|
|
224
|
-
const
|
|
283
|
+
const validateTrackingLogListing = (allowed) => [
|
|
225
284
|
pageQuery(),
|
|
226
285
|
pageSizeQuery(),
|
|
227
286
|
adminAppIdFilter(allowed),
|
|
228
287
|
query('source').optional().custom(within(VIEW_LOG_SOURCE)).withMessage('Invalid source'),
|
|
288
|
+
query('outcome').optional().custom(within(TRACKING_OUTCOME)).withMessage('Invalid outcome'),
|
|
229
289
|
];
|
|
230
290
|
|
|
291
|
+
const validateTrackingSummary = (allowed) => [adminAppIdFilter(allowed)];
|
|
292
|
+
|
|
231
293
|
/** Admin-shaped validation failure: a stable code plus per-field detail. */
|
|
232
294
|
function handleAdminValidation(req, res, next) {
|
|
233
295
|
const errors = validationResult(req);
|
|
@@ -239,14 +301,18 @@ function handleAdminValidation(req, res, next) {
|
|
|
239
301
|
}
|
|
240
302
|
|
|
241
303
|
module.exports = {
|
|
304
|
+
parseWhere,
|
|
242
305
|
validateLogin,
|
|
243
306
|
validateViewListing,
|
|
244
307
|
validateAnalysis,
|
|
308
|
+
validateEventTypes,
|
|
309
|
+
validateRealtime,
|
|
245
310
|
validateEdit,
|
|
246
311
|
validateNote,
|
|
247
312
|
validateBatch,
|
|
248
313
|
validateAdminLogListing,
|
|
249
|
-
|
|
314
|
+
validateTrackingLogListing,
|
|
315
|
+
validateTrackingSummary,
|
|
250
316
|
handleAdminValidation,
|
|
251
317
|
resolveChanges,
|
|
252
318
|
checkField,
|
package/middleware/auth.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two distinct steps, deliberately separate:
|
|
5
5
|
*
|
|
6
|
-
* requireReadApiKey
|
|
7
|
-
* requireAppScope
|
|
6
|
+
* requireReadApiKey *authentication*: is this a key we issued?
|
|
7
|
+
* requireAppScope *authorization*: may THIS key read THIS app?
|
|
8
8
|
*
|
|
9
9
|
* The second step is what makes the service multi-tenant. Without it a valid
|
|
10
10
|
* key read every tenant's analytics, because the appId allowlist only ever
|
package/middleware/security.js
CHANGED
|
@@ -10,7 +10,7 @@ const { HTTP_STATUS } = require('../constants');
|
|
|
10
10
|
* agent-instructions SECURITY.md §9: `cors()` with no options is never the
|
|
11
11
|
* default. The wildcard mattered more here than the usual "no credentials, so
|
|
12
12
|
* it's harmless" reasoning suggests, because the read endpoints served real
|
|
13
|
-
* data
|
|
13
|
+
* data: `*` made them script-readable from any origin, not merely reachable.
|
|
14
14
|
*
|
|
15
15
|
* @param {string[]} allowedOrigins
|
|
16
16
|
* @returns {import('cors').CorsOptions}
|
|
@@ -32,6 +32,26 @@ function buildCorsOptions(allowedOrigins) {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Before a browser sends JSON to another site, it asks (a CORS preflight). A
|
|
37
|
+
* site missing from CORS_ORIGINS is refused there, and the request itself
|
|
38
|
+
* never arrives, so nothing else could count it. This counts the refusal, for
|
|
39
|
+
* the tracking-log paths `isTrackingPath` names, so a misconfigured
|
|
40
|
+
* CORS_ORIGINS shows up in the tracking log. Place it before `cors()`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} allowedOrigins as given to buildCorsOptions
|
|
43
|
+
* @param {{ isTrackingPath: (path: string) => boolean, onRefused: (req: import('express').Request) => void }} hooks
|
|
44
|
+
* @returns {import('express').RequestHandler}
|
|
45
|
+
*/
|
|
46
|
+
function countRefusedPreflights(allowedOrigins, { isTrackingPath, onRefused }) {
|
|
47
|
+
const allowlist = new Set(allowedOrigins);
|
|
48
|
+
return (req, res, next) => {
|
|
49
|
+
const origin = req.get('origin');
|
|
50
|
+
if (req.method === 'OPTIONS' && origin && !allowlist.has(origin) && isTrackingPath(req.path)) onRefused(req);
|
|
51
|
+
next();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
/**
|
|
36
56
|
* Extract the requesting origin, falling back to the referrer's origin.
|
|
37
57
|
* @returns {string|null}
|
|
@@ -64,9 +84,11 @@ function requestOrigin(req) {
|
|
|
64
84
|
* every appId still in that state.
|
|
65
85
|
*
|
|
66
86
|
* @param {{ origins: Record<string, string[]> }} allowed
|
|
87
|
+
* @param {{ onReject?: (req: import('express').Request, appId: string) => void }} [options]
|
|
88
|
+
* told about each refusal, so the tracking log can count it
|
|
67
89
|
* @returns {import('express').RequestHandler}
|
|
68
90
|
*/
|
|
69
|
-
function requireRegisteredOrigin(allowed) {
|
|
91
|
+
function requireRegisteredOrigin(allowed, { onReject = () => {} } = {}) {
|
|
70
92
|
const origins = allowed?.origins || {};
|
|
71
93
|
|
|
72
94
|
return (req, res, next) => {
|
|
@@ -82,6 +104,7 @@ function requireRegisteredOrigin(allowed) {
|
|
|
82
104
|
return next();
|
|
83
105
|
}
|
|
84
106
|
|
|
107
|
+
onReject(req, appId);
|
|
85
108
|
return res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
86
109
|
message: 'Request origin is not registered for this appId',
|
|
87
110
|
});
|
|
@@ -103,6 +126,7 @@ function noStore(req, res, next) {
|
|
|
103
126
|
|
|
104
127
|
module.exports = {
|
|
105
128
|
buildCorsOptions,
|
|
129
|
+
countRefusedPreflights,
|
|
106
130
|
requireRegisteredOrigin,
|
|
107
131
|
requestOrigin,
|
|
108
132
|
noStore,
|
package/middleware/validation.js
CHANGED
|
@@ -5,8 +5,12 @@ const {
|
|
|
5
5
|
HTTP_STATUS,
|
|
6
6
|
PAYLOAD_LIMITS,
|
|
7
7
|
QUERY_LIMITS,
|
|
8
|
+
REJECTION_REASON,
|
|
9
|
+
TRACKING,
|
|
8
10
|
TREND_PERIODS,
|
|
11
|
+
UUID_PATTERN,
|
|
9
12
|
} = require('../constants');
|
|
13
|
+
const { UTM_PARAMETERS } = require('../utils/visitorContext');
|
|
10
14
|
const { jsonByteLength } = require('../utils/stringUtils');
|
|
11
15
|
const { isValidAppId } = require('../utils/appIdUtils');
|
|
12
16
|
|
|
@@ -60,7 +64,7 @@ const boundedBody = (name, max) =>
|
|
|
60
64
|
*
|
|
61
65
|
* Deliberately no `.toInt()` sanitizer: under Express 5 `req.query` is a
|
|
62
66
|
* getter-only property, so a sanitizer appears to work but never writes the
|
|
63
|
-
* coerced value back
|
|
67
|
+
* coerced value back: the handler would still receive a string and bind it
|
|
64
68
|
* into `LIMIT ?`, which MySQL rejects. Handlers coerce explicitly instead,
|
|
65
69
|
* after this validator has established the value is a valid integer in range.
|
|
66
70
|
*/
|
|
@@ -83,6 +87,24 @@ const validateRegisterView = (allowedValues) => [
|
|
|
83
87
|
boundedQuery('title', FIELD_MAX_LENGTH.PAGE_TITLE),
|
|
84
88
|
boundedQuery('referrer', FIELD_MAX_LENGTH.REFERRER),
|
|
85
89
|
boundedQuery('sessionId', FIELD_MAX_LENGTH.SESSION_ID),
|
|
90
|
+
...UTM_PARAMETERS.map((name) => boundedQuery(name, FIELD_MAX_LENGTH.UTM)),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate an engagement beacon: how long a recorded view's page was visible
|
|
95
|
+
* and how far it was scrolled.
|
|
96
|
+
*/
|
|
97
|
+
const validateEngage = (allowedValues) => [
|
|
98
|
+
body('appId')
|
|
99
|
+
.notEmpty().withMessage('appId is required')
|
|
100
|
+
.custom((value) => allowedValues.appId.includes(value)).withMessage('Invalid appId'),
|
|
101
|
+
body('id')
|
|
102
|
+
.isString().withMessage('id must be a string')
|
|
103
|
+
.matches(UUID_PATTERN).withMessage('id must be a view ID'),
|
|
104
|
+
body('ms')
|
|
105
|
+
.isInt({ min: 0, max: TRACKING.MAX_ENGAGED_MS }).withMessage(`ms must be an integer between 0 and ${TRACKING.MAX_ENGAGED_MS}`),
|
|
106
|
+
body('scroll')
|
|
107
|
+
.isInt({ min: 0, max: 100 }).withMessage('scroll must be an integer between 0 and 100'),
|
|
86
108
|
];
|
|
87
109
|
|
|
88
110
|
/**
|
|
@@ -165,7 +187,7 @@ const validateSessionRequest = (allowedValues) => [
|
|
|
165
187
|
* Validate an app-provisioning request.
|
|
166
188
|
*
|
|
167
189
|
* `appId` here becomes a table identifier, so it is checked against the strict
|
|
168
|
-
* pattern rather than an allowlist
|
|
190
|
+
* pattern rather than an allowlist: there is no allowlist yet, that is the
|
|
169
191
|
* point of the call.
|
|
170
192
|
*/
|
|
171
193
|
const validateAppRegistration = () => [
|
|
@@ -199,14 +221,40 @@ const handleValidationErrors = (req, res, next) => {
|
|
|
199
221
|
return next();
|
|
200
222
|
};
|
|
201
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The same 422 as handleValidationErrors, for a tracking endpoint: the refusal
|
|
226
|
+
* is also reported, so the tracking log can say why a site's views are not
|
|
227
|
+
* arriving. An appId that was given but is not allowed is its own reason,
|
|
228
|
+
* since a misspelled one is the commonest cause; anything else, including a
|
|
229
|
+
* missing appId, names the first field that failed.
|
|
230
|
+
*
|
|
231
|
+
* @param {(req: import('express').Request, reason: string, details: { appId?: string, detail?: string }) => void} onReject
|
|
232
|
+
* @returns {import('express').RequestHandler}
|
|
233
|
+
*/
|
|
234
|
+
const handleTrackingValidation = (onReject) => (req, res, next) => {
|
|
235
|
+
const errors = validationResult(req);
|
|
236
|
+
if (errors.isEmpty()) return next();
|
|
237
|
+
|
|
238
|
+
const failed = errors.array();
|
|
239
|
+
const appIdError = failed.find((error) => error.path === 'appId');
|
|
240
|
+
if (appIdError && typeof appIdError.value === 'string' && appIdError.value !== '') {
|
|
241
|
+
onReject(req, REJECTION_REASON.UNKNOWN_APP, { appId: appIdError.value });
|
|
242
|
+
} else {
|
|
243
|
+
onReject(req, REJECTION_REASON.INVALID_REQUEST, { detail: (appIdError || failed[0]).path });
|
|
244
|
+
}
|
|
245
|
+
return handleValidationErrors(req, res, next);
|
|
246
|
+
};
|
|
247
|
+
|
|
202
248
|
module.exports = {
|
|
203
249
|
validateAppRegistration,
|
|
204
250
|
validateRegisterView,
|
|
205
251
|
validateEvent,
|
|
252
|
+
validateEngage,
|
|
206
253
|
validateStatsRequest,
|
|
207
254
|
validateTrendsRequest,
|
|
208
255
|
validateListRequest,
|
|
209
256
|
validateViewsRequest,
|
|
210
257
|
validateSessionRequest,
|
|
211
258
|
handleValidationErrors,
|
|
259
|
+
handleTrackingValidation,
|
|
212
260
|
};
|