@harshankur/viewcounter 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.env.example +20 -0
  2. package/README.md +174 -4
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +1875 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +89 -0
  11. package/admin/js/api.js +100 -0
  12. package/admin/js/charts.js +502 -0
  13. package/admin/js/clamp.js +41 -0
  14. package/admin/js/constants.js +150 -0
  15. package/admin/js/dom.js +83 -0
  16. package/admin/js/format.js +79 -0
  17. package/admin/js/i18n.js +80 -0
  18. package/admin/js/insights.js +192 -0
  19. package/admin/js/listbox.js +144 -0
  20. package/admin/js/logs.js +167 -0
  21. package/admin/js/main.js +235 -0
  22. package/admin/js/modal.js +171 -0
  23. package/admin/js/table.js +134 -0
  24. package/admin/js/theme.js +72 -0
  25. package/admin/js/toast.js +47 -0
  26. package/admin/js/viewDialogs.js +208 -0
  27. package/admin/js/views.js +685 -0
  28. package/admin/locales/en.json +394 -0
  29. package/admin/site.webmanifest +20 -0
  30. package/config/index.js +83 -0
  31. package/constants.js +215 -2
  32. package/db/AdminRepository.js +562 -0
  33. package/db/DatabaseManager.js +94 -20
  34. package/db/LogRepository.js +217 -0
  35. package/db/adminSchema.js +244 -0
  36. package/db/retention.js +97 -0
  37. package/index.js +39 -6
  38. package/middleware/adminAuth.js +204 -0
  39. package/middleware/adminValidation.js +253 -0
  40. package/package.json +16 -9
  41. package/routes/admin.js +438 -0
  42. package/routes/analytics.js +11 -2
  43. package/utils/cookieUtils.js +47 -0
  44. package/utils/errorUtils.js +35 -0
@@ -0,0 +1,438 @@
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
+ EDITABLE_FIELDS,
29
+ FIELD_MAX_LENGTH,
30
+ HTTP_STATUS,
31
+ MODIFIED_FILTER,
32
+ SORT_ORDER,
33
+ VIEW_LOG_SOURCE,
34
+ VIEW_STATUS,
35
+ } = require('../constants');
36
+ const logger = require('../utils/logger');
37
+ const { ErrorType, getError, logWarning, WarningType } = require('../utils/errorUtils');
38
+ const { noStore } = require('../middleware/security');
39
+ const {
40
+ createSessionStore,
41
+ requireAdminSession,
42
+ requireCsrf,
43
+ verifyPassword,
44
+ cookieOptions,
45
+ originAllowed,
46
+ readToken,
47
+ } = require('../middleware/adminAuth');
48
+ const {
49
+ validateLogin,
50
+ validateViewListing,
51
+ validateAnalysis,
52
+ validateEdit,
53
+ validateNote,
54
+ validateBatch,
55
+ validateAdminLogListing,
56
+ validateViewLogListing,
57
+ handleAdminValidation,
58
+ } = require('../middleware/adminValidation');
59
+ const { logContext, withRequestId } = require('./analytics');
60
+
61
+ /** The UI's static files, shipped in the package. */
62
+ const ADMIN_UI_DIR = path.join(__dirname, '..', 'admin');
63
+
64
+ /**
65
+ * Content Security Policy for the admin surface. Stricter than helmet's
66
+ * default: no inline script or style anywhere, no third-party origin at all
67
+ * (including fonts, which would disclose every admin's IP to the font host),
68
+ * and no framing.
69
+ */
70
+ const ADMIN_CSP = {
71
+ defaultSrc: ["'self'"],
72
+ scriptSrc: ["'self'"],
73
+ styleSrc: ["'self'"],
74
+ imgSrc: ["'self'", 'data:'],
75
+ fontSrc: ["'self'"],
76
+ connectSrc: ["'self'"],
77
+ objectSrc: ["'none'"],
78
+ baseUri: ["'none'"],
79
+ formAction: ["'self'"],
80
+ frameAncestors: ["'none'"],
81
+ };
82
+
83
+ /** Read a validated integer query parameter, or its default. */
84
+ function intParam(req, name, fallback) {
85
+ const parsed = Number.parseInt(req.query[name], 10);
86
+ return Number.isFinite(parsed) ? parsed : fallback;
87
+ }
88
+
89
+ /** The filters a listing and its analysis share, with defaults applied. */
90
+ function filterQuery(req) {
91
+ return {
92
+ status: req.query.status || VIEW_STATUS.ACTIVE,
93
+ modified: req.query.modified || MODIFIED_FILTER.ANY,
94
+ range: req.query.range || ADMIN_RANGE.ALL,
95
+ search: req.query.search || '',
96
+ eventType: req.query.eventType || '',
97
+ };
98
+ }
99
+
100
+ /** Stable failure response; the detail stays in the server log. */
101
+ function adminError(req, res, error, operation) {
102
+ logger.error(`admin ${operation} failed: ${error.message}`, logContext(req));
103
+ return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
104
+ code: ADMIN_ERROR_CODE.SERVER_ERROR,
105
+ requestId: req.id,
106
+ });
107
+ }
108
+
109
+ /**
110
+ * @param {{ config: object, adminRepo: object, logRepo: object,
111
+ * sessionStore?: object, isReady?: () => boolean, uiDir?: string }} deps
112
+ * @returns {import('express').Router}
113
+ */
114
+ function createAdminRouter({
115
+ config,
116
+ adminRepo,
117
+ logRepo,
118
+ sessionStore = createSessionStore(),
119
+ isReady = () => true,
120
+ uiDir = ADMIN_UI_DIR,
121
+ }) {
122
+ // The standalone server checks this when it loads its config; an app
123
+ // embedding the router gets the same floor, not a weaker admin surface.
124
+ const password = config?.admin?.password;
125
+ if (typeof password !== 'string' || password.length < ADMIN.MIN_PASSWORD_LENGTH) {
126
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, {
127
+ field: 'admin.password',
128
+ reason: `must be at least ${ADMIN.MIN_PASSWORD_LENGTH} characters`,
129
+ });
130
+ }
131
+
132
+ const router = express.Router();
133
+
134
+ router.use(withRequestId);
135
+ // Where this router is mounted ('/admin' in the server, anything when
136
+ // another app embeds it): the session cookie is scoped to it.
137
+ router.use((req, res, next) => {
138
+ req.adminBasePath = req.baseUrl || '/';
139
+ next();
140
+ });
141
+ router.use(helmet.contentSecurityPolicy({ useDefaults: false, directives: ADMIN_CSP }));
142
+ router.use((req, res, next) => {
143
+ res.set('X-Robots-Tag', 'noindex, nofollow');
144
+ res.set('Referrer-Policy', 'no-referrer');
145
+ next();
146
+ });
147
+ router.use(rateLimit({
148
+ windowMs: ADMIN.RATE_LIMIT_WINDOW_MS,
149
+ limit: ADMIN.RATE_LIMIT_MAX,
150
+ standardHeaders: true,
151
+ legacyHeaders: false,
152
+ handler: (req, res) => res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({ code: ADMIN_ERROR_CODE.RATE_LIMITED }),
153
+ }));
154
+
155
+ router.use(ADMIN.API_PATH, createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }));
156
+
157
+ // Relative asset URLs in the UI only resolve under a trailing slash.
158
+ router.get('/', (req, res, next) => {
159
+ const [pathname, search = ''] = req.originalUrl.split('?');
160
+ if (pathname.endsWith('/')) return next();
161
+ return res.redirect(`${pathname}/${search ? `?${search}` : ''}`);
162
+ });
163
+ router.use(express.static(uiDir, { index: 'index.html', dotfiles: 'ignore', redirect: false }));
164
+
165
+ return router;
166
+ }
167
+
168
+ /**
169
+ * The JSON API behind the UI.
170
+ */
171
+ function createAdminApi({ config, adminRepo, logRepo, sessionStore, isReady }) {
172
+ const api = express.Router();
173
+ const allowed = config.allowed;
174
+
175
+ api.use(express.json({ limit: ADMIN.MAX_BODY_BYTES }));
176
+ api.use(noStore);
177
+ api.use((req, res, next) => {
178
+ if (isReady()) return next();
179
+ return res.status(HTTP_STATUS.SERVICE_UNAVAILABLE).json({ code: ADMIN_ERROR_CODE.SERVER_ERROR });
180
+ });
181
+
182
+ const requireSession = requireAdminSession(sessionStore);
183
+ const csrf = requireCsrf();
184
+ const authed = [requireSession, csrf];
185
+
186
+ /** Everything an admin log entry needs from the request. */
187
+ const actor = (req) => ({
188
+ sessionId: req.adminSession?.id ?? null,
189
+ maskedIp: logContext(req).ip,
190
+ });
191
+
192
+ const record = async (req, action, fields = {}) => {
193
+ logger.audit(`admin:${action}`, { ...logContext(req), actor: req.adminSession?.id, appId: fields.appId });
194
+ await logRepo.writeAdminLog({ action, ...actor(req), ...fields });
195
+ };
196
+
197
+ // ---- Session ----------------------------------------------------------
198
+
199
+ api.get('/session', (req, res) => {
200
+ const session = sessionStore.get(readToken(req));
201
+ if (!session) return res.json({ authenticated: false });
202
+ return res.json({ authenticated: true, csrfToken: session.csrfToken });
203
+ });
204
+
205
+ const loginLimiter = rateLimit({
206
+ windowMs: ADMIN.LOGIN_RATE_LIMIT_WINDOW_MS,
207
+ limit: ADMIN.LOGIN_RATE_LIMIT_MAX,
208
+ // Only a wrong password is a guess. A refused origin or a malformed
209
+ // body never reaches the password check, so it must not lock the
210
+ // admin out (a misconfigured proxy would otherwise do exactly that).
211
+ skipSuccessfulRequests: true,
212
+ requestWasSuccessful: (req, res) => res.statusCode !== HTTP_STATUS.UNAUTHORIZED,
213
+ standardHeaders: true,
214
+ legacyHeaders: false,
215
+ handler: (req, res) => res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({ code: ADMIN_ERROR_CODE.TOO_MANY_ATTEMPTS }),
216
+ });
217
+
218
+ api.post('/login', loginLimiter, validateLogin(), handleAdminValidation, async (req, res) => {
219
+ try {
220
+ if (!originAllowed(req)) {
221
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
222
+ }
223
+
224
+ if (!verifyPassword(req.body.password, config.admin.password)) {
225
+ await record(req, ADMIN_ACTION.LOGIN_FAILED);
226
+ return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.INVALID_PASSWORD });
227
+ }
228
+
229
+ if (!req.secure && config.server?.isProduction) {
230
+ logWarning(WarningType.ADMIN_INSECURE_TRANSPORT);
231
+ }
232
+
233
+ // A fresh token on every login; any previous session in this
234
+ // browser is ended rather than left valid alongside the new one.
235
+ sessionStore.destroy(readToken(req));
236
+ const { token, session } = sessionStore.create();
237
+ res.cookie(ADMIN.SESSION_COOKIE, token, cookieOptions(req));
238
+
239
+ req.adminSession = session;
240
+ await record(req, ADMIN_ACTION.LOGIN_SUCCEEDED);
241
+ return res.json({ authenticated: true, csrfToken: session.csrfToken });
242
+ } catch (error) {
243
+ return adminError(req, res, error, 'login');
244
+ }
245
+ });
246
+
247
+ api.post('/logout', authed, async (req, res) => {
248
+ try {
249
+ await record(req, ADMIN_ACTION.LOGOUT);
250
+ sessionStore.destroy(req.adminToken);
251
+ res.clearCookie(ADMIN.SESSION_COOKIE, { ...cookieOptions(req), maxAge: undefined });
252
+ return res.status(HTTP_STATUS.NO_CONTENT).end();
253
+ } catch (error) {
254
+ return adminError(req, res, error, 'logout');
255
+ }
256
+ });
257
+
258
+ // ---- Reference data ---------------------------------------------------
259
+
260
+ api.get('/meta', requireSession, (req, res) => {
261
+ res.json({
262
+ deviceSizes: allowed.deviceSize,
263
+ editableFields: Object.keys(EDITABLE_FIELDS),
264
+ sortFields: Object.keys(ADMIN_SORT_COLUMNS),
265
+ statuses: Object.values(VIEW_STATUS),
266
+ ranges: Object.values(ADMIN_RANGE),
267
+ modifiedFilters: Object.values(MODIFIED_FILTER),
268
+ actions: Object.values(ADMIN_ACTION),
269
+ sources: Object.values(VIEW_LOG_SOURCE),
270
+ maxBatchIds: ADMIN.MAX_BATCH_IDS,
271
+ pageSizes: ADMIN.PAGE_SIZES,
272
+ pageSizeDefault: ADMIN.PAGE_SIZE_DEFAULT,
273
+ searchMaxLength: ADMIN.SEARCH_MAX_LENGTH,
274
+ trashRetentionDays: config.admin.trashRetentionDays,
275
+ viewLogRetentionDays: config.admin.viewLogRetentionDays,
276
+ maxLength: {
277
+ note: FIELD_MAX_LENGTH.NOTE,
278
+ pagePath: FIELD_MAX_LENGTH.PAGE_PATH,
279
+ pageTitle: FIELD_MAX_LENGTH.PAGE_TITLE,
280
+ referrer: FIELD_MAX_LENGTH.REFERRER,
281
+ eventType: FIELD_MAX_LENGTH.EVENT_TYPE,
282
+ },
283
+ });
284
+ });
285
+
286
+ api.get('/apps', requireSession, async (req, res) => {
287
+ try {
288
+ const apps = await adminRepo.summarizeApps(allowed.appId);
289
+ return res.json({ apps });
290
+ } catch (error) {
291
+ return adminError(req, res, error, 'list apps');
292
+ }
293
+ });
294
+
295
+ // ---- Views ------------------------------------------------------------
296
+
297
+ const listingQuery = (req) => ({
298
+ ...filterQuery(req),
299
+ sort: req.query.sort || 'timestamp',
300
+ order: req.query.order || SORT_ORDER.DESC,
301
+ page: intParam(req, 'page', 1),
302
+ pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
303
+ });
304
+
305
+ api.get('/apps/:appId/views', requireSession, validateViewListing(allowed), handleAdminValidation,
306
+ async (req, res) => {
307
+ try {
308
+ const query = listingQuery(req);
309
+ const result = await adminRepo.listViews([req.params.appId], query);
310
+ return res.json({ appId: req.params.appId, ...query, ...result });
311
+ } catch (error) {
312
+ return adminError(req, res, error, 'list views');
313
+ }
314
+ });
315
+
316
+ /** Every app at once. An app whose table is missing is left out, not fatal. */
317
+ api.get('/views', requireSession, validateViewListing(), handleAdminValidation, async (req, res) => {
318
+ try {
319
+ const query = listingQuery(req);
320
+ const apps = await adminRepo.existingTables(allowed.appId);
321
+ const result = await adminRepo.listViews(apps, query);
322
+ return res.json({ apps, ...query, ...result });
323
+ } catch (error) {
324
+ return adminError(req, res, error, 'list all views');
325
+ }
326
+ });
327
+
328
+ api.get('/apps/:appId/analytics', requireSession, validateAnalysis(allowed), handleAdminValidation,
329
+ async (req, res) => {
330
+ try {
331
+ const query = filterQuery(req);
332
+ const result = await adminRepo.analyze([req.params.appId], query);
333
+ return res.json({ apps: [req.params.appId], ...query, ...result });
334
+ } catch (error) {
335
+ return adminError(req, res, error, 'analyse views');
336
+ }
337
+ });
338
+
339
+ api.get('/analytics', requireSession, validateAnalysis(), handleAdminValidation, async (req, res) => {
340
+ try {
341
+ const query = filterQuery(req);
342
+ const apps = await adminRepo.existingTables(allowed.appId);
343
+ const result = await adminRepo.analyze(apps, query);
344
+ return res.json({ apps, ...query, ...result });
345
+ } catch (error) {
346
+ return adminError(req, res, error, 'analyse all views');
347
+ }
348
+ });
349
+
350
+ api.patch('/apps/:appId/views', authed, validateEdit(allowed), handleAdminValidation, async (req, res) => {
351
+ try {
352
+ const { appId } = req.params;
353
+ const { columns, fields } = req.resolvedChanges;
354
+ const changed = await adminRepo.updateContent(appId, req.body.ids, columns);
355
+ if (changed.length) await record(req, ADMIN_ACTION.VIEWS_EDITED, { appId, targetIds: changed, fields });
356
+ return res.json({ affected: changed.length, ids: changed });
357
+ } catch (error) {
358
+ return adminError(req, res, error, 'edit views');
359
+ }
360
+ });
361
+
362
+ api.put('/apps/:appId/views/note', authed, validateNote(allowed), handleAdminValidation, async (req, res) => {
363
+ try {
364
+ const { appId } = req.params;
365
+ const note = req.body.note && req.body.note.trim() ? req.body.note : null;
366
+ const changed = await adminRepo.setNote(appId, req.body.ids, note);
367
+ const action = note ? ADMIN_ACTION.NOTE_SET : ADMIN_ACTION.NOTE_CLEARED;
368
+ if (changed.length) await record(req, action, { appId, targetIds: changed });
369
+ return res.json({ affected: changed.length, ids: changed });
370
+ } catch (error) {
371
+ return adminError(req, res, error, 'set note');
372
+ }
373
+ });
374
+
375
+ /** delete / restore / purge share one shape: IDs in, changed IDs out. */
376
+ const batchRoute = (suffix, operation, action) => {
377
+ api.post(`/apps/:appId/views/${suffix}`, authed, validateBatch(allowed), handleAdminValidation,
378
+ async (req, res) => {
379
+ try {
380
+ const { appId } = req.params;
381
+ const changed = await adminRepo[operation](appId, req.body.ids);
382
+ if (changed.length) await record(req, action, { appId, targetIds: changed });
383
+ return res.json({ affected: changed.length, ids: changed });
384
+ } catch (error) {
385
+ return adminError(req, res, error, operation);
386
+ }
387
+ });
388
+ };
389
+
390
+ batchRoute('delete', 'softDelete', ADMIN_ACTION.VIEWS_DELETED);
391
+ batchRoute('restore', 'restore', ADMIN_ACTION.VIEWS_RESTORED);
392
+ batchRoute('purge', 'purge', ADMIN_ACTION.VIEWS_PURGED);
393
+
394
+ // ---- Logs -------------------------------------------------------------
395
+
396
+ api.get('/logs/admin', requireSession, validateAdminLogListing(allowed), handleAdminValidation,
397
+ async (req, res) => {
398
+ try {
399
+ const result = await logRepo.listAdminLog({
400
+ page: intParam(req, 'page', 1),
401
+ pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
402
+ action: req.query.action,
403
+ appId: req.query.appId,
404
+ });
405
+ return res.json(result);
406
+ } catch (error) {
407
+ return adminError(req, res, error, 'list admin log');
408
+ }
409
+ });
410
+
411
+ api.get('/logs/views', requireSession, validateViewLogListing(allowed), handleAdminValidation,
412
+ async (req, res) => {
413
+ try {
414
+ const result = await logRepo.listViewLog({
415
+ page: intParam(req, 'page', 1),
416
+ pageSize: intParam(req, 'pageSize', ADMIN.PAGE_SIZE_DEFAULT),
417
+ appId: req.query.appId,
418
+ source: req.query.source,
419
+ });
420
+ return res.json(result);
421
+ } catch (error) {
422
+ return adminError(req, res, error, 'list view log');
423
+ }
424
+ });
425
+
426
+ api.use((req, res) => res.status(HTTP_STATUS.NOT_FOUND).json({ code: ADMIN_ERROR_CODE.NOT_FOUND }));
427
+
428
+ // Malformed or oversized JSON bodies, in the admin error shape.
429
+ // eslint-disable-next-line no-unused-vars
430
+ api.use((err, req, res, next) => {
431
+ logger.warn(`admin request rejected: ${err.message}`, logContext(req));
432
+ return res.status(HTTP_STATUS.BAD_REQUEST).json({ code: ADMIN_ERROR_CODE.VALIDATION_FAILED });
433
+ });
434
+
435
+ return api;
436
+ }
437
+
438
+ module.exports = { createAdminRouter, ADMIN_UI_DIR, ADMIN_CSP };
@@ -8,6 +8,7 @@ const {
8
8
  HTTP_STATUS,
9
9
  QUERY_LIMITS,
10
10
  TREND_PERIOD,
11
+ VIEW_LOG_SOURCE,
11
12
  } = require('../constants');
12
13
  const UserAgentParser = require('../utils/userAgentParser');
13
14
  const ReferrerParser = require('../utils/referrerParser');
@@ -190,8 +191,14 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true }) {
190
191
  const userAgent = req.get('user-agent') || '';
191
192
  const uaData = UserAgentParser.parse(userAgent);
192
193
 
193
- const referrerHeader = referrer || req.get('referer') || req.get('referrer');
194
- const referrerData = ReferrerParser.parse(referrerHeader);
194
+ // The `referrer` parameter is the only source of the visitor's
195
+ // referrer; absent or empty means a direct visit. The Referer
196
+ // header is deliberately not a fallback: on every browser
197
+ // integration (a fetch or an <img> beacon) it names the tracked
198
+ // page itself, not where the visitor came from, so reading it
199
+ // recorded every direct visit as a referral from the site's own
200
+ // domain. A server relaying views passes the real referrer here.
201
+ const referrerData = ReferrerParser.parse(referrer);
195
202
 
196
203
  const result = await dbManager.registerEvent(appId, {
197
204
  ip,
@@ -212,6 +219,7 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true }) {
212
219
  userAgent,
213
220
  visitorSecret: config.privacy.visitorSecret,
214
221
  uniqueWindowHours: config.server.uniqueVisitorWindowHours,
222
+ source: VIEW_LOG_SOURCE.REGISTER_VIEW,
215
223
  });
216
224
 
217
225
  logger.audit('registerView', { ...logContext(req), appId, duplicate: result.duplicate });
@@ -269,6 +277,7 @@ function createAnalyticsRouter({ config, dbManager, isReady = () => true }) {
269
277
  visitorSecret: config.privacy.visitorSecret,
270
278
  // Custom events are never deduplicated.
271
279
  uniqueWindowHours: 0,
280
+ source: VIEW_LOG_SOURCE.EVENT,
272
281
  });
273
282
 
274
283
  logger.audit('trackEvent', { ...logContext(req), appId, eventType });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Cookie header parsing.
3
+ *
4
+ * Small enough to own rather than pull in a dependency for, and strict about
5
+ * what it accepts: a malformed pair is skipped, never thrown on, because the
6
+ * header is attacker-controlled input.
7
+ */
8
+
9
+ /** Bounds the work one request can cause here. */
10
+ const MAX_COOKIE_PAIRS = 64;
11
+
12
+ /**
13
+ * Parse a `Cookie` request header into a name -> value map.
14
+ *
15
+ * The first occurrence of a name wins, matching how browsers order cookies
16
+ * (most specific path first) and denying a later, attacker-planted duplicate.
17
+ * Values that are not valid percent-encoding are kept verbatim.
18
+ *
19
+ * @param {string|undefined} header
20
+ * @returns {Record<string, string>}
21
+ */
22
+ function parseCookies(header) {
23
+ const cookies = Object.create(null);
24
+ if (typeof header !== 'string' || header.length === 0) return cookies;
25
+
26
+ const pairs = header.split(';').slice(0, MAX_COOKIE_PAIRS);
27
+ for (const pair of pairs) {
28
+ const separator = pair.indexOf('=');
29
+ if (separator <= 0) continue;
30
+
31
+ const name = pair.slice(0, separator).trim();
32
+ if (!name || name in cookies) continue;
33
+
34
+ let value = pair.slice(separator + 1).trim();
35
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
36
+ value = value.slice(1, -1);
37
+ }
38
+ try {
39
+ cookies[name] = decodeURIComponent(value);
40
+ } catch {
41
+ cookies[name] = value;
42
+ }
43
+ }
44
+ return cookies;
45
+ }
46
+
47
+ module.exports = { parseCookies, MAX_COOKIE_PAIRS };
@@ -23,6 +23,8 @@ const ErrorType = {
23
23
  INVALID_APP_ID: 'INVALID_APP_ID',
24
24
  SECRET_PERSIST_FAILED: 'SECRET_PERSIST_FAILED',
25
25
  SECRET_UNAVAILABLE: 'SECRET_UNAVAILABLE',
26
+ MIGRATION_FAILED: 'MIGRATION_FAILED',
27
+ FIELD_NOT_WRITABLE: 'FIELD_NOT_WRITABLE',
26
28
  };
27
29
 
28
30
  /** Non-fatal conditions worth surfacing but not worth stopping for. */
@@ -36,6 +38,15 @@ const WarningType = {
36
38
  API_KEY_EMPTY_SCOPE: 'API_KEY_EMPTY_SCOPE',
37
39
  APP_ALREADY_REGISTERED: 'APP_ALREADY_REGISTERED',
38
40
  FIELD_TRUNCATED: 'FIELD_TRUNCATED',
41
+ ADMIN_UI_DISABLED: 'ADMIN_UI_DISABLED',
42
+ ADMIN_PASSWORD_REUSED: 'ADMIN_PASSWORD_REUSED',
43
+ ADMIN_INSECURE_TRANSPORT: 'ADMIN_INSECURE_TRANSPORT',
44
+ ADMIN_ORIGIN_REJECTED: 'ADMIN_ORIGIN_REJECTED',
45
+ VIEW_LOG_PRUNE_FAILED: 'VIEW_LOG_PRUNE_FAILED',
46
+ MIGRATION_TABLE_MISSING: 'MIGRATION_TABLE_MISSING',
47
+ VIEW_LOG_WRITE_FAILED: 'VIEW_LOG_WRITE_FAILED',
48
+ ADMIN_LOG_WRITE_FAILED: 'ADMIN_LOG_WRITE_FAILED',
49
+ TRASH_PURGE_FAILED: 'TRASH_PURGE_FAILED',
39
50
  };
40
51
 
41
52
  /**
@@ -62,6 +73,10 @@ const ERROR_MESSAGES = {
62
73
  `Could not persist the visitor-hash secret to ${info?.path}. ` +
63
74
  'Without a stable secret, visitor hashes are not reversible-resistant across restarts.',
64
75
  [ErrorType.SECRET_UNAVAILABLE]: 'Visitor-hash secret has not been initialized',
76
+ [ErrorType.MIGRATION_FAILED]: (info) =>
77
+ `Schema migration failed for table '${info?.table}': ${info?.cause}`,
78
+ [ErrorType.FIELD_NOT_WRITABLE]: (info) =>
79
+ `Refusing to write column '${info?.column}': it is not an admin-editable field.`,
65
80
  };
66
81
 
67
82
  /** @type {Record<string, string | ((info: any) => string)>} */
@@ -86,6 +101,26 @@ const WARNING_MESSAGES = {
86
101
  `App '${info?.appId}' is already registered; leaving it as-is.`,
87
102
  [WarningType.FIELD_TRUNCATED]: (info) =>
88
103
  `Field '${info?.field}' exceeded ${info?.max} characters and was truncated before storage.`,
104
+ [WarningType.ADMIN_UI_DISABLED]: 'ADMIN_PASSWORD is not set; the admin UI and its API are disabled.',
105
+ [WarningType.ADMIN_PASSWORD_REUSED]:
106
+ 'ADMIN_PASSWORD is identical to a configured API key. Use an independent secret so ' +
107
+ 'leaking one credential tier never unlocks another.',
108
+ [WarningType.ADMIN_INSECURE_TRANSPORT]:
109
+ 'An admin login arrived over plain HTTP. The session cookie is not marked Secure on ' +
110
+ 'such a request; serve the admin UI over HTTPS.',
111
+ [WarningType.ADMIN_ORIGIN_REJECTED]: (info) =>
112
+ `Refused an admin request from origin '${info?.presented}'; this server expected '${info?.expected}'. ` +
113
+ 'Behind a TLS-terminating proxy, set TRUST_PROXY and have the proxy pass X-Forwarded-Proto and the original Host.',
114
+ [WarningType.MIGRATION_TABLE_MISSING]: (info) =>
115
+ `Table '${info?.table}' does not exist; skipping its schema migration.`,
116
+ [WarningType.VIEW_LOG_WRITE_FAILED]: (info) =>
117
+ `Could not write the view register log entry for '${info?.appId}': ${info?.cause}`,
118
+ [WarningType.ADMIN_LOG_WRITE_FAILED]: (info) =>
119
+ `Could not write the admin operation log entry '${info?.action}': ${info?.cause}`,
120
+ [WarningType.VIEW_LOG_PRUNE_FAILED]: (info) =>
121
+ `Automatic view log pruning failed: ${info?.cause}`,
122
+ [WarningType.TRASH_PURGE_FAILED]: (info) =>
123
+ `Automatic trash purge failed for '${info?.appId}': ${info?.cause}`,
89
124
  };
90
125
 
91
126
  /**