@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
package/routes/admin.js
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin UI and its JSON API.
|
|
3
|
+
*
|
|
4
|
+
* Mounted at ADMIN.PATH_PREFIX, and only when ADMIN_PASSWORD is configured:
|
|
5
|
+
* with no password there is no route, so the surface does not exist rather
|
|
6
|
+
* than existing behind a check that could be got wrong.
|
|
7
|
+
*
|
|
8
|
+
* /admin/ the UI: static, build-free, no data of its own
|
|
9
|
+
* /admin/api/... session-authenticated JSON API
|
|
10
|
+
*
|
|
11
|
+
* Trust model: the static shell is public because it contains nothing; every
|
|
12
|
+
* API route except /session and /login requires a session, and every
|
|
13
|
+
* state-changing route also requires the session's CSRF token. Every mutation
|
|
14
|
+
* is recorded in the admin operation log.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const helmet = require('helmet');
|
|
20
|
+
const rateLimit = require('express-rate-limit');
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
ADMIN,
|
|
24
|
+
ADMIN_ACTION,
|
|
25
|
+
ADMIN_ERROR_CODE,
|
|
26
|
+
ADMIN_RANGE,
|
|
27
|
+
ADMIN_SORT_COLUMNS,
|
|
28
|
+
ANALYSIS,
|
|
29
|
+
EDITABLE_FIELDS,
|
|
30
|
+
FIELD_MAX_LENGTH,
|
|
31
|
+
HTTP_STATUS,
|
|
32
|
+
MODIFIED_FILTER,
|
|
33
|
+
REJECTION_REASON,
|
|
34
|
+
SORT_ORDER,
|
|
35
|
+
TRACKING_OUTCOME,
|
|
36
|
+
VIEW_LOG_SOURCE,
|
|
37
|
+
VIEW_STATUS,
|
|
38
|
+
} = require('../constants');
|
|
39
|
+
const logger = require('../utils/logger');
|
|
40
|
+
const { ErrorType, getError, logWarning, WarningType } = require('../utils/errorUtils');
|
|
41
|
+
const { noStore } = require('../middleware/security');
|
|
42
|
+
const {
|
|
43
|
+
createSessionStore,
|
|
44
|
+
requireAdminSession,
|
|
45
|
+
requireCsrf,
|
|
46
|
+
verifyPassword,
|
|
47
|
+
requireRecentPassword,
|
|
48
|
+
sessionCsrfToken,
|
|
49
|
+
cookieOptions,
|
|
50
|
+
originAllowed,
|
|
51
|
+
readToken,
|
|
52
|
+
} = require('../middleware/adminAuth');
|
|
53
|
+
const {
|
|
54
|
+
parseWhere,
|
|
55
|
+
validateLogin,
|
|
56
|
+
validateViewListing,
|
|
57
|
+
validateAnalysis,
|
|
58
|
+
validateEventTypes,
|
|
59
|
+
validateRealtime,
|
|
60
|
+
validateEdit,
|
|
61
|
+
validateNote,
|
|
62
|
+
validateBatch,
|
|
63
|
+
validateAdminLogListing,
|
|
64
|
+
validateTrackingLogListing,
|
|
65
|
+
validateTrackingSummary,
|
|
66
|
+
handleAdminValidation,
|
|
67
|
+
} = require('../middleware/adminValidation');
|
|
68
|
+
const { logContext, withRequestId } = require('./analytics');
|
|
69
|
+
const { attributions } = require('../utils/geoCity');
|
|
70
|
+
const { ACQUISITION_DIMENSIONS } = require('../db/analysis');
|
|
71
|
+
|
|
72
|
+
/** The UI's static files, shipped in the package. */
|
|
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;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Content Security Policy for the admin surface. Stricter than helmet's
|
|
79
|
+
* default: no inline script or style anywhere, no third-party origin at all
|
|
80
|
+
* (including fonts, which would disclose every admin's IP to the font host),
|
|
81
|
+
* and no framing.
|
|
82
|
+
*/
|
|
83
|
+
const ADMIN_CSP = {
|
|
84
|
+
defaultSrc: ["'self'"],
|
|
85
|
+
scriptSrc: ["'self'"],
|
|
86
|
+
styleSrc: ["'self'"],
|
|
87
|
+
imgSrc: ["'self'", 'data:'],
|
|
88
|
+
fontSrc: ["'self'"],
|
|
89
|
+
connectSrc: ["'self'"],
|
|
90
|
+
objectSrc: ["'none'"],
|
|
91
|
+
baseUri: ["'none'"],
|
|
92
|
+
formAction: ["'self'"],
|
|
93
|
+
frameAncestors: ["'none'"],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Read a validated integer query parameter, or its default. */
|
|
97
|
+
function intParam(req, name, fallback) {
|
|
98
|
+
const parsed = Number.parseInt(req.query[name], 10);
|
|
99
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The filters a listing and its analysis share, with defaults applied. */
|
|
103
|
+
function filterQuery(req) {
|
|
104
|
+
return {
|
|
105
|
+
status: req.query.status || VIEW_STATUS.ACTIVE,
|
|
106
|
+
modified: req.query.modified || MODIFIED_FILTER.ANY,
|
|
107
|
+
range: req.query.range || ADMIN_RANGE.ALL,
|
|
108
|
+
search: req.query.search || '',
|
|
109
|
+
eventType: req.query.eventType || '',
|
|
110
|
+
// Validated already; parsed again here since Express 5 recomputes req.query.
|
|
111
|
+
where: parseWhere(req.query.where),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Stable failure response; the detail stays in the server log. */
|
|
116
|
+
function adminError(req, res, error, operation) {
|
|
117
|
+
logger.error(`admin ${operation} failed: ${error.message}`, logContext(req));
|
|
118
|
+
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
|
|
119
|
+
code: ADMIN_ERROR_CODE.SERVER_ERROR,
|
|
120
|
+
requestId: req.id,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {{ config: object, adminRepo: object, logRepo: object,
|
|
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
|
|
129
|
+
* @returns {import('express').Router}
|
|
130
|
+
*/
|
|
131
|
+
function createAdminRouter({
|
|
132
|
+
config,
|
|
133
|
+
adminRepo,
|
|
134
|
+
logRepo,
|
|
135
|
+
sessionStore = createSessionStore({ idleMs: config.admin.sessionIdleMs, absoluteMs: config.admin.sessionMaxAgeMs }),
|
|
136
|
+
isReady = () => true,
|
|
137
|
+
uiDir = ADMIN_UI_DIR,
|
|
138
|
+
geo = { city: null },
|
|
139
|
+
}) {
|
|
140
|
+
// The standalone server checks this when it loads its config; an app
|
|
141
|
+
// embedding the router gets the same floor, not a weaker admin surface.
|
|
142
|
+
const password = config?.admin?.password;
|
|
143
|
+
if (typeof password !== 'string' || password.length < ADMIN.MIN_PASSWORD_LENGTH) {
|
|
144
|
+
throw getError(ErrorType.CONFIG_INVALID_VALUE, {
|
|
145
|
+
field: 'admin.password',
|
|
146
|
+
reason: `must be at least ${ADMIN.MIN_PASSWORD_LENGTH} characters`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const router = express.Router();
|
|
151
|
+
|
|
152
|
+
router.use(withRequestId);
|
|
153
|
+
// Where this router is mounted ('/admin' in the server, anything when
|
|
154
|
+
// another app embeds it): the session cookie is scoped to it.
|
|
155
|
+
router.use((req, res, next) => {
|
|
156
|
+
req.adminBasePath = req.baseUrl || '/';
|
|
157
|
+
next();
|
|
158
|
+
});
|
|
159
|
+
router.use(helmet.contentSecurityPolicy({ useDefaults: false, directives: ADMIN_CSP }));
|
|
160
|
+
router.use((req, res, next) => {
|
|
161
|
+
res.set('X-Robots-Tag', 'noindex, nofollow');
|
|
162
|
+
res.set('Referrer-Policy', 'no-referrer');
|
|
163
|
+
next();
|
|
164
|
+
});
|
|
165
|
+
router.use(rateLimit({
|
|
166
|
+
windowMs: ADMIN.RATE_LIMIT_WINDOW_MS,
|
|
167
|
+
limit: ADMIN.RATE_LIMIT_MAX,
|
|
168
|
+
standardHeaders: true,
|
|
169
|
+
legacyHeaders: false,
|
|
170
|
+
handler: (req, res) => res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({ code: ADMIN_ERROR_CODE.RATE_LIMITED }),
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
router.use(ADMIN.API_PATH, createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady, geo }));
|
|
174
|
+
|
|
175
|
+
// Relative asset URLs in the UI only resolve under a trailing slash.
|
|
176
|
+
router.get('/', (req, res, next) => {
|
|
177
|
+
const [pathname, search = ''] = req.originalUrl.split('?');
|
|
178
|
+
if (pathname.endsWith('/')) return next();
|
|
179
|
+
return res.redirect(`${pathname}/${search ? `?${search}` : ''}`);
|
|
180
|
+
});
|
|
181
|
+
router.use(express.static(uiDir, { index: 'index.html', dotfiles: 'ignore', redirect: false }));
|
|
182
|
+
|
|
183
|
+
return router;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The JSON API behind the UI.
|
|
188
|
+
*/
|
|
189
|
+
function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady, geo }) {
|
|
190
|
+
const api = express.Router();
|
|
191
|
+
const allowed = config.allowed;
|
|
192
|
+
|
|
193
|
+
api.use(express.json({ limit: ADMIN.MAX_BODY_BYTES }));
|
|
194
|
+
api.use(noStore);
|
|
195
|
+
api.use((req, res, next) => {
|
|
196
|
+
if (isReady()) return next();
|
|
197
|
+
return res.status(HTTP_STATUS.SERVICE_UNAVAILABLE).json({ code: ADMIN_ERROR_CODE.SERVER_ERROR });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const requireSession = requireAdminSession(sessionStore);
|
|
201
|
+
const csrf = requireCsrf();
|
|
202
|
+
const authed = [requireSession, csrf];
|
|
203
|
+
|
|
204
|
+
/** Everything an admin log entry needs from the request. */
|
|
205
|
+
const actor = (req) => ({
|
|
206
|
+
sessionId: req.adminSession?.id ?? null,
|
|
207
|
+
maskedIp: logContext(req).ip,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const record = async (req, action, fields = {}) => {
|
|
211
|
+
logger.audit(`admin:${action}`, { ...logContext(req), actor: req.adminSession?.id, appId: fields.appId });
|
|
212
|
+
await logRepo.writeAdminLog({ action, ...actor(req), ...fields });
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ---- Session ----------------------------------------------------------
|
|
216
|
+
|
|
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
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const loginLimiter = rateLimit({
|
|
229
|
+
windowMs: ADMIN.LOGIN_RATE_LIMIT_WINDOW_MS,
|
|
230
|
+
limit: ADMIN.LOGIN_RATE_LIMIT_MAX,
|
|
231
|
+
// Only a wrong password is a guess. A refused origin or a malformed
|
|
232
|
+
// body never reaches the password check, so it must not lock the
|
|
233
|
+
// admin out (a misconfigured proxy would otherwise do exactly that).
|
|
234
|
+
skipSuccessfulRequests: true,
|
|
235
|
+
requestWasSuccessful: (req, res) => res.statusCode !== HTTP_STATUS.UNAUTHORIZED,
|
|
236
|
+
standardHeaders: true,
|
|
237
|
+
legacyHeaders: false,
|
|
238
|
+
handler: (req, res) => res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({ code: ADMIN_ERROR_CODE.TOO_MANY_ATTEMPTS }),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
api.post('/login', loginLimiter, validateLogin(), handleAdminValidation, async (req, res) => {
|
|
242
|
+
try {
|
|
243
|
+
if (!originAllowed(req)) {
|
|
244
|
+
return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!verifyPassword(req.body.password, config.admin.password)) {
|
|
248
|
+
await record(req, ADMIN_ACTION.LOGIN_FAILED);
|
|
249
|
+
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.INVALID_PASSWORD });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!req.secure && config.server?.isProduction) {
|
|
253
|
+
logWarning(WarningType.ADMIN_INSECURE_TRANSPORT);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// A fresh token on every login; any previous session in this
|
|
257
|
+
// browser is ended rather than left valid alongside the new one.
|
|
258
|
+
await sessionStore.destroy(readToken(req));
|
|
259
|
+
const { token, session } = await sessionStore.create();
|
|
260
|
+
res.cookie(ADMIN.SESSION_COOKIE, token, cookieOptions(req, sessionStore.absoluteMs));
|
|
261
|
+
|
|
262
|
+
req.adminSession = session;
|
|
263
|
+
await record(req, ADMIN_ACTION.LOGIN_SUCCEEDED);
|
|
264
|
+
return res.json({ authenticated: true, csrfToken: sessionCsrfToken(token) });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return adminError(req, res, error, 'login');
|
|
267
|
+
}
|
|
268
|
+
});
|
|
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
|
+
|
|
286
|
+
api.post('/logout', authed, async (req, res) => {
|
|
287
|
+
try {
|
|
288
|
+
await record(req, ADMIN_ACTION.LOGOUT);
|
|
289
|
+
await sessionStore.destroy(req.adminToken);
|
|
290
|
+
res.clearCookie(ADMIN.SESSION_COOKIE, { ...cookieOptions(req), maxAge: undefined });
|
|
291
|
+
return res.status(HTTP_STATUS.NO_CONTENT).end();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return adminError(req, res, error, 'logout');
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ---- Reference data ---------------------------------------------------
|
|
298
|
+
|
|
299
|
+
api.get('/meta', requireSession, (req, res) => {
|
|
300
|
+
res.json({
|
|
301
|
+
version: PACKAGE_VERSION,
|
|
302
|
+
deviceSizes: allowed.deviceSize,
|
|
303
|
+
editableFields: Object.keys(EDITABLE_FIELDS),
|
|
304
|
+
sortFields: Object.keys(ADMIN_SORT_COLUMNS),
|
|
305
|
+
statuses: Object.values(VIEW_STATUS),
|
|
306
|
+
ranges: Object.values(ADMIN_RANGE),
|
|
307
|
+
modifiedFilters: Object.values(MODIFIED_FILTER),
|
|
308
|
+
actions: Object.values(ADMIN_ACTION),
|
|
309
|
+
sources: Object.values(VIEW_LOG_SOURCE),
|
|
310
|
+
outcomes: Object.values(TRACKING_OUTCOME),
|
|
311
|
+
rejectionReasons: Object.values(REJECTION_REASON),
|
|
312
|
+
maxBatchIds: ADMIN.MAX_BATCH_IDS,
|
|
313
|
+
pageSizes: ADMIN.PAGE_SIZES,
|
|
314
|
+
pageSizeDefault: ADMIN.PAGE_SIZE_DEFAULT,
|
|
315
|
+
searchMaxLength: ADMIN.SEARCH_MAX_LENGTH,
|
|
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),
|
|
325
|
+
viewLogRetentionDays: config.admin.viewLogRetentionDays,
|
|
326
|
+
maxLength: {
|
|
327
|
+
note: FIELD_MAX_LENGTH.NOTE,
|
|
328
|
+
pagePath: FIELD_MAX_LENGTH.PAGE_PATH,
|
|
329
|
+
pageTitle: FIELD_MAX_LENGTH.PAGE_TITLE,
|
|
330
|
+
referrer: FIELD_MAX_LENGTH.REFERRER,
|
|
331
|
+
eventType: FIELD_MAX_LENGTH.EVENT_TYPE,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
api.get('/apps', requireSession, async (req, res) => {
|
|
337
|
+
try {
|
|
338
|
+
const apps = await adminRepo.summarizeApps(allowed.appId);
|
|
339
|
+
return res.json({ apps });
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return adminError(req, res, error, 'list apps');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---- Views ------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
const listingQuery = (req) => ({
|
|
348
|
+
...filterQuery(req),
|
|
349
|
+
sort: req.query.sort || 'timestamp',
|
|
350
|
+
order: req.query.order || SORT_ORDER.DESC,
|
|
351
|
+
page: intParam(req, 'page', 1),
|
|
352
|
+
pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
api.get('/apps/:appId/views', requireSession, validateViewListing(allowed), handleAdminValidation,
|
|
356
|
+
async (req, res) => {
|
|
357
|
+
try {
|
|
358
|
+
const query = listingQuery(req);
|
|
359
|
+
const result = await adminRepo.listViews([req.params.appId], query);
|
|
360
|
+
return res.json({ appId: req.params.appId, ...query, ...result });
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return adminError(req, res, error, 'list views');
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/** Every app at once. An app whose table is missing is left out, not fatal. */
|
|
367
|
+
api.get('/views', requireSession, validateViewListing(), handleAdminValidation, async (req, res) => {
|
|
368
|
+
try {
|
|
369
|
+
const query = listingQuery(req);
|
|
370
|
+
const apps = await adminRepo.existingTables(allowed.appId);
|
|
371
|
+
const result = await adminRepo.listViews(apps, query);
|
|
372
|
+
return res.json({ apps, ...query, ...result });
|
|
373
|
+
} catch (error) {
|
|
374
|
+
return adminError(req, res, error, 'list all views');
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
api.get('/apps/:appId/analytics', requireSession, validateAnalysis(allowed), handleAdminValidation,
|
|
379
|
+
async (req, res) => {
|
|
380
|
+
try {
|
|
381
|
+
const query = filterQuery(req);
|
|
382
|
+
const result = await adminRepo.analyze([req.params.appId], query);
|
|
383
|
+
return res.json({ apps: [req.params.appId], ...query, ...result });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return adminError(req, res, error, 'analyse views');
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
api.get('/analytics', requireSession, validateAnalysis(), handleAdminValidation, async (req, res) => {
|
|
390
|
+
try {
|
|
391
|
+
const query = filterQuery(req);
|
|
392
|
+
const apps = await adminRepo.existingTables(allowed.appId);
|
|
393
|
+
const result = await adminRepo.analyze(apps, query);
|
|
394
|
+
return res.json({ apps, ...query, ...result });
|
|
395
|
+
} catch (error) {
|
|
396
|
+
return adminError(req, res, error, 'analyse all views');
|
|
397
|
+
}
|
|
398
|
+
});
|
|
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
|
+
|
|
440
|
+
api.patch('/apps/:appId/views', authed, validateEdit(allowed), handleAdminValidation, async (req, res) => {
|
|
441
|
+
try {
|
|
442
|
+
const { appId } = req.params;
|
|
443
|
+
const { columns, fields } = req.resolvedChanges;
|
|
444
|
+
const changed = await adminRepo.updateContent(appId, req.body.ids, columns);
|
|
445
|
+
if (changed.length) await record(req, ADMIN_ACTION.VIEWS_EDITED, { appId, targetIds: changed, fields });
|
|
446
|
+
return res.json({ affected: changed.length, ids: changed });
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return adminError(req, res, error, 'edit views');
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
api.put('/apps/:appId/views/note', authed, validateNote(allowed), handleAdminValidation, async (req, res) => {
|
|
453
|
+
try {
|
|
454
|
+
const { appId } = req.params;
|
|
455
|
+
const note = req.body.note && req.body.note.trim() ? req.body.note : null;
|
|
456
|
+
const changed = await adminRepo.setNote(appId, req.body.ids, note);
|
|
457
|
+
const action = note ? ADMIN_ACTION.NOTE_SET : ADMIN_ACTION.NOTE_CLEARED;
|
|
458
|
+
if (changed.length) await record(req, action, { appId, targetIds: changed });
|
|
459
|
+
return res.json({ affected: changed.length, ids: changed });
|
|
460
|
+
} catch (error) {
|
|
461
|
+
return adminError(req, res, error, 'set note');
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/** delete / restore / purge share one shape: IDs in, changed IDs out. */
|
|
466
|
+
const batchRoute = (suffix, operation, action, guards = []) => {
|
|
467
|
+
api.post(`/apps/:appId/views/${suffix}`, authed, ...guards, validateBatch(allowed), handleAdminValidation,
|
|
468
|
+
async (req, res) => {
|
|
469
|
+
try {
|
|
470
|
+
const { appId } = req.params;
|
|
471
|
+
const changed = await adminRepo[operation](appId, req.body.ids);
|
|
472
|
+
if (changed.length) await record(req, action, { appId, targetIds: changed });
|
|
473
|
+
return res.json({ affected: changed.length, ids: changed });
|
|
474
|
+
} catch (error) {
|
|
475
|
+
return adminError(req, res, error, operation);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
batchRoute('delete', 'softDelete', ADMIN_ACTION.VIEWS_DELETED);
|
|
481
|
+
batchRoute('restore', 'restore', ADMIN_ACTION.VIEWS_RESTORED);
|
|
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()]);
|
|
485
|
+
|
|
486
|
+
// ---- Logs -------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
api.get('/logs/admin', requireSession, validateAdminLogListing(allowed), handleAdminValidation,
|
|
489
|
+
async (req, res) => {
|
|
490
|
+
try {
|
|
491
|
+
const result = await logRepo.listAdminLog({
|
|
492
|
+
page: intParam(req, 'page', 1),
|
|
493
|
+
pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
|
|
494
|
+
action: req.query.action,
|
|
495
|
+
appId: req.query.appId,
|
|
496
|
+
});
|
|
497
|
+
return res.json(result);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return adminError(req, res, error, 'list admin log');
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// The tracking log: every tracking request and what became of it.
|
|
504
|
+
api.get('/logs/tracking', requireSession, validateTrackingLogListing(allowed), handleAdminValidation,
|
|
505
|
+
async (req, res) => {
|
|
506
|
+
try {
|
|
507
|
+
const result = await logRepo.listTrackingLog({
|
|
508
|
+
page: intParam(req, 'page', 1),
|
|
509
|
+
pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
|
|
510
|
+
appId: req.query.appId,
|
|
511
|
+
source: req.query.source,
|
|
512
|
+
outcome: req.query.outcome,
|
|
513
|
+
});
|
|
514
|
+
return res.json(result);
|
|
515
|
+
} catch (error) {
|
|
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');
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
api.use((req, res) => res.status(HTTP_STATUS.NOT_FOUND).json({ code: ADMIN_ERROR_CODE.NOT_FOUND }));
|
|
530
|
+
|
|
531
|
+
// Malformed or oversized JSON bodies, in the admin error shape.
|
|
532
|
+
// eslint-disable-next-line no-unused-vars
|
|
533
|
+
api.use((err, req, res, next) => {
|
|
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');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
return api;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
module.exports = { createAdminRouter, ADMIN_UI_DIR, ADMIN_CSP };
|