@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.
Files changed (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
@@ -2,11 +2,14 @@
2
2
  "app": {
3
3
  "name": "viewcounter",
4
4
  "badge": "admin",
5
- "documentTitle": "ViewCounter Admin"
5
+ "documentTitle": "ViewCounter Admin",
6
+ "version": "v{version}"
6
7
  },
7
8
  "nav": {
8
9
  "skip": "Skip to content",
9
- "logout": "Sign out"
10
+ "logout": "Sign out",
11
+ "website": "Website",
12
+ "websiteTitle": "viewcounter's website, in a new tab"
10
13
  },
11
14
  "theme": {
12
15
  "label": "Colour theme",
@@ -22,12 +25,20 @@
22
25
  "submit": "Sign in",
23
26
  "signedOut": "You have been signed out."
24
27
  },
28
+ "session": {
29
+ "endedTitle": "Signed out",
30
+ "endedMessage": "Your session has ended. Sign in again to carry on where you left off.",
31
+ "confirmTitle": "Confirm it is you",
32
+ "confirmMessage": "Erasing views permanently cannot be undone. Enter your password to continue.",
33
+ "confirmAction": "Confirm"
34
+ },
25
35
  "tabs": {
26
36
  "label": "Sections",
27
- "views": "views",
28
- "trash": "trash",
29
- "adminLog": "admin-log",
30
- "viewLog": "view-log"
37
+ "overview": "Overview",
38
+ "views": "Views",
39
+ "trash": "Trash",
40
+ "trackingLog": "Tracking log",
41
+ "adminLog": "Admin log"
31
42
  },
32
43
  "common": {
33
44
  "none": "-",
@@ -43,10 +54,13 @@
43
54
  "TOO_MANY_ATTEMPTS": "Too many failed attempts. Wait a few minutes and try again.",
44
55
  "RATE_LIMITED": "Too many requests. Slow down for a moment and try again.",
45
56
  "CSRF_REJECTED": "The request was refused for security reasons. Reload the page and try again.",
57
+ "REAUTH_REQUIRED": "Enter your password again to continue.",
46
58
  "VALIDATION_FAILED": "The server did not accept that input.",
47
59
  "NOT_FOUND": "That was not found.",
48
60
  "SERVER_ERROR": "Something went wrong on the server. Please try again.",
49
- "NETWORK": "The server could not be reached. Check your connection."
61
+ "NETWORK": "The server could not be reached. Check your connection.",
62
+ "ACCESS_EXPIRED": "Your sign-in to the site's access gateway (for example Cloudflare Access) has ended. Reload the page to sign in there again.",
63
+ "UNEXPECTED": "Something went wrong in this page. Reload it and try again."
50
64
  },
51
65
  "pager": {
52
66
  "previous": "Previous",
@@ -72,7 +86,7 @@
72
86
  },
73
87
  "toolbar": {
74
88
  "search": "Search views",
75
- "searchPlaceholder": "Search page, title, source, note, event…",
89
+ "searchPlaceholder": "Search page, title, site, source, campaign, note, event, or view ID…",
76
90
  "modifiedFilter": "Filter by admin changes",
77
91
  "showDeleted": "Show deleted",
78
92
  "pageSize": "Rows per page",
@@ -110,19 +124,54 @@
110
124
  "selectPage": "Select every view on this page",
111
125
  "selectRow": "Select the view from {time}",
112
126
  "empty": "No views match.",
113
- "emptyTrash": "The trash is empty."
127
+ "emptyTrash": "The trash is empty.",
128
+ "columns": "Columns",
129
+ "columnsTitle": "Columns",
130
+ "columnsIntro": "Tick the columns you want, and put them in order of importance. The table shows as many as fit its width, in this order; the rest of each row is one tap away, under it. Drag a column's edge in the table to change its width.",
131
+ "columnsReset": "Reset to default",
132
+ "columnsApply": "Apply",
133
+ "columnsNone": "Choose at least one column.",
134
+ "moveUp": "Move {column} up",
135
+ "moveDown": "Move {column} down",
136
+ "onlyAllApps": "shown under All apps",
137
+ "resize": "Width of the {column} column",
138
+ "resizeHint": "Drag to resize. Arrow keys also work, and Enter or a double click resets it.",
139
+ "showMore": {
140
+ "one": "Show {count} more field",
141
+ "other": "Show {count} more fields"
142
+ },
143
+ "hideMore": {
144
+ "one": "Hide {count} more field",
145
+ "other": "Hide {count} more fields"
146
+ },
147
+ "showMoreAll": "Show for every row: {columns}",
148
+ "hideMoreAll": "Hide for every row: {columns}"
114
149
  },
115
150
  "columns": {
116
- "timestamp": "Time",
151
+ "time": "Time",
152
+ "deleted": "Deleted",
153
+ "app": "App",
117
154
  "page": "Page",
118
155
  "source": "Source",
156
+ "status": "Status",
157
+ "event": "Event",
158
+ "country": "Location",
119
159
  "device": "Device",
120
- "country": "Country",
121
160
  "client": "Client",
122
- "event": "Event",
123
- "status": "Status",
124
- "actions": "Actions",
125
- "app": "App"
161
+ "title": "Title",
162
+ "site": "Site",
163
+ "referrer": "Referrer",
164
+ "campaign": "Campaign",
165
+ "language": "Language",
166
+ "browser": "Browser",
167
+ "system": "System",
168
+ "engagement": "Engagement",
169
+ "ip": "Masked IP",
170
+ "session": "Session ID",
171
+ "viewId": "View ID",
172
+ "note": "Note",
173
+ "modified": "Changed by admin",
174
+ "actions": "Actions"
126
175
  },
127
176
  "status": {
128
177
  "deleted": "In trash",
@@ -145,6 +194,7 @@
145
194
  "email": "Email",
146
195
  "campaign": "Campaign",
147
196
  "referral": "Referral",
197
+ "internal": "Internal",
148
198
  "unknown": "Unknown"
149
199
  },
150
200
  "deviceSizes": {
@@ -156,14 +206,16 @@
156
206
  "summary": {
157
207
  "one": "{active} view · {modified} modified · {deleted} in trash",
158
208
  "other": "{active} views · {modified} modified · {deleted} in trash"
159
- }
209
+ },
210
+ "intro": "Every view and event the tracker recorded, one row each, newest first. These rows are what every number on the Overview is computed from. Correct a row, add a private note, or move it to the trash; every change is recorded in the Admin log."
160
211
  },
161
212
  "trash": {
162
213
  "retention": {
163
214
  "one": "Views in the trash no longer count in any statistic. They are erased permanently {count} day after being deleted, or sooner if you erase them here.",
164
215
  "other": "Views in the trash no longer count in any statistic. They are erased permanently {count} days after being deleted, or sooner if you erase them here."
165
216
  },
166
- "retentionOff": "Views in the trash no longer count in any statistic. Automatic erasure is off (TRASH_RETENTION_DAYS=0), so they stay until erased here."
217
+ "retentionOff": "Views in the trash no longer count in any statistic. Automatic erasure is off (TRASH_RETENTION_DAYS=0), so they stay until erased here.",
218
+ "intro": "Views moved to the trash. They no longer count in any statistic and can be restored, or erased for good. Erasing asks for your password again."
167
219
  },
168
220
  "toasts": {
169
221
  "edited": {
@@ -240,7 +292,16 @@
240
292
  "purgeAction": "Erase permanently"
241
293
  },
242
294
  "details": {
243
- "title": "View details"
295
+ "title": "View details",
296
+ "scrolled": "{percent}% of the page",
297
+ "groups": {
298
+ "view": "View",
299
+ "page": "Page",
300
+ "source": "Source and campaign",
301
+ "visitor": "Visitor",
302
+ "engagement": "Engagement",
303
+ "admin": "Admin changes"
304
+ }
244
305
  },
245
306
  "fields": {
246
307
  "id": "View ID",
@@ -258,13 +319,24 @@
258
319
  "browserVersion": "Browser version",
259
320
  "os": "Operating system",
260
321
  "osVersion": "OS version",
261
- "sessionId": "Session ID",
322
+ "sessionId": "Session ID (sent by the site)",
262
323
  "eventType": "Event type",
263
324
  "eventData": "Event data",
264
325
  "isUnique": "Unique view",
265
326
  "note": "Note",
266
327
  "adminModifiedAt": "Modified by admin",
267
- "deletedAt": "Moved to trash"
328
+ "deletedAt": "Moved to trash",
329
+ "hostname": "Site (hostname)",
330
+ "language": "Language",
331
+ "utmSource": "Campaign source",
332
+ "utmMedium": "Campaign medium",
333
+ "utmCampaign": "Campaign name",
334
+ "utmTerm": "Campaign term",
335
+ "utmContent": "Campaign content",
336
+ "region": "Region",
337
+ "city": "City",
338
+ "engagedMs": "Time on page",
339
+ "scrollDepth": "Scroll depth"
268
340
  },
269
341
  "edit": {
270
342
  "titleSingle": "Edit view",
@@ -298,16 +370,40 @@
298
370
  "allActions": "All actions",
299
371
  "filterApp": "Filter by app",
300
372
  "allApps": "All apps",
301
- "filterSource": "Filter by source",
302
- "allSources": "All sources",
373
+ "filterSource": "Filter by request type",
374
+ "allSources": "All request types",
375
+ "filterOutcome": "Filter by outcome",
376
+ "allOutcomes": "All outcomes",
303
377
  "system": "system",
304
378
  "emptyAdmin": "No admin operations recorded yet.",
305
- "emptyViews": "No views recorded yet.",
306
- "viewLogRetention": {
307
- "one": "Entries are removed automatically {count} day after they are recorded. The views themselves are not affected.",
308
- "other": "Entries are removed automatically {count} days after they are recorded. The views themselves are not affected."
379
+ "emptyTracking": "No tracking requests match.",
380
+ "adminIntro": "Everything done in this admin: sign-ins, edits, notes, moves to the trash, restores, and erasures, with the session and the masked address it came from. It never records what a view said, so an erased view leaves no trace of its content.",
381
+ "trackingIntro": "Every tracking request that reached the server, and what became of it. Views lists only what was recorded, and the Overview counts only that. This log also shows what was never stored: bots, and requests refused (an unregistered site, an unknown app, a malformed request). Use it to check that a site is sending views and to find out why some are not counted. Editing or deleting a view never changes it.",
382
+ "trackingRetention": {
383
+ "one": "Entries are removed automatically {count} day after they are recorded. Views are not affected.",
384
+ "other": "Entries are removed automatically {count} days after they are recorded. Views are not affected."
385
+ },
386
+ "trackingRetentionOff": "Entries are kept until removed by hand (VIEW_LOG_RETENTION_DAYS=0).",
387
+ "outcomesTitle": "What each outcome means",
388
+ "outcomeHelp": {
389
+ "recorded": "Stored as a view, and counted as a unique view.",
390
+ "repeat": "Stored as a view, from a visitor already counted in this window, so not counted as unique.",
391
+ "bot": "A crawler, preview fetcher, or automated browser. Never stored; counted here by name, per minute.",
392
+ "rejected": "Refused and never stored; the reason is shown. Counted per minute, without anything about the requester."
393
+ },
394
+ "lastHours": {
395
+ "one": "last {count} hour",
396
+ "other": "last {count} hours"
309
397
  },
310
- "viewLogRetentionOff": "Entries are kept until removed by hand (VIEW_LOG_RETENTION_DAYS=0)."
398
+ "summaryLabel": {
399
+ "one": "Tracking requests in the last {count} hour",
400
+ "other": "Tracking requests in the last {count} hours"
401
+ },
402
+ "reasons": "Refused in the last day: {list}",
403
+ "noReasons": "Nothing refused in the last day.",
404
+ "autoRefresh": "Refresh every {seconds} s",
405
+ "openView": "Show this view",
406
+ "openViewNamed": "Show view {id}"
311
407
  },
312
408
  "logColumns": {
313
409
  "time": "Time",
@@ -317,10 +413,13 @@
317
413
  "fields": "Fields",
318
414
  "session": "Session",
319
415
  "ip": "IP (masked)",
320
- "source": "Source",
416
+ "source": "Request",
417
+ "outcome": "Outcome",
418
+ "detail": "Reason or detail",
419
+ "view": "View",
420
+ "site": "Site",
321
421
  "event": "Event",
322
- "unique": "Unique",
323
- "view": "View ID"
422
+ "requests": "Requests"
324
423
  },
325
424
  "actions": {
326
425
  "login_succeeded": "Signed in",
@@ -333,13 +432,32 @@
333
432
  "views_restored": "Restored",
334
433
  "views_purged": "Erased",
335
434
  "trash_auto_purged": "Trash auto-erased",
336
- "view_log_pruned": "View log pruned"
435
+ "view_log_pruned": "Tracking log pruned",
436
+ "password_confirmed": "Password confirmed"
437
+ },
438
+ "outcomes": {
439
+ "recorded": "Recorded",
440
+ "repeat": "Repeat visit",
441
+ "bot": "Bot",
442
+ "rejected": "Refused"
443
+ },
444
+ "rejectionReasons": {
445
+ "bot": "automated client",
446
+ "unknown_app": "unknown app",
447
+ "origin_not_allowed": "site not allowed",
448
+ "invalid_request": "invalid request",
449
+ "invalid_ip": "unreadable IP address",
450
+ "rate_limited": "rate limited",
451
+ "unknown_view": "view not found",
452
+ "server_error": "server error"
337
453
  },
338
454
  "logSources": {
339
455
  "registerView": "Page view",
340
- "event": "Custom event"
456
+ "event": "Event",
457
+ "engage": "Engagement"
341
458
  },
342
459
  "ranges": {
460
+ "24h": "24 hours",
343
461
  "7d": "7 days",
344
462
  "30d": "30 days",
345
463
  "90d": "90 days",
@@ -347,48 +465,219 @@
347
465
  "all": "All time"
348
466
  },
349
467
  "eventTypes": {
350
- "pageview": "Page view"
351
- },
352
- "insights": {
353
- "title": "insights",
354
- "scopeAll": "All apps, filtered like the table below",
355
- "scopeApp": "{app}, filtered like the table below",
356
- "span": "From {from} to {to}",
357
- "noData": "No views match these filters.",
468
+ "pageview": "Page view",
469
+ "outbound": "Link to another site",
470
+ "download": "Download"
471
+ },
472
+ "deviceTypes": {
473
+ "desktop": "Desktop",
474
+ "mobile": "Phone",
475
+ "tablet": "Tablet",
476
+ "wearable": "Wearable",
477
+ "tv": "TV",
478
+ "console": "Games console"
479
+ },
480
+ "charts": {
358
481
  "views": "Views",
359
- "uniqueViews": "Unique views",
360
- "visitors": "Visitors",
361
- "uniqueShare": "Unique views",
362
- "countries": "Countries",
363
- "modified": "Modified by admin",
364
- "trendTitle": "Views over time",
365
- "mapTitle": "Where views come from",
366
- "showTable": "Show as table",
367
- "showChart": "Show as chart",
482
+ "share": "of views",
483
+ "noData": "No views match these filters.",
484
+ "showTable": "Table",
485
+ "showChart": "Chart",
368
486
  "period": "Period",
369
487
  "weekOf": "Week of {date}",
370
- "trendLabel": "Views over time from {from} to {to}, peaking at {peak}. Use the arrow keys to read each period.",
371
- "share": "of views",
372
- "other": "Other",
373
- "otherTypes": "Other types",
374
- "unknown": "Unknown",
488
+ "inProgress": "{period} (so far)",
489
+ "trendLabel": "{metric} from {from} to {to}, highest at {peak} on {peakAt}. Use the arrow keys to read each period.",
375
490
  "topCountries": "Countries by views",
376
491
  "mapLabel": "World map of views by country, {countries} countries with views. The list beside it gives the numbers.",
377
492
  "mapUnavailable": "The map could not be loaded. The list beside it has every number.",
378
493
  "noViews": "No views",
494
+ "otherTypes": "Other types",
379
495
  "moreCountries": {
380
496
  "one": "Show {count} more country",
381
497
  "other": "Show {count} more countries"
382
498
  },
383
- "breakdown": {
384
- "source": "Sources",
385
- "deviceSize": "Device sizes",
386
- "browser": "Browsers",
387
- "os": "Operating systems",
388
- "eventType": "Event types",
389
- "app": "Apps"
499
+ "fewerCountries": "Show fewer",
500
+ "heatmapLabel": "Views by weekday and hour, busiest on {day} at {hour} with {views} views. Use the arrow keys to read each hour, or switch to the table.",
501
+ "heatmapCell": "{day}, {from} to {to}",
502
+ "hour": "Hour",
503
+ "minutesLabel": "Views per minute over the last {count} minutes, {views} in all. Use the arrow keys to read each minute.",
504
+ "minutesAgo": {
505
+ "one": "{count} minute ago",
506
+ "other": "{count} minutes ago"
390
507
  },
391
- "inProgress": "{period} (so far)",
392
- "fewerCountries": "Show fewer"
508
+ "now": "Now"
509
+ },
510
+ "overview": {
511
+ "intro": "Statistics for the app and period chosen below, computed from live views only (the trash never counts). Click any row in a breakdown to narrow everything to it.",
512
+ "showViews": "Show these views",
513
+ "removeFilter": "Remove the filter {filter}",
514
+ "clearFilters": "Clear filters",
515
+ "filterBy": "Show only {dimension}: {value}",
516
+ "cardView": "{card}: what to show",
517
+ "trendTitle": "{metric} over time",
518
+ "trendView": "Show the trend as",
519
+ "tilesLabel": "Headline numbers. Choose one to chart it over time.",
520
+ "span": {
521
+ "one": "From {from} to {to} · {countries} country · {unique} unique views · {modified} changed by an admin",
522
+ "other": "From {from} to {to} · {countries} countries · {unique} unique views · {modified} changed by an admin"
523
+ },
524
+ "empty": "No views match these filters.",
525
+ "allTime": "All time",
526
+ "noComparison": "Nothing to compare with",
527
+ "vsPrevious": {
528
+ "24h": "vs the 24 hours before",
529
+ "7d": "vs the 7 days before",
530
+ "30d": "vs the 30 days before",
531
+ "90d": "vs the 90 days before",
532
+ "1y": "vs the year before"
533
+ },
534
+ "points": "{value} pts",
535
+ "deltaNew": "New",
536
+ "helpTitle": "How these numbers are counted",
537
+ "metrics": {
538
+ "visitors": "Visitors",
539
+ "visits": "Visits",
540
+ "pageviews": "Page views",
541
+ "views": "Views and events",
542
+ "bounceRate": "Bounce rate",
543
+ "avgVisitMs": "Visit duration",
544
+ "pagesPerVisit": "Pages per visit",
545
+ "avgEngagedMs": "Time on page",
546
+ "avgScroll": "Scroll depth"
547
+ },
548
+ "help": {
549
+ "visitors": "Distinct visitors, told apart by a hash of the address and browser that changes every day and is never stored with anything that identifies a person. The same person on two days counts twice, and people sharing a network and browser count once.",
550
+ "visits": "Page views of one visitor with no pause longer than 30 minutes between them.",
551
+ "pageviews": "Views of a page, without custom events.",
552
+ "views": "Everything recorded: page views and custom events such as downloads or clicks on links to other sites.",
553
+ "bounceRate": "The share of visits that saw only one page.",
554
+ "avgVisitMs": "From the first page view of a visit to the last, plus how long the last page was visible. The average over visits.",
555
+ "pagesPerVisit": "Page views per visit, on average.",
556
+ "avgEngagedMs": "How long a page was actually visible on screen, as the tracker script measured it. Only pages with a measurement count.",
557
+ "avgScroll": "How much of a page had been on screen, at most, as the tracker script measured it."
558
+ },
559
+ "dims": {
560
+ "source": "Channel",
561
+ "referrer": "Referrer",
562
+ "referrerUrl": "Referring page",
563
+ "utmSource": "Campaign source",
564
+ "utmMedium": "Campaign medium",
565
+ "utmCampaign": "Campaign",
566
+ "utmTerm": "Campaign term",
567
+ "utmContent": "Campaign content",
568
+ "page": "Page",
569
+ "title": "Title",
570
+ "hostname": "Site",
571
+ "country": "Country",
572
+ "region": "Region",
573
+ "city": "City",
574
+ "language": "Language",
575
+ "deviceSize": "Screen size",
576
+ "deviceType": "Device type",
577
+ "browser": "Browser",
578
+ "browserVersion": "Browser version",
579
+ "os": "Operating system",
580
+ "osVersion": "System version",
581
+ "eventType": "Event type",
582
+ "app": "App",
583
+ "timeOnPage": "Time on page",
584
+ "scrollDepth": "Scrolled"
585
+ },
586
+ "noReferrer": "Direct or not sent",
587
+ "notTagged": "Not tagged",
588
+ "noTitle": "No title",
589
+ "unknown": "Unknown",
590
+ "other": "Everything else",
591
+ "columns": {
592
+ "visitors": "Visitors",
593
+ "views": "Views",
594
+ "share": "Share",
595
+ "visits": "Visits",
596
+ "bounceRate": "Bounce",
597
+ "timeOnPage": "Time",
598
+ "scroll": "Scroll",
599
+ "steps": "Times",
600
+ "count": "Count",
601
+ "pageviews": "Page views"
602
+ },
603
+ "noVisits": "No page views, so no visits, match these filters.",
604
+ "noFlow": "No visitor went from one page to another in these views.",
605
+ "noProperties": "No custom event carried properties in these views.",
606
+ "noEngagement": "No time or scrolling measured yet. The tracker script measures both; a site that registers views another way does not.",
607
+ "noCityData": "Regions and cities need a city database on the server (GEOIP_CITY_DB). Countries work without one.",
608
+ "flowHeader": "From page, to page",
609
+ "flowStep": "{from} → {to}",
610
+ "propertyHeader": "Property",
611
+ "property": "{key} = {value}",
612
+ "orMore": "{from} or more",
613
+ "under": "Under {to}",
614
+ "between": "{from} to {to}",
615
+ "scrollAll": "All of it (100%)",
616
+ "scrollBetween": "{from}% to {to}%",
617
+ "cards": {
618
+ "apps": "Apps",
619
+ "sources": "Sources",
620
+ "pages": "Pages",
621
+ "locations": "Locations",
622
+ "devices": "Devices",
623
+ "browsers": "Browsers",
624
+ "systems": "Operating systems",
625
+ "campaigns": "Campaigns",
626
+ "events": "Events",
627
+ "engagement": "Engagement",
628
+ "when": "When, by weekday and hour ({zone})",
629
+ "flow": "Page flow"
630
+ },
631
+ "tabs": {
632
+ "channels": "Channels",
633
+ "referrers": "Referrers",
634
+ "referringPages": "Referring pages",
635
+ "topPages": "Top",
636
+ "entryPages": "Entry",
637
+ "exitPages": "Exit",
638
+ "titles": "Titles",
639
+ "sites": "Sites",
640
+ "map": "Map",
641
+ "countries": "Countries",
642
+ "regions": "Regions",
643
+ "cities": "Cities",
644
+ "languages": "Languages",
645
+ "screenSizes": "Screen sizes",
646
+ "deviceTypes": "Device types",
647
+ "browsers": "Browsers",
648
+ "versions": "Versions",
649
+ "systems": "Systems",
650
+ "utmSource": "Source",
651
+ "utmMedium": "Medium",
652
+ "utmCampaign": "Campaign",
653
+ "utmTerm": "Term",
654
+ "utmContent": "Content",
655
+ "eventTypes": "Types",
656
+ "properties": "Properties",
657
+ "timeOnPage": "Time on page",
658
+ "scrollDepth": "Scroll depth"
659
+ },
660
+ "whenNote": "Covers up to the last year of the chosen period, in your time zone.",
661
+ "live": {
662
+ "title": "Right now",
663
+ "visitors": {
664
+ "one": "visitor in the last {minutes} minutes",
665
+ "other": "visitors in the last {minutes} minutes"
666
+ },
667
+ "pages": "Pages open now",
668
+ "nobody": "Nobody on the sites right now.",
669
+ "updated": "Updated {time}, every {seconds} s while this page is open. Live views of the chosen app; the filters above do not apply."
670
+ }
671
+ },
672
+ "footer": {
673
+ "product": "Version {version}",
674
+ "links": "About viewcounter",
675
+ "website": "Website",
676
+ "docs": "Admin guide",
677
+ "changelog": "Changelog",
678
+ "source": "Source code",
679
+ "npm": "npm package",
680
+ "credits": "Location data:",
681
+ "copyright": "{product} © {year} {holder}"
393
682
  }
394
683
  }
package/config/index.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  SERVER,
14
14
  } = require('../constants');
15
15
  const { filterValidAppIds } = require('../utils/appIdUtils');
16
+ const { parseDuration, formatDuration } = require('../utils/durationUtils');
16
17
  const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
17
18
  const { LogLevel } = require('../utils/logger');
18
19
 
@@ -71,7 +72,7 @@ function readJsonFile(filePath) {
71
72
  *
72
73
  * CONFIG.md §4: never a shallow spread of a nested object. The previous
73
74
  * implementation returned the parsed file verbatim, so a `dbInfo.json` missing
74
- * `host` produced `host: undefined` instead of falling back — and an
75
+ * `host` produced `host: undefined` instead of falling back, and an
75
76
  * `allowed.json` missing `appId` produced `undefined`, which then threw on
76
77
  * `.join()` at startup.
77
78
  *
@@ -98,13 +99,16 @@ class Config {
98
99
  this.auth = this.loadAuthConfig();
99
100
  this.privacy = this.loadPrivacyConfig();
100
101
  this.admin = this.loadAdminConfig();
102
+ // Optional: an .mmdb city database (DB-IP City Lite or GeoLite2 City)
103
+ // for each view's region and city. Without it, country only.
104
+ this.geo = { cityDatabase: this.env.GEOIP_CITY_DB || null };
101
105
  }
102
106
 
103
107
  /**
104
108
  * Server, logging, and proxy settings.
105
109
  *
106
110
  * `nodeEnv` defaults to production. It previously defaulted to
107
- * development, which is also what the setup wizard wrote into `.env` — and
111
+ * development, which is also what the setup wizard wrote into `.env`, and
108
112
  * ten route handlers echo raw database error text to the caller when the
109
113
  * environment is development. A deploy that forgot to set NODE_ENV leaked
110
114
  * table names and SQL fragments to anonymous callers.
@@ -209,7 +213,7 @@ class Config {
209
213
  *
210
214
  * Two sources, because they serve different deployments:
211
215
  * - `READ_API_KEYS` (env): unscoped keys that can read every app. This is
212
- * the single-operator case — all the apps are yours anyway.
216
+ * the single-operator case: all the apps are yours anyway.
213
217
  * - `apiKeys` in allowed.json: `{ "<key>": ["blog"] }`, scoped to named
214
218
  * apps. This is the multi-tenant case, where one customer's key must
215
219
  * not read another customer's analytics. Use `"*"` for an unscoped key.
@@ -275,7 +279,7 @@ class Config {
275
279
  // Resolved lazily, on first read rather than at construction.
276
280
  //
277
281
  // This module is imported by index.js, and index.js is the package
278
- // entry point — so an application that only wants to mount
282
+ // entry point, so an application that only wants to mount
279
283
  // createAnalyticsRouter would otherwise generate and persist a secret
280
284
  // purely as a side effect of `require('@harshankur/viewcounter')`, writing it into
281
285
  // node_modules where the next `npm ci` wipes it. Embedders supply their
@@ -319,7 +323,13 @@ class Config {
319
323
  * pruned: it is the record of who changed or erased what, and it grows only
320
324
  * with admin activity.
321
325
  *
322
- * An out-of-range or unparseable value falls back to the default.
326
+ * An out-of-range or unparseable retention falls back to the default.
327
+ *
328
+ * `ADMIN_SESSION_IDLE_TIMEOUT` and `ADMIN_SESSION_MAX_AGE` ("30m", "12h",
329
+ * "7d") bound how long a sign-in lasts: signed out after that long unused,
330
+ * and after that long regardless. Unlike a retention, a malformed timeout
331
+ * stops startup: silently using the default would give a typo like
332
+ * "30 min" a week-long session.
323
333
  */
324
334
  loadAdminConfig() {
325
335
  const password = this.env.ADMIN_PASSWORD || '';
@@ -330,9 +340,34 @@ class Config {
330
340
  return days >= 0 && days <= max ? days : fallback;
331
341
  };
332
342
 
343
+ const duration = (field, fallback, min, max) => {
344
+ const raw = this.env[field];
345
+ if (raw === undefined || raw === '') return fallback;
346
+ const ms = parseDuration(raw);
347
+ if (ms === null || ms < min || ms > max) {
348
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, {
349
+ field,
350
+ reason: `must be a duration such as 30m, 12h, or 7d, from ${formatDuration(min)} to ${formatDuration(max)}`,
351
+ });
352
+ }
353
+ return ms;
354
+ };
355
+ const sessionIdleMs = duration('ADMIN_SESSION_IDLE_TIMEOUT',
356
+ ADMIN.SESSION_IDLE_TIMEOUT_MS, ADMIN.SESSION_IDLE_MIN_MS, ADMIN.SESSION_IDLE_MAX_MS);
357
+ const sessionMaxAgeMs = duration('ADMIN_SESSION_MAX_AGE',
358
+ ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS, ADMIN.SESSION_MAX_AGE_MIN_MS, ADMIN.SESSION_MAX_AGE_MAX_MS);
359
+ if (sessionIdleMs > sessionMaxAgeMs) {
360
+ throw getError(ErrorType.CONFIG_INVALID_VALUE, {
361
+ field: 'ADMIN_SESSION_IDLE_TIMEOUT',
362
+ reason: `must not be longer than ADMIN_SESSION_MAX_AGE (${formatDuration(sessionMaxAgeMs)})`,
363
+ });
364
+ }
365
+
333
366
  return {
334
367
  password,
335
368
  enabled: longEnough,
369
+ sessionIdleMs,
370
+ sessionMaxAgeMs,
336
371
  trashRetentionDays: retention(
337
372
  this.env.TRASH_RETENTION_DAYS, ADMIN.DEFAULT_TRASH_RETENTION_DAYS, ADMIN.MAX_TRASH_RETENTION_DAYS),
338
373
  viewLogRetentionDays: retention(