@harshankur/viewcounter 3.0.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.
@@ -0,0 +1,212 @@
1
+ const { body, query, param, validationResult } = require('express-validator');
2
+
3
+ const {
4
+ FIELD_MAX_LENGTH,
5
+ HTTP_STATUS,
6
+ PAYLOAD_LIMITS,
7
+ QUERY_LIMITS,
8
+ TREND_PERIODS,
9
+ } = require('../constants');
10
+ const { jsonByteLength } = require('../utils/stringUtils');
11
+ const { isValidAppId } = require('../utils/appIdUtils');
12
+
13
+ /** Bounds the origin list an admin can attach to one app. */
14
+ const MAX_ORIGINS_PER_APP = 20;
15
+
16
+ /**
17
+ * Request validation.
18
+ *
19
+ * Every externally-supplied value is bounded here, at the boundary
20
+ * (CODE_STANDARDS.md §6): an allowlist for enum-like fields, an explicit
21
+ * length for anything destined for a fixed-width column, and an integer range
22
+ * for anything that reaches a SQL LIMIT / INTERVAL. Values that fail are
23
+ * rejected with 422 rather than being clamped, so a caller sending nonsense
24
+ * finds out rather than silently getting different data than they asked for.
25
+ */
26
+
27
+ /**
28
+ * appId must be one of the currently allowed apps. This is the table-name gate.
29
+ *
30
+ * Uses `.custom()` reading `allowedValues.appId` per request rather than
31
+ * `.isIn(array)`, which captures the array at chain-build time. Apps can now be
32
+ * registered at runtime through the admin API, and a validator holding a stale
33
+ * snapshot would reject a tenant that exists.
34
+ */
35
+ const appIdParam = (allowedValues) =>
36
+ param('appId')
37
+ .notEmpty().withMessage('appId is required')
38
+ .custom((value) => allowedValues.appId.includes(value)).withMessage('Invalid appId');
39
+
40
+ const appIdQuery = (allowedValues) =>
41
+ query('appId')
42
+ .notEmpty().withMessage('appId is required')
43
+ .custom((value) => allowedValues.appId.includes(value)).withMessage('Invalid appId');
44
+
45
+ /** Optional free-text query parameter, bounded to its column width. */
46
+ const boundedQuery = (name, max) =>
47
+ query(name)
48
+ .optional()
49
+ .isString().withMessage(`${name} must be a string`)
50
+ .isLength({ max }).withMessage(`${name} must be at most ${max} characters`);
51
+
52
+ const boundedBody = (name, max) =>
53
+ body(name)
54
+ .optional()
55
+ .isString().withMessage(`${name} must be a string`)
56
+ .isLength({ max }).withMessage(`${name} must be at most ${max} characters`);
57
+
58
+ /**
59
+ * Bounded integer query parameter. Rejects NaN, negatives, and huge values.
60
+ *
61
+ * Deliberately no `.toInt()` sanitizer: under Express 5 `req.query` is a
62
+ * getter-only property, so a sanitizer appears to work but never writes the
63
+ * coerced value back — the handler would still receive a string and bind it
64
+ * into `LIMIT ?`, which MySQL rejects. Handlers coerce explicitly instead,
65
+ * after this validator has established the value is a valid integer in range.
66
+ */
67
+ const boundedInt = (name, min, max) =>
68
+ query(name)
69
+ .optional()
70
+ .isInt({ min, max }).withMessage(`${name} must be an integer between ${min} and ${max}`);
71
+
72
+ /**
73
+ * Validate registerView request
74
+ */
75
+ const validateRegisterView = (allowedValues) => [
76
+ appIdQuery(allowedValues),
77
+
78
+ query('deviceSize')
79
+ .notEmpty().withMessage('deviceSize is required')
80
+ .isIn(allowedValues.deviceSize).withMessage('Invalid deviceSize'),
81
+
82
+ boundedQuery('page', FIELD_MAX_LENGTH.PAGE_PATH),
83
+ boundedQuery('title', FIELD_MAX_LENGTH.PAGE_TITLE),
84
+ boundedQuery('referrer', FIELD_MAX_LENGTH.REFERRER),
85
+ boundedQuery('sessionId', FIELD_MAX_LENGTH.SESSION_ID),
86
+ ];
87
+
88
+ /**
89
+ * Validate custom event request.
90
+ *
91
+ * This endpoint previously hand-rolled three checks and never went through
92
+ * express-validator at all, so `eventData` was arbitrary unbounded JSON
93
+ * persisted verbatim and no field had a length limit.
94
+ */
95
+ const validateEvent = (allowedValues) => [
96
+ body('appId')
97
+ .notEmpty().withMessage('appId is required')
98
+ .custom((value) => allowedValues.appId.includes(value)).withMessage('Invalid appId'),
99
+
100
+ body('eventType')
101
+ .notEmpty().withMessage('eventType is required')
102
+ .isString().withMessage('eventType must be a string')
103
+ .isLength({ max: FIELD_MAX_LENGTH.EVENT_TYPE })
104
+ .withMessage(`eventType must be at most ${FIELD_MAX_LENGTH.EVENT_TYPE} characters`),
105
+
106
+ boundedBody('page', FIELD_MAX_LENGTH.PAGE_PATH),
107
+ boundedBody('title', FIELD_MAX_LENGTH.PAGE_TITLE),
108
+ boundedBody('sessionId', FIELD_MAX_LENGTH.SESSION_ID),
109
+
110
+ // Size is checked before anything inspects the value's shape.
111
+ body('eventData')
112
+ .optional()
113
+ .custom((value) => jsonByteLength(value) <= PAYLOAD_LIMITS.MAX_EVENT_DATA_BYTES)
114
+ .withMessage(`eventData must serialize to at most ${PAYLOAD_LIMITS.MAX_EVENT_DATA_BYTES} bytes`),
115
+ ];
116
+
117
+ /**
118
+ * Validate stats request
119
+ */
120
+ const validateStatsRequest = (allowedValues) => [appIdParam(allowedValues)];
121
+
122
+ /**
123
+ * Validate trends request
124
+ */
125
+ const validateTrendsRequest = (allowedValues) => [
126
+ appIdParam(allowedValues),
127
+
128
+ query('period')
129
+ .optional()
130
+ .isIn(TREND_PERIODS).withMessage(`period must be one of: ${TREND_PERIODS.join(', ')}`),
131
+
132
+ boundedInt('days', QUERY_LIMITS.TREND_DAYS_MIN, QUERY_LIMITS.TREND_DAYS_MAX),
133
+ ];
134
+
135
+ /**
136
+ * Validate a request taking a plain `limit` (referrers, pages)
137
+ */
138
+ const validateListRequest = (allowedValues) => [
139
+ appIdParam(allowedValues),
140
+ boundedInt('limit', QUERY_LIMITS.LIST_LIMIT_MIN, QUERY_LIMITS.LIST_LIMIT_MAX),
141
+ ];
142
+
143
+ /**
144
+ * Validate views request
145
+ */
146
+ const validateViewsRequest = (allowedValues) => [
147
+ appIdParam(allowedValues),
148
+ boundedInt('limit', QUERY_LIMITS.VIEWS_LIMIT_MIN, QUERY_LIMITS.VIEWS_LIMIT_MAX),
149
+ boundedInt('offset', QUERY_LIMITS.OFFSET_MIN, QUERY_LIMITS.OFFSET_MAX),
150
+ ];
151
+
152
+ /**
153
+ * Validate session lookup
154
+ */
155
+ const validateSessionRequest = (allowedValues) => [
156
+ appIdParam(allowedValues),
157
+
158
+ param('sessionId')
159
+ .notEmpty().withMessage('sessionId is required')
160
+ .isLength({ max: FIELD_MAX_LENGTH.SESSION_ID })
161
+ .withMessage(`sessionId must be at most ${FIELD_MAX_LENGTH.SESSION_ID} characters`),
162
+ ];
163
+
164
+ /**
165
+ * Validate an app-provisioning request.
166
+ *
167
+ * `appId` here becomes a table identifier, so it is checked against the strict
168
+ * pattern rather than an allowlist — there is no allowlist yet, that is the
169
+ * point of the call.
170
+ */
171
+ const validateAppRegistration = () => [
172
+ body('appId')
173
+ .notEmpty().withMessage('appId is required')
174
+ .custom(isValidAppId)
175
+ .withMessage('appId must be 1-64 characters of letters, digits, underscore, or hyphen, and must not start with an underscore'),
176
+
177
+ body('origins')
178
+ .optional()
179
+ .isArray({ max: MAX_ORIGINS_PER_APP })
180
+ .withMessage(`origins must be an array of at most ${MAX_ORIGINS_PER_APP} entries`),
181
+
182
+ body('origins.*')
183
+ .optional()
184
+ .isURL({ require_protocol: true, require_tld: false })
185
+ .withMessage('each origin must be an absolute URL, e.g. https://example.com'),
186
+ ];
187
+
188
+ /**
189
+ * Handle validation errors
190
+ */
191
+ const handleValidationErrors = (req, res, next) => {
192
+ const errors = validationResult(req);
193
+ if (!errors.isEmpty()) {
194
+ return res.status(HTTP_STATUS.UNPROCESSABLE_ENTITY).json({
195
+ message: 'Validation failed',
196
+ errors: errors.array(),
197
+ });
198
+ }
199
+ return next();
200
+ };
201
+
202
+ module.exports = {
203
+ validateAppRegistration,
204
+ validateRegisterView,
205
+ validateEvent,
206
+ validateStatsRequest,
207
+ validateTrendsRequest,
208
+ validateListRequest,
209
+ validateViewsRequest,
210
+ validateSessionRequest,
211
+ handleValidationErrors,
212
+ };
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@harshankur/viewcounter",
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.0.0",
5
+ "main": "index.js",
6
+ "engines": {
7
+ "node": ">=24"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "constants.js",
12
+ "config/",
13
+ "db/",
14
+ "middleware/",
15
+ "routes/",
16
+ "utils/",
17
+ "scripts/setup.js",
18
+ ".env.example",
19
+ "allowed.sample.json",
20
+ "dbInfo.sample.json"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "provenance": true
25
+ },
26
+ "scripts": {
27
+ "start": "node index.js",
28
+ "setup": "node scripts/setup.js",
29
+ "assets": "node scripts/generate-brand-assets.js",
30
+ "lint": "eslint .",
31
+ "test": "npm run lint && jest --coverage --verbose && node scripts/generate-test-report.js",
32
+ "test:watch": "jest --watch",
33
+ "test:ci": "npm run lint && jest --coverage --ci",
34
+ "test:persist": "PERSIST_TEST_DB=true jest --coverage --verbose && node scripts/generate-test-report.js",
35
+ "test:e2e": "bash tests/e2e/matrix.sh",
36
+ "e2e:up": "docker compose -f docker-compose.e2e.yml up -d --wait",
37
+ "e2e:down": "docker compose -f docker-compose.e2e.yml down -v"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "^9.39.5",
41
+ "eslint": "^9.39.5",
42
+ "jest": "^30.4.2",
43
+ "jest-html-reporter": "^4.4.0",
44
+ "supertest": "^7.2.2"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/harshankur/viewcounter.git"
49
+ },
50
+ "keywords": [
51
+ "viewcounter",
52
+ "analytics",
53
+ "tracking",
54
+ "mysql",
55
+ "middleware",
56
+ "privacy-first"
57
+ ],
58
+ "author": "Harsh Ankur",
59
+ "license": "MIT",
60
+ "bugs": {
61
+ "url": "https://github.com/harshankur/viewcounter/issues"
62
+ },
63
+ "homepage": "https://harshankur.github.io/viewcounter/",
64
+ "dependencies": {
65
+ "cors": "^2.8.6",
66
+ "dotenv": "^17.4.2",
67
+ "express": "^5.2.1",
68
+ "express-rate-limit": "^8.6.0",
69
+ "express-validator": "^7.3.2",
70
+ "geoip-country": "^5.0.202607180103",
71
+ "helmet": "^8.3.0",
72
+ "mysql2": "^3.23.0",
73
+ "ua-parser-js": "^2.0.10",
74
+ "url-parse": "^1.5.10"
75
+ },
76
+ "overrides": {
77
+ "geoip-country": {
78
+ "ip-address": "^10.2.0"
79
+ }
80
+ }
81
+ }