@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.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -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 +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
Binary file
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ViewCounter">
2
+ <title>ViewCounter</title>
3
+ <rect width="64" height="64" rx="14" fill="#0a0f0c"/>
4
+ <rect x="0.75" y="0.75" width="62.5" height="62.5" rx="13.25" fill="none" stroke="#3ddc84" stroke-opacity="0.35" stroke-width="1.5"/>
5
+ <g transform="translate(2.92 13.29) scale(1.17)">
6
+ <polyline points="3,6 15,16 3,26" fill="none" stroke="#3ddc84" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
7
+ <path d="M24.55 7.94L27.94 7.94L27.96 7.98L30.18 18.13L30.69 20.74L30.73 20.72L31.22 17.8L33.31 7.98L33.33 7.94L36.63 7.94L32.69 23.98L28.53 23.98ZM43.51 7.72L44.59 7.74L45.8 7.96L46.42 8.18L46.95 8.45L47.56 8.86L48.2 9.48L48.68 10.18L49.06 11.11L49.23 12.21L49.23 12.54L45.96 12.54L45.93 12.12L45.85 11.75L45.54 11.2L45.25 10.93L44.9 10.73L44.15 10.56L43.43 10.58L42.85 10.73L42.37 11.04L42.15 11.28L41.95 11.64L41.8 12.3L41.8 19.62L41.97 20.33L42.28 20.79L42.63 21.07L43.27 21.32L44.15 21.36L44.57 21.29L44.9 21.18L45.27 20.96L45.65 20.57L45.89 20.02L45.96 19.38L49.23 19.38L49.15 20.41L48.84 21.4L48.4 22.17L47.72 22.92L47.06 23.41L46.13 23.85L45.23 24.09L44.26 24.2L42.99 24.15L41.97 23.96L40.94 23.54L40.17 23.03L39.64 22.53L39.36 22.17L38.89 21.36L38.63 20.52L38.52 19.71L38.52 12.19L38.61 11.48L38.87 10.6L39.18 10.01L39.58 9.46L40.17 8.89L40.74 8.49L41.4 8.16L41.97 7.96L42.66 7.81Z" fill="#3ddc84"/>
8
+ </g>
9
+ </svg>
Binary file
Binary file
@@ -0,0 +1,95 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="theme-dark">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <meta name="robots" content="noindex, nofollow">
8
+ <meta name="referrer" content="no-referrer">
9
+ <title>ViewCounter</title>
10
+ <link rel="icon" href="favicon.ico" sizes="any">
11
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
12
+ <link rel="apple-touch-icon" href="apple-touch-icon.png">
13
+ <link rel="manifest" href="site.webmanifest">
14
+ <link rel="stylesheet" href="css/admin.css">
15
+ <script type="module" src="js/main.js"></script>
16
+ </head>
17
+
18
+ <body class="booting">
19
+ <div class="bg-grid" aria-hidden="true"></div>
20
+ <div class="bg-glow" aria-hidden="true"></div>
21
+ <div class="crt-overlay" aria-hidden="true"></div>
22
+
23
+ <a class="skip-link" href="#main" data-i18n="nav.skip"></a>
24
+
25
+ <nav class="topbar">
26
+ <div class="logo">
27
+ <svg class="logo-mark" viewBox="0 0 96 32" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
28
+ <polyline points="3,6 15,16 3,26" fill="none" stroke="currentColor" stroke-width="5"
29
+ stroke-linecap="round" stroke-linejoin="round" />
30
+ <path
31
+ d="M24.55 7.94L27.94 7.94L27.96 7.98L30.18 18.13L30.69 20.74L30.73 20.72L31.22 17.8L33.31 7.98L33.33 7.94L36.63 7.94L32.69 23.98L28.53 23.98ZM43.51 7.72L44.59 7.74L45.8 7.96L46.42 8.18L46.95 8.45L47.56 8.86L48.2 9.48L48.68 10.18L49.06 11.11L49.23 12.21L49.23 12.54L45.96 12.54L45.93 12.12L45.85 11.75L45.54 11.2L45.25 10.93L44.9 10.73L44.15 10.56L43.43 10.58L42.85 10.73L42.37 11.04L42.15 11.28L41.95 11.64L41.8 12.3L41.8 19.62L41.97 20.33L42.28 20.79L42.63 21.07L43.27 21.32L44.15 21.36L44.57 21.29L44.9 21.18L45.27 20.96L45.65 20.57L45.89 20.02L45.96 19.38L49.23 19.38L49.15 20.41L48.84 21.4L48.4 22.17L47.72 22.92L47.06 23.41L46.13 23.85L45.23 24.09L44.26 24.2L42.99 24.15L41.97 23.96L40.94 23.54L40.17 23.03L39.64 22.53L39.36 22.17L38.89 21.36L38.63 20.52L38.52 19.71L38.52 12.19L38.61 11.48L38.87 10.6L39.18 10.01L39.58 9.46L40.17 8.89L40.74 8.49L41.4 8.16L41.97 7.96L42.66 7.81Z"
32
+ fill="currentColor" />
33
+ <rect class="logo-cursor" x="70" y="7" width="10" height="19" fill="currentColor" />
34
+ </svg>
35
+ <span class="logo-text" data-i18n="app.name"></span>
36
+ <span class="logo-badge" data-i18n="app.badge"></span>
37
+ <span class="version-badge" id="app-version" hidden></span>
38
+ </div>
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>
44
+ <div class="theme-switcher" role="group" data-i18n-attr="aria-label:theme.label">
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>
51
+ </div>
52
+ <button class="btn btn-secondary btn-small" id="logout-btn" type="button" hidden
53
+ data-icon="signOut"><span data-i18n="nav.logout"></span></button>
54
+ </div>
55
+ </nav>
56
+
57
+ <main id="main">
58
+ <section id="login-screen" class="login" hidden>
59
+ <div class="term-window login-window">
60
+ <div class="term-titlebar">
61
+ <div class="term-dots" aria-hidden="true">
62
+ <span class="term-dot red"></span>
63
+ <span class="term-dot amber"></span>
64
+ <span class="term-dot green"></span>
65
+ </div>
66
+ <span class="term-title" data-i18n="login.windowTitle"></span>
67
+ </div>
68
+ <form id="login-form" class="term-body login-form" novalidate>
69
+ <h1 class="login-heading">
70
+ <span class="prompt-char" aria-hidden="true">❯</span>
71
+ <span data-i18n="login.heading"></span>
72
+ </h1>
73
+ <p class="login-intro" data-i18n="login.intro"></p>
74
+ <label class="field-label" for="login-password" data-i18n="login.password"></label>
75
+ <input class="input" id="login-password" name="password" type="password"
76
+ autocomplete="current-password" required>
77
+ <p id="login-error" class="field-error" role="alert" hidden></p>
78
+ <button class="btn btn-primary" id="login-submit" type="submit" data-i18n="login.submit"></button>
79
+ </form>
80
+ </div>
81
+ </section>
82
+
83
+ <section id="app-screen" class="app-screen" hidden>
84
+ <div id="tabs" class="tabs" role="tablist" data-i18n-attr="aria-label:tabs.label"></div>
85
+ <div id="panels"></div>
86
+ </section>
87
+ </main>
88
+
89
+ <footer class="app-footer" id="app-footer" hidden></footer>
90
+
91
+ <div id="modal-root" hidden></div>
92
+ <div id="toast-root" class="toast-root"></div>
93
+ </body>
94
+
95
+ </html>
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Client for the admin API.
3
+ *
4
+ * Holds the session's CSRF token and sends it on every state-changing request.
5
+ * Failures become an ApiError carrying the server's stable `code`, which the
6
+ * UI translates; no server-side message text is ever shown.
7
+ */
8
+
9
+ import { API_BASE, CSRF_HEADER, ERROR_CODE } from './constants.js';
10
+
11
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
12
+ const HTTP_NO_CONTENT = 204;
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']);
17
+
18
+ let csrfToken = null;
19
+ const unauthenticatedListeners = new Set();
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
+
39
+ export class ApiError {
40
+ /** @param {number} status @param {string} code @param {object} [body] */
41
+ constructor(status, code, body = {}) {
42
+ this.status = status;
43
+ this.code = code;
44
+ this.body = body;
45
+ }
46
+ }
47
+
48
+ /** Called whenever the server says the session is gone. */
49
+ export function onUnauthenticated(listener) {
50
+ unauthenticatedListeners.add(listener);
51
+ }
52
+
53
+ export function setCsrfToken(token) {
54
+ csrfToken = token;
55
+ }
56
+
57
+ /**
58
+ * @param {string} method
59
+ * @param {string} path relative to the API base, no leading slash
60
+ * @param {{ body?: object, query?: Record<string, string|number|undefined> }} [options]
61
+ */
62
+ export async function request(method, path, { body, query } = {}, { retried = false } = {}) {
63
+ const params = new URLSearchParams();
64
+ for (const [key, value] of Object.entries(query || {})) {
65
+ if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
66
+ }
67
+ const url = `${API_BASE}/${path}${params.size ? `?${params}` : ''}`;
68
+
69
+ const headers = { Accept: 'application/json' };
70
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
71
+ if (!SAFE_METHODS.has(method) && csrfToken) headers[CSRF_HEADER] = csrfToken;
72
+
73
+ let response;
74
+ try {
75
+ response = await fetch(url, {
76
+ method,
77
+ headers,
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',
82
+ body: body === undefined ? undefined : JSON.stringify(body),
83
+ });
84
+ } catch {
85
+ throw new ApiError(0, ERROR_CODE.NETWORK);
86
+ }
87
+ if (response.type === 'opaqueredirect') throw new ApiError(0, ERROR_CODE.ACCESS_EXPIRED);
88
+
89
+ if (response.status === HTTP_NO_CONTENT) return null;
90
+
91
+ let payload = {};
92
+ try {
93
+ payload = await response.json();
94
+ } catch {
95
+ payload = {};
96
+ }
97
+
98
+ if (!response.ok) {
99
+ const code = payload.code || ERROR_CODE.SERVER_ERROR;
100
+ const retry = () => request(method, path, { body, query }, { retried: true });
101
+
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();
105
+ for (const listener of unauthenticatedListeners) listener();
106
+ }
107
+ if (response.status === HTTP_FORBIDDEN && code === ERROR_CODE.REAUTH_REQUIRED
108
+ && !retried && recovery.confirmPassword && await recovery.confirmPassword()) {
109
+ return retry();
110
+ }
111
+ throw new ApiError(response.status, code, payload);
112
+ }
113
+ return payload;
114
+ }
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
+
123
+ export const api = {
124
+ session: () => request('GET', 'session'),
125
+ login: (password) => request('POST', 'login', { body: { password } }),
126
+ reauth: (password) => request('POST', 'reauth', { body: { password } }),
127
+ logout: () => request('POST', 'logout'),
128
+ meta: () => request('GET', 'meta'),
129
+ apps: () => request('GET', 'apps'),
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'),
138
+ edit: (appId, ids, changes) => request('PATCH', `apps/${encodeURIComponent(appId)}/views`, { body: { ids, changes } }),
139
+ note: (appId, ids, note) => request('PUT', `apps/${encodeURIComponent(appId)}/views/note`, { body: { ids, note } }),
140
+ remove: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/delete`, { body: { ids } }),
141
+ restore: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/restore`, { body: { ids } }),
142
+ purge: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/purge`, { body: { ids } }),
143
+ adminLog: (query) => request('GET', 'logs/admin', { query }),
144
+ trackingLog: (query) => request('GET', 'logs/tracking', { query }),
145
+ trackingSummary: (query) => request('GET', 'logs/tracking/summary', { query }),
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
+ }