@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.
- package/.env.example +20 -0
- package/README.md +174 -4
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +1875 -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 +89 -0
- package/admin/js/api.js +100 -0
- package/admin/js/charts.js +502 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +150 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +79 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/insights.js +192 -0
- package/admin/js/listbox.js +144 -0
- package/admin/js/logs.js +167 -0
- package/admin/js/main.js +235 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/table.js +134 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +208 -0
- package/admin/js/views.js +685 -0
- package/admin/locales/en.json +394 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +83 -0
- package/constants.js +215 -2
- package/db/AdminRepository.js +562 -0
- package/db/DatabaseManager.js +94 -20
- package/db/LogRepository.js +217 -0
- package/db/adminSchema.js +244 -0
- package/db/retention.js +97 -0
- package/index.js +39 -6
- package/middleware/adminAuth.js +204 -0
- package/middleware/adminValidation.js +253 -0
- package/package.json +16 -9
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- package/utils/errorUtils.js +35 -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
|
package/admin/index.html
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="topbar-actions">
|
|
40
|
+
<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>
|
|
47
|
+
</div>
|
|
48
|
+
<button class="btn btn-secondary btn-small" id="logout-btn" type="button" hidden
|
|
49
|
+
data-i18n="nav.logout"></button>
|
|
50
|
+
</div>
|
|
51
|
+
</nav>
|
|
52
|
+
|
|
53
|
+
<main id="main">
|
|
54
|
+
<section id="login-screen" class="login" hidden>
|
|
55
|
+
<div class="term-window login-window">
|
|
56
|
+
<div class="term-titlebar">
|
|
57
|
+
<div class="term-dots" aria-hidden="true">
|
|
58
|
+
<span class="term-dot red"></span>
|
|
59
|
+
<span class="term-dot amber"></span>
|
|
60
|
+
<span class="term-dot green"></span>
|
|
61
|
+
</div>
|
|
62
|
+
<span class="term-title" data-i18n="login.windowTitle"></span>
|
|
63
|
+
</div>
|
|
64
|
+
<form id="login-form" class="term-body login-form" novalidate>
|
|
65
|
+
<h1 class="login-heading">
|
|
66
|
+
<span class="prompt-char" aria-hidden="true">❯</span>
|
|
67
|
+
<span data-i18n="login.heading"></span>
|
|
68
|
+
</h1>
|
|
69
|
+
<p class="login-intro" data-i18n="login.intro"></p>
|
|
70
|
+
<label class="field-label" for="login-password" data-i18n="login.password"></label>
|
|
71
|
+
<input class="input" id="login-password" name="password" type="password"
|
|
72
|
+
autocomplete="current-password" required>
|
|
73
|
+
<p id="login-error" class="field-error" role="alert" hidden></p>
|
|
74
|
+
<button class="btn btn-primary" id="login-submit" type="submit" data-i18n="login.submit"></button>
|
|
75
|
+
</form>
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<section id="app-screen" class="app-screen" hidden>
|
|
80
|
+
<div id="tabs" class="tabs" role="tablist" data-i18n-attr="aria-label:tabs.label"></div>
|
|
81
|
+
<div id="panels"></div>
|
|
82
|
+
</section>
|
|
83
|
+
</main>
|
|
84
|
+
|
|
85
|
+
<div id="modal-root" hidden></div>
|
|
86
|
+
<div id="toast-root" class="toast-root"></div>
|
|
87
|
+
</body>
|
|
88
|
+
|
|
89
|
+
</html>
|
package/admin/js/api.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
|
|
15
|
+
let csrfToken = null;
|
|
16
|
+
const unauthenticatedListeners = new Set();
|
|
17
|
+
|
|
18
|
+
export class ApiError {
|
|
19
|
+
/** @param {number} status @param {string} code @param {object} [body] */
|
|
20
|
+
constructor(status, code, body = {}) {
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.body = body;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Called whenever the server says the session is gone. */
|
|
28
|
+
export function onUnauthenticated(listener) {
|
|
29
|
+
unauthenticatedListeners.add(listener);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setCsrfToken(token) {
|
|
33
|
+
csrfToken = token;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} method
|
|
38
|
+
* @param {string} path relative to the API base, no leading slash
|
|
39
|
+
* @param {{ body?: object, query?: Record<string, string|number|undefined> }} [options]
|
|
40
|
+
*/
|
|
41
|
+
export async function request(method, path, { body, query } = {}) {
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
44
|
+
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
|
45
|
+
}
|
|
46
|
+
const url = `${API_BASE}/${path}${params.size ? `?${params}` : ''}`;
|
|
47
|
+
|
|
48
|
+
const headers = { Accept: 'application/json' };
|
|
49
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
50
|
+
if (!SAFE_METHODS.has(method) && csrfToken) headers[CSRF_HEADER] = csrfToken;
|
|
51
|
+
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(url, {
|
|
55
|
+
method,
|
|
56
|
+
headers,
|
|
57
|
+
credentials: 'same-origin',
|
|
58
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
throw new ApiError(0, ERROR_CODE.NETWORK);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (response.status === HTTP_NO_CONTENT) return null;
|
|
65
|
+
|
|
66
|
+
let payload = {};
|
|
67
|
+
try {
|
|
68
|
+
payload = await response.json();
|
|
69
|
+
} catch {
|
|
70
|
+
payload = {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
const code = payload.code || ERROR_CODE.SERVER_ERROR;
|
|
75
|
+
if (response.status === HTTP_UNAUTHORIZED && code === ERROR_CODE.UNAUTHENTICATED) {
|
|
76
|
+
for (const listener of unauthenticatedListeners) listener();
|
|
77
|
+
}
|
|
78
|
+
throw new ApiError(response.status, code, payload);
|
|
79
|
+
}
|
|
80
|
+
return payload;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const api = {
|
|
84
|
+
session: () => request('GET', 'session'),
|
|
85
|
+
login: (password) => request('POST', 'login', { body: { password } }),
|
|
86
|
+
logout: () => request('POST', 'logout'),
|
|
87
|
+
meta: () => request('GET', 'meta'),
|
|
88
|
+
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 }),
|
|
93
|
+
edit: (appId, ids, changes) => request('PATCH', `apps/${encodeURIComponent(appId)}/views`, { body: { ids, changes } }),
|
|
94
|
+
note: (appId, ids, note) => request('PUT', `apps/${encodeURIComponent(appId)}/views/note`, { body: { ids, note } }),
|
|
95
|
+
remove: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/delete`, { body: { ids } }),
|
|
96
|
+
restore: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/restore`, { body: { ids } }),
|
|
97
|
+
purge: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/purge`, { body: { ids } }),
|
|
98
|
+
adminLog: (query) => request('GET', 'logs/admin', { query }),
|
|
99
|
+
viewLog: (query) => request('GET', 'logs/views', { query }),
|
|
100
|
+
};
|