@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.
- package/.env.example +115 -0
- package/LICENSE +21 -0
- package/README.md +797 -0
- package/allowed.sample.json +24 -0
- package/config/index.js +362 -0
- package/constants.js +229 -0
- package/db/DatabaseManager.js +704 -0
- package/db/schema.sql +71 -0
- package/dbInfo.sample.json +8 -0
- package/index.js +184 -0
- package/middleware/auth.js +194 -0
- package/middleware/security.js +109 -0
- package/middleware/validation.js +212 -0
- package/package.json +81 -0
- package/routes/analytics.js +456 -0
- package/scripts/setup.js +191 -0
- package/utils/appIdUtils.js +38 -0
- package/utils/errorUtils.js +157 -0
- package/utils/ipUtils.js +63 -0
- package/utils/logger.js +138 -0
- package/utils/privacyUtils.js +107 -0
- package/utils/referrerParser.js +137 -0
- package/utils/secretStore.js +57 -0
- package/utils/stringUtils.js +36 -0
- package/utils/userAgentParser.js +68 -0
|
@@ -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
|
+
}
|