@harshankur/viewcounter 3.0.0 → 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 +189 -14
- 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 +17 -10
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- package/utils/errorUtils.js +35 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boundary validation for the admin API (CODE_STANDARDS.md §6).
|
|
3
|
+
*
|
|
4
|
+
* The admin is authenticated, but authenticated is not the same as trusted
|
|
5
|
+
* input: a stolen session, a buggy client, or a crafted request all arrive
|
|
6
|
+
* here. Every value is bounded, every enum-like value is checked against an
|
|
7
|
+
* allowlist, and a batch can never exceed ADMIN.MAX_BATCH_IDS rows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { body, param, query, validationResult } = require('express-validator');
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
ADMIN,
|
|
14
|
+
ADMIN_ACTION,
|
|
15
|
+
ADMIN_RANGE,
|
|
16
|
+
ADMIN_ERROR_CODE,
|
|
17
|
+
ADMIN_SORT_COLUMNS,
|
|
18
|
+
EDITABLE_FIELDS,
|
|
19
|
+
FIELD_MAX_LENGTH,
|
|
20
|
+
HTTP_STATUS,
|
|
21
|
+
MODIFIED_FILTER,
|
|
22
|
+
PAYLOAD_LIMITS,
|
|
23
|
+
SORT_ORDER,
|
|
24
|
+
UUID_PATTERN,
|
|
25
|
+
VIEW_LOG_SOURCE,
|
|
26
|
+
VIEW_STATUS,
|
|
27
|
+
} = require('../constants');
|
|
28
|
+
const ReferrerParser = require('../utils/referrerParser');
|
|
29
|
+
const { jsonByteLength } = require('../utils/stringUtils');
|
|
30
|
+
|
|
31
|
+
const within = (values) => (value) => Object.values(values).includes(value);
|
|
32
|
+
|
|
33
|
+
/** appId path parameter: must be a currently allowed app. */
|
|
34
|
+
const adminAppIdParam = (allowed) =>
|
|
35
|
+
param('appId')
|
|
36
|
+
.custom((value) => allowed.appId.includes(value)).withMessage('Unknown appId');
|
|
37
|
+
|
|
38
|
+
/** Optional appId filter on a log listing. */
|
|
39
|
+
const adminAppIdFilter = (allowed) =>
|
|
40
|
+
query('appId')
|
|
41
|
+
.optional()
|
|
42
|
+
.custom((value) => allowed.appId.includes(value)).withMessage('Unknown appId');
|
|
43
|
+
|
|
44
|
+
const pageQuery = () =>
|
|
45
|
+
query('page')
|
|
46
|
+
.optional()
|
|
47
|
+
.isInt({ min: 1, max: ADMIN.PAGE_MAX }).withMessage(`page must be an integer between 1 and ${ADMIN.PAGE_MAX}`);
|
|
48
|
+
|
|
49
|
+
const pageSizeQuery = () =>
|
|
50
|
+
query('pageSize')
|
|
51
|
+
.optional()
|
|
52
|
+
.custom((value) => ADMIN.PAGE_SIZES.includes(Number(value)))
|
|
53
|
+
.withMessage(`pageSize must be one of: ${ADMIN.PAGE_SIZES.join(', ')}`);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `ids`: a non-empty array of distinct UUIDs, at most MAX_BATCH_IDS long.
|
|
57
|
+
* Checked as a whole before any element is inspected, so an enormous array is
|
|
58
|
+
* rejected on its length rather than walked.
|
|
59
|
+
*/
|
|
60
|
+
const idsBody = () =>
|
|
61
|
+
body('ids')
|
|
62
|
+
.isArray({ min: 1, max: ADMIN.MAX_BATCH_IDS })
|
|
63
|
+
.withMessage(`ids must be an array of 1 to ${ADMIN.MAX_BATCH_IDS} view IDs`)
|
|
64
|
+
.bail()
|
|
65
|
+
.custom((ids) => ids.every((id) => typeof id === 'string' && UUID_PATTERN.test(id)))
|
|
66
|
+
.withMessage('every id must be a UUID')
|
|
67
|
+
.bail()
|
|
68
|
+
.custom((ids) => new Set(ids).size === ids.length)
|
|
69
|
+
.withMessage('ids must not repeat');
|
|
70
|
+
|
|
71
|
+
const validateLogin = () => [
|
|
72
|
+
body('password')
|
|
73
|
+
.isString().withMessage('password is required')
|
|
74
|
+
.isLength({ min: 1, max: ADMIN.MAX_PASSWORD_INPUT_LENGTH })
|
|
75
|
+
.withMessage('password is required'),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Filters shared by a listing and its analysis, for one app or all. */
|
|
79
|
+
const filterQueries = () => [
|
|
80
|
+
query('status').optional().custom(within(VIEW_STATUS)).withMessage('Invalid status'),
|
|
81
|
+
query('modified').optional().custom(within(MODIFIED_FILTER)).withMessage('Invalid modified filter'),
|
|
82
|
+
query('range').optional().custom(within(ADMIN_RANGE)).withMessage('Invalid range'),
|
|
83
|
+
query('eventType')
|
|
84
|
+
.optional()
|
|
85
|
+
.isString().withMessage('eventType must be a string')
|
|
86
|
+
.isLength({ max: FIELD_MAX_LENGTH.EVENT_TYPE })
|
|
87
|
+
.withMessage(`eventType must be at most ${FIELD_MAX_LENGTH.EVENT_TYPE} characters`),
|
|
88
|
+
query('search')
|
|
89
|
+
.optional()
|
|
90
|
+
.isString().withMessage('search must be a string')
|
|
91
|
+
.isLength({ max: ADMIN.SEARCH_MAX_LENGTH })
|
|
92
|
+
.withMessage(`search must be at most ${ADMIN.SEARCH_MAX_LENGTH} characters`),
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** A listing of one app (with `allowed`) or of every app (without). */
|
|
96
|
+
const validateViewListing = (allowed) => [
|
|
97
|
+
...(allowed ? [adminAppIdParam(allowed)] : []),
|
|
98
|
+
...filterQueries(),
|
|
99
|
+
query('sort').optional().custom((value) => Object.hasOwn(ADMIN_SORT_COLUMNS, value)).withMessage('Invalid sort'),
|
|
100
|
+
query('order').optional().custom(within(SORT_ORDER)).withMessage('Invalid order'),
|
|
101
|
+
pageQuery(),
|
|
102
|
+
pageSizeQuery(),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/** The analysis of one app (with `allowed`) or of every app (without). */
|
|
106
|
+
const validateAnalysis = (allowed) => [
|
|
107
|
+
...(allowed ? [adminAppIdParam(allowed)] : []),
|
|
108
|
+
...filterQueries(),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate one field of an edit and return the column value to store.
|
|
114
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, error: string }}
|
|
115
|
+
*/
|
|
116
|
+
function checkField(field, value, deviceSizes) {
|
|
117
|
+
const optionalText = (max) => {
|
|
118
|
+
if (value === null || value === '') return { ok: true, value: null };
|
|
119
|
+
if (typeof value !== 'string') return { ok: false, error: `${field} must be a string or null` };
|
|
120
|
+
if (value.length > max) return { ok: false, error: `${field} must be at most ${max} characters` };
|
|
121
|
+
return { ok: true, value };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
switch (field) {
|
|
125
|
+
case 'pagePath':
|
|
126
|
+
return optionalText(FIELD_MAX_LENGTH.PAGE_PATH);
|
|
127
|
+
case 'pageTitle':
|
|
128
|
+
return optionalText(FIELD_MAX_LENGTH.PAGE_TITLE);
|
|
129
|
+
case 'referrer':
|
|
130
|
+
return optionalText(FIELD_MAX_LENGTH.REFERRER);
|
|
131
|
+
case 'deviceSize':
|
|
132
|
+
return deviceSizes.includes(value)
|
|
133
|
+
? { ok: true, value }
|
|
134
|
+
: { ok: false, error: `deviceSize must be one of: ${deviceSizes.join(', ')}` };
|
|
135
|
+
case 'eventType':
|
|
136
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
137
|
+
return { ok: false, error: 'eventType must be a non-empty string' };
|
|
138
|
+
}
|
|
139
|
+
if (value.length > FIELD_MAX_LENGTH.EVENT_TYPE) {
|
|
140
|
+
return { ok: false, error: `eventType must be at most ${FIELD_MAX_LENGTH.EVENT_TYPE} characters` };
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, value };
|
|
143
|
+
case 'eventData':
|
|
144
|
+
if (value === null) return { ok: true, value: null };
|
|
145
|
+
if (typeof value !== 'object') return { ok: false, error: 'eventData must be an object, an array, or null' };
|
|
146
|
+
if (jsonByteLength(value) > PAYLOAD_LIMITS.MAX_EVENT_DATA_BYTES) {
|
|
147
|
+
return { ok: false, error: `eventData must serialize to at most ${PAYLOAD_LIMITS.MAX_EVENT_DATA_BYTES} bytes` };
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, value: JSON.stringify(value) };
|
|
150
|
+
default:
|
|
151
|
+
return { ok: false, error: `${field} is not an editable field` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Turn an edit's `changes` object into column values.
|
|
157
|
+
*
|
|
158
|
+
* Only EDITABLE_FIELDS may appear. A change to `referrer` also re-derives
|
|
159
|
+
* `referrer_domain` and `source_type` with the same parser the write path
|
|
160
|
+
* uses, so the three can never disagree.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} changes
|
|
163
|
+
* @param {string[]} deviceSizes
|
|
164
|
+
* @returns {{ ok: true, columns: Record<string, unknown>, fields: string[] } | { ok: false, error: string }}
|
|
165
|
+
*/
|
|
166
|
+
function resolveChanges(changes, deviceSizes) {
|
|
167
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
|
|
168
|
+
return { ok: false, error: 'changes must be an object' };
|
|
169
|
+
}
|
|
170
|
+
const fields = Object.keys(changes);
|
|
171
|
+
if (fields.length === 0) return { ok: false, error: 'changes must name at least one field' };
|
|
172
|
+
|
|
173
|
+
const columns = {};
|
|
174
|
+
for (const field of fields) {
|
|
175
|
+
if (!Object.hasOwn(EDITABLE_FIELDS, field)) {
|
|
176
|
+
return { ok: false, error: `${field} is not an editable field` };
|
|
177
|
+
}
|
|
178
|
+
const result = checkField(field, changes[field], deviceSizes);
|
|
179
|
+
if (!result.ok) return result;
|
|
180
|
+
columns[EDITABLE_FIELDS[field]] = result.value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Object.hasOwn(changes, 'referrer')) {
|
|
184
|
+
const parsed = ReferrerParser.parse(columns.referrer);
|
|
185
|
+
columns.referrer = parsed.referrer;
|
|
186
|
+
columns.referrer_domain = parsed.referrerDomain;
|
|
187
|
+
columns.source_type = parsed.sourceType;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ok: true, columns, fields };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const validateEdit = (allowed) => [
|
|
194
|
+
adminAppIdParam(allowed),
|
|
195
|
+
idsBody(),
|
|
196
|
+
body('changes')
|
|
197
|
+
.custom((changes, { req }) => {
|
|
198
|
+
const result = resolveChanges(changes, allowed.deviceSize);
|
|
199
|
+
req.resolvedChanges = result;
|
|
200
|
+
return result.ok;
|
|
201
|
+
})
|
|
202
|
+
.withMessage((value, { req }) => req.resolvedChanges?.error),
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const validateNote = (allowed) => [
|
|
206
|
+
adminAppIdParam(allowed),
|
|
207
|
+
idsBody(),
|
|
208
|
+
body('note')
|
|
209
|
+
.custom((note) => note === null || typeof note === 'string').withMessage('note must be a string or null')
|
|
210
|
+
.bail()
|
|
211
|
+
.custom((note) => note === null || note.length <= FIELD_MAX_LENGTH.NOTE)
|
|
212
|
+
.withMessage(`note must be at most ${FIELD_MAX_LENGTH.NOTE} characters`),
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const validateBatch = (allowed) => [adminAppIdParam(allowed), idsBody()];
|
|
216
|
+
|
|
217
|
+
const validateAdminLogListing = (allowed) => [
|
|
218
|
+
pageQuery(),
|
|
219
|
+
pageSizeQuery(),
|
|
220
|
+
adminAppIdFilter(allowed),
|
|
221
|
+
query('action').optional().custom(within(ADMIN_ACTION)).withMessage('Invalid action'),
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const validateViewLogListing = (allowed) => [
|
|
225
|
+
pageQuery(),
|
|
226
|
+
pageSizeQuery(),
|
|
227
|
+
adminAppIdFilter(allowed),
|
|
228
|
+
query('source').optional().custom(within(VIEW_LOG_SOURCE)).withMessage('Invalid source'),
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
/** Admin-shaped validation failure: a stable code plus per-field detail. */
|
|
232
|
+
function handleAdminValidation(req, res, next) {
|
|
233
|
+
const errors = validationResult(req);
|
|
234
|
+
if (errors.isEmpty()) return next();
|
|
235
|
+
return res.status(HTTP_STATUS.UNPROCESSABLE_ENTITY).json({
|
|
236
|
+
code: ADMIN_ERROR_CODE.VALIDATION_FAILED,
|
|
237
|
+
errors: errors.array().map((error) => ({ field: error.path, message: error.msg })),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
validateLogin,
|
|
243
|
+
validateViewListing,
|
|
244
|
+
validateAnalysis,
|
|
245
|
+
validateEdit,
|
|
246
|
+
validateNote,
|
|
247
|
+
validateBatch,
|
|
248
|
+
validateAdminLogListing,
|
|
249
|
+
validateViewLogListing,
|
|
250
|
+
handleAdminValidation,
|
|
251
|
+
resolveChanges,
|
|
252
|
+
checkField,
|
|
253
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harshankur/viewcounter",
|
|
3
3
|
"description": "A middleware backend server that registers views to my db server when requested to register a view from my other projects.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.1.0",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
11
|
"constants.js",
|
|
12
|
+
"admin/",
|
|
12
13
|
"config/",
|
|
13
14
|
"db/",
|
|
14
15
|
"middleware/",
|
|
@@ -27,8 +28,11 @@
|
|
|
27
28
|
"start": "node index.js",
|
|
28
29
|
"setup": "node scripts/setup.js",
|
|
29
30
|
"assets": "node scripts/generate-brand-assets.js",
|
|
31
|
+
"map": "node scripts/generate-world-map.js",
|
|
32
|
+
"admin:demo": "node tests/ui/server.js 4173 --demo",
|
|
30
33
|
"lint": "eslint .",
|
|
31
|
-
"test": "npm run lint && jest --coverage --verbose && node scripts/generate-test-report.js",
|
|
34
|
+
"test": "npm run lint && jest --coverage --verbose && playwright test && node scripts/generate-test-report.js",
|
|
35
|
+
"test:ui": "playwright test",
|
|
32
36
|
"test:watch": "jest --watch",
|
|
33
37
|
"test:ci": "npm run lint && jest --coverage --ci",
|
|
34
38
|
"test:persist": "PERSIST_TEST_DB=true jest --coverage --verbose && node scripts/generate-test-report.js",
|
|
@@ -38,10 +42,15 @@
|
|
|
38
42
|
},
|
|
39
43
|
"devDependencies": {
|
|
40
44
|
"@eslint/js": "^9.39.5",
|
|
45
|
+
"@playwright/test": "^1.63.0",
|
|
46
|
+
"d3-geo": "^3.1.1",
|
|
41
47
|
"eslint": "^9.39.5",
|
|
48
|
+
"i18n-iso-countries": "^7.14.0",
|
|
42
49
|
"jest": "^30.4.2",
|
|
43
50
|
"jest-html-reporter": "^4.4.0",
|
|
44
|
-
"supertest": "^7.2.2"
|
|
51
|
+
"supertest": "^7.2.2",
|
|
52
|
+
"topojson-client": "^3.1.0",
|
|
53
|
+
"world-atlas": "^2.0.2"
|
|
45
54
|
},
|
|
46
55
|
"repository": {
|
|
47
56
|
"type": "git",
|
|
@@ -60,22 +69,20 @@
|
|
|
60
69
|
"bugs": {
|
|
61
70
|
"url": "https://github.com/harshankur/viewcounter/issues"
|
|
62
71
|
},
|
|
63
|
-
"homepage": "https://harshankur.
|
|
72
|
+
"homepage": "https://viewcounter.harshankur.com",
|
|
64
73
|
"dependencies": {
|
|
65
74
|
"cors": "^2.8.6",
|
|
66
75
|
"dotenv": "^17.4.2",
|
|
67
76
|
"express": "^5.2.1",
|
|
68
|
-
"express-rate-limit": "^8.
|
|
77
|
+
"express-rate-limit": "^8.7.0",
|
|
69
78
|
"express-validator": "^7.3.2",
|
|
70
|
-
"geoip-country": "^5.0.
|
|
79
|
+
"geoip-country": "^5.0.202609230144",
|
|
71
80
|
"helmet": "^8.3.0",
|
|
72
|
-
"mysql2": "^3.
|
|
81
|
+
"mysql2": "^3.24.4",
|
|
73
82
|
"ua-parser-js": "^2.0.10",
|
|
74
83
|
"url-parse": "^1.5.10"
|
|
75
84
|
},
|
|
76
85
|
"overrides": {
|
|
77
|
-
"
|
|
78
|
-
"ip-address": "^10.2.0"
|
|
79
|
-
}
|
|
86
|
+
"ip-address": "^10.7.2"
|
|
80
87
|
}
|
|
81
88
|
}
|