@harshankur/viewcounter 3.1.0 → 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.
Files changed (50) hide show
  1. package/.env.example +34 -10
  2. package/README.md +306 -136
  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 +55 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +89 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +479 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +53 -19
  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 +197 -18
  39. package/tracker/tracker.js +191 -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
package/admin/index.html CHANGED
@@ -34,19 +34,23 @@
34
34
  </svg>
35
35
  <span class="logo-text" data-i18n="app.name"></span>
36
36
  <span class="logo-badge" data-i18n="app.badge"></span>
37
+ <span class="version-badge" id="app-version" hidden></span>
37
38
  </div>
38
39
 
39
40
  <div class="topbar-actions">
41
+ <a class="header-link" id="website-link" href="https://viewcounter.harshankur.com" target="_blank"
42
+ rel="noopener noreferrer" data-icon="globe" data-i18n-attr="title:nav.websiteTitle"><span
43
+ data-i18n="nav.website"></span></a>
40
44
  <div class="theme-switcher" role="group" data-i18n-attr="aria-label:theme.label">
41
- <button class="theme-btn" id="theme-light" type="button" aria-pressed="false"
42
- data-i18n-attr="aria-label:theme.light;title:theme.light">☀️</button>
43
- <button class="theme-btn" id="theme-dark" type="button" aria-pressed="false"
44
- data-i18n-attr="aria-label:theme.dark;title:theme.dark">🌙</button>
45
- <button class="theme-btn" id="theme-auto" type="button" aria-pressed="false"
46
- data-i18n-attr="aria-label:theme.auto;title:theme.auto">🌓</button>
45
+ <button class="theme-btn" id="theme-light" type="button" aria-pressed="false" data-icon="sun"
46
+ data-i18n-attr="aria-label:theme.light;title:theme.light"></button>
47
+ <button class="theme-btn" id="theme-dark" type="button" aria-pressed="false" data-icon="moon"
48
+ data-i18n-attr="aria-label:theme.dark;title:theme.dark"></button>
49
+ <button class="theme-btn" id="theme-auto" type="button" aria-pressed="false" data-icon="auto"
50
+ data-i18n-attr="aria-label:theme.auto;title:theme.auto"></button>
47
51
  </div>
48
52
  <button class="btn btn-secondary btn-small" id="logout-btn" type="button" hidden
49
- data-i18n="nav.logout"></button>
53
+ data-icon="signOut"><span data-i18n="nav.logout"></span></button>
50
54
  </div>
51
55
  </nav>
52
56
 
@@ -82,6 +86,8 @@
82
86
  </section>
83
87
  </main>
84
88
 
89
+ <footer class="app-footer" id="app-footer" hidden></footer>
90
+
85
91
  <div id="modal-root" hidden></div>
86
92
  <div id="toast-root" class="toast-root"></div>
87
93
  </body>
package/admin/js/api.js CHANGED
@@ -11,10 +11,31 @@ import { API_BASE, CSRF_HEADER, ERROR_CODE } from './constants.js';
11
11
  const SAFE_METHODS = new Set(['GET', 'HEAD']);
12
12
  const HTTP_NO_CONTENT = 204;
13
13
  const HTTP_UNAUTHORIZED = 401;
14
+ const HTTP_FORBIDDEN = 403;
15
+ /** These establish a session; failing them is an answer, not something to recover from. */
16
+ const SESSION_PATHS = new Set(['session', 'login']);
14
17
 
15
18
  let csrfToken = null;
16
19
  const unauthenticatedListeners = new Set();
17
20
 
21
+ /**
22
+ * How a failed request recovers instead of failing, set by main.js:
23
+ * reauthenticate() a sign-in dialog over the page; true once signed in again
24
+ * confirmPassword() the password for an action that cannot be undone; true once confirmed
25
+ */
26
+ const recovery = { reauthenticate: null, confirmPassword: null };
27
+ /** Several requests can find the session gone at once; they share one dialog. */
28
+ let pendingSignIn = null;
29
+
30
+ export function setRecovery(handlers) {
31
+ Object.assign(recovery, handlers);
32
+ }
33
+
34
+ function signInAgain() {
35
+ pendingSignIn ??= recovery.reauthenticate().finally(() => { pendingSignIn = null; });
36
+ return pendingSignIn;
37
+ }
38
+
18
39
  export class ApiError {
19
40
  /** @param {number} status @param {string} code @param {object} [body] */
20
41
  constructor(status, code, body = {}) {
@@ -38,7 +59,7 @@ export function setCsrfToken(token) {
38
59
  * @param {string} path relative to the API base, no leading slash
39
60
  * @param {{ body?: object, query?: Record<string, string|number|undefined> }} [options]
40
61
  */
41
- export async function request(method, path, { body, query } = {}) {
62
+ export async function request(method, path, { body, query } = {}, { retried = false } = {}) {
42
63
  const params = new URLSearchParams();
43
64
  for (const [key, value] of Object.entries(query || {})) {
44
65
  if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
@@ -55,11 +76,15 @@ export async function request(method, path, { body, query } = {}) {
55
76
  method,
56
77
  headers,
57
78
  credentials: 'same-origin',
79
+ // The API never redirects. A gateway in front of it (Cloudflare
80
+ // Access, say) does, to its own sign-in page, when its session ends.
81
+ redirect: 'manual',
58
82
  body: body === undefined ? undefined : JSON.stringify(body),
59
83
  });
60
84
  } catch {
61
85
  throw new ApiError(0, ERROR_CODE.NETWORK);
62
86
  }
87
+ if (response.type === 'opaqueredirect') throw new ApiError(0, ERROR_CODE.ACCESS_EXPIRED);
63
88
 
64
89
  if (response.status === HTTP_NO_CONTENT) return null;
65
90
 
@@ -72,29 +97,50 @@ export async function request(method, path, { body, query } = {}) {
72
97
 
73
98
  if (!response.ok) {
74
99
  const code = payload.code || ERROR_CODE.SERVER_ERROR;
100
+ const retry = () => request(method, path, { body, query }, { retried: true });
101
+
75
102
  if (response.status === HTTP_UNAUTHORIZED && code === ERROR_CODE.UNAUTHENTICATED) {
103
+ // Sign in again over the page, then carry on with this request.
104
+ if (!retried && !SESSION_PATHS.has(path) && recovery.reauthenticate && await signInAgain()) return retry();
76
105
  for (const listener of unauthenticatedListeners) listener();
77
106
  }
107
+ if (response.status === HTTP_FORBIDDEN && code === ERROR_CODE.REAUTH_REQUIRED
108
+ && !retried && recovery.confirmPassword && await recovery.confirmPassword()) {
109
+ return retry();
110
+ }
78
111
  throw new ApiError(response.status, code, payload);
79
112
  }
80
113
  return payload;
81
114
  }
82
115
 
116
+ /** A listing or analysis query for the wire: breakdown filters travel as JSON. */
117
+ function filterQuery({ where, ...query } = {}) {
118
+ return where && Object.keys(where).length ? { ...query, where: JSON.stringify(where) } : query;
119
+ }
120
+
121
+ const appPath = (appId, rest) => `apps/${encodeURIComponent(appId)}/${rest}`;
122
+
83
123
  export const api = {
84
124
  session: () => request('GET', 'session'),
85
125
  login: (password) => request('POST', 'login', { body: { password } }),
126
+ reauth: (password) => request('POST', 'reauth', { body: { password } }),
86
127
  logout: () => request('POST', 'logout'),
87
128
  meta: () => request('GET', 'meta'),
88
129
  apps: () => request('GET', 'apps'),
89
- views: (appId, query) => request('GET', `apps/${encodeURIComponent(appId)}/views`, { query }),
90
- allViews: (query) => request('GET', 'views', { query }),
91
- analytics: (appId, query) => request('GET', `apps/${encodeURIComponent(appId)}/analytics`, { query }),
92
- analyticsAll: (query) => request('GET', 'analytics', { query }),
130
+ views: (appId, query) => request('GET', appPath(appId, 'views'), { query: filterQuery(query) }),
131
+ allViews: (query) => request('GET', 'views', { query: filterQuery(query) }),
132
+ analytics: (appId, query) => request('GET', appPath(appId, 'analytics'), { query: filterQuery(query) }),
133
+ analyticsAll: (query) => request('GET', 'analytics', { query: filterQuery(query) }),
134
+ eventTypes: (appId, query) => request('GET', appPath(appId, 'event-types'), { query }),
135
+ eventTypesAll: (query) => request('GET', 'event-types', { query }),
136
+ realtime: (appId) => request('GET', appPath(appId, 'realtime')),
137
+ realtimeAll: () => request('GET', 'realtime'),
93
138
  edit: (appId, ids, changes) => request('PATCH', `apps/${encodeURIComponent(appId)}/views`, { body: { ids, changes } }),
94
139
  note: (appId, ids, note) => request('PUT', `apps/${encodeURIComponent(appId)}/views/note`, { body: { ids, note } }),
95
140
  remove: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/delete`, { body: { ids } }),
96
141
  restore: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/restore`, { body: { ids } }),
97
142
  purge: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/purge`, { body: { ids } }),
98
143
  adminLog: (query) => request('GET', 'logs/admin', { query }),
99
- viewLog: (query) => request('GET', 'logs/views', { query }),
144
+ trackingLog: (query) => request('GET', 'logs/tracking', { query }),
145
+ trackingSummary: (query) => request('GET', 'logs/tracking/summary', { query }),
100
146
  };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The app tabs: one per app, led by one for every app together. Overview,
3
+ * Views, and Trash each show a strip, and all three follow the same choice:
4
+ * switching in one switches the others.
5
+ *
6
+ * ARIA tabs pattern with automatic activation: arrow keys, Home and End move
7
+ * and select.
8
+ */
9
+
10
+ import { el, replaceChildren } from './dom.js';
11
+ import { formatNumber } from './format.js';
12
+ import { t } from './i18n.js';
13
+ import { ALL_APPS, KEY } from './constants.js';
14
+
15
+ /**
16
+ * Every app, led by one entry for all of them together.
17
+ * @param {object[]} apps as GET api/apps lists them
18
+ */
19
+ export function appEntries(apps) {
20
+ const sum = (key) => apps.reduce((total, app) => total + app[key], 0);
21
+ return [
22
+ {
23
+ appId: ALL_APPS,
24
+ label: t('appTabs.all'),
25
+ active: sum('active'),
26
+ deleted: sum('deleted'),
27
+ modified: sum('modified'),
28
+ available: true,
29
+ },
30
+ ...apps.map((app) => ({ ...app, label: app.appId })),
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * @param {{ context: { apps: () => object[], appId: () => string, setAppId: (id: string) => void },
36
+ * controls: string, trash?: boolean }} options controls is the id of the region the tabs switch
37
+ * @returns {{ element: HTMLElement, render: () => void }}
38
+ */
39
+ export function createAppTabs({ context, controls, trash = false }) {
40
+ const element = el('div', {
41
+ className: 'app-tabs',
42
+ attrs: { role: 'tablist', 'aria-label': t('appTabs.label') },
43
+ });
44
+ let focusOnRender = false;
45
+
46
+ function select(appId, { focus = false } = {}) {
47
+ focusOnRender = focus;
48
+ if (appId === context.appId()) {
49
+ render();
50
+ return;
51
+ }
52
+ context.setAppId(appId);
53
+ }
54
+
55
+ function render() {
56
+ const current = context.appId();
57
+ replaceChildren(element, appEntries(context.apps()).map((app) => {
58
+ const selected = app.appId === current;
59
+ const count = trash ? app.deleted : app.active;
60
+ const countText = t(trash ? 'appTabs.deletedCount' : 'appTabs.activeCount', { count });
61
+ return el('button', {
62
+ className: `app-tab${selected ? ' active' : ''}${app.available ? '' : ' unavailable'}`,
63
+ attrs: {
64
+ type: 'button',
65
+ role: 'tab',
66
+ 'aria-selected': String(selected),
67
+ 'aria-controls': controls,
68
+ 'aria-label': t('appTabs.tabName', { app: app.label, count: countText }),
69
+ tabindex: selected ? '0' : '-1',
70
+ title: app.available ? null : t('appTabs.unavailable', { app: app.appId }),
71
+ },
72
+ dataset: { appId: app.appId },
73
+ on: { click: () => select(app.appId) },
74
+ }, [
75
+ el('span', { className: 'app-tab-name', text: app.label }),
76
+ el('span', { className: 'app-tab-count', text: formatNumber(count), attrs: { 'aria-hidden': 'true' } }),
77
+ ]);
78
+ }));
79
+ if (focusOnRender) {
80
+ element.querySelector('[aria-selected="true"]')?.focus();
81
+ focusOnRender = false;
82
+ }
83
+ }
84
+
85
+ element.addEventListener('keydown', (event) => {
86
+ const ids = appEntries(context.apps()).map((app) => app.appId);
87
+ const index = Math.max(0, ids.indexOf(context.appId()));
88
+ const moves = {
89
+ [KEY.ARROW_RIGHT]: (index + 1) % ids.length,
90
+ [KEY.ARROW_LEFT]: (index - 1 + ids.length) % ids.length,
91
+ [KEY.HOME]: 0,
92
+ [KEY.END]: ids.length - 1,
93
+ };
94
+ if (!(event.key in moves)) return;
95
+ event.preventDefault();
96
+ select(ids[moves[event.key]], { focus: true });
97
+ });
98
+
99
+ return { element, render };
100
+ }