@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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"appId": [
|
|
3
|
+
"example_app_1",
|
|
4
|
+
"example_app_2"
|
|
5
|
+
],
|
|
6
|
+
"deviceSize": [
|
|
7
|
+
"small",
|
|
8
|
+
"medium",
|
|
9
|
+
"large"
|
|
10
|
+
],
|
|
11
|
+
"origins": {
|
|
12
|
+
"example_app_1": [
|
|
13
|
+
"https://example.com",
|
|
14
|
+
"https://www.example.com"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"_comment_apiKeys": "Scoped read keys. Each key maps to the apps it may read, or \"*\" for all. Keys set via READ_API_KEYS are always unscoped. Minimum 32 characters.",
|
|
18
|
+
"apiKeys": {
|
|
19
|
+
"replace-with-32+-char-key-for-tenant-one": [
|
|
20
|
+
"example_app_1"
|
|
21
|
+
],
|
|
22
|
+
"replace-with-32+-char-key-for-the-owner": "*"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/config/index.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
require('dotenv').config({ quiet: true });
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
DATABASE,
|
|
8
|
+
INSECURE_DEFAULTS,
|
|
9
|
+
NODE_ENV,
|
|
10
|
+
PRIVACY,
|
|
11
|
+
SCOPE_ALL,
|
|
12
|
+
SERVER,
|
|
13
|
+
} = require('../constants');
|
|
14
|
+
const { filterValidAppIds } = require('../utils/appIdUtils');
|
|
15
|
+
const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
|
|
16
|
+
const { LogLevel } = require('../utils/logger');
|
|
17
|
+
|
|
18
|
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
19
|
+
const DB_INFO_PATH = path.join(PROJECT_ROOT, 'dbInfo.json');
|
|
20
|
+
const ALLOWED_PATH = path.join(PROJECT_ROOT, 'allowed.json');
|
|
21
|
+
|
|
22
|
+
/** Values the code falls back to when nothing else supplies one. */
|
|
23
|
+
const DEFAULTS = {
|
|
24
|
+
dbInfo: {
|
|
25
|
+
mode: 'connect',
|
|
26
|
+
host: '127.0.0.1',
|
|
27
|
+
port: DATABASE.DEFAULT_PORT,
|
|
28
|
+
database: 'viewcounterdb',
|
|
29
|
+
user: INSECURE_DEFAULTS.DB_USER,
|
|
30
|
+
password: INSECURE_DEFAULTS.DB_PASSWORD,
|
|
31
|
+
},
|
|
32
|
+
allowed: {
|
|
33
|
+
appId: [INSECURE_DEFAULTS.APP_ID],
|
|
34
|
+
deviceSize: ['small', 'medium', 'large'],
|
|
35
|
+
origins: {},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Parse a comma-separated env var into a trimmed, non-empty list. */
|
|
40
|
+
function parseList(raw) {
|
|
41
|
+
if (!raw) return [];
|
|
42
|
+
return String(raw)
|
|
43
|
+
.split(',')
|
|
44
|
+
.map((item) => item.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Parse an integer env var, falling back when absent or unparseable. */
|
|
49
|
+
function parseIntOr(raw, fallback) {
|
|
50
|
+
const parsed = Number.parseInt(raw, 10);
|
|
51
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read and parse a JSON config file.
|
|
56
|
+
* @returns {object|null} null when absent or unparseable
|
|
57
|
+
*/
|
|
58
|
+
function readJsonFile(filePath) {
|
|
59
|
+
if (!fs.existsSync(filePath)) return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
62
|
+
} catch {
|
|
63
|
+
logWarning(WarningType.CONFIG_FILE_UNREADABLE, { file: path.basename(filePath) });
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve one field through the precedence chain, field by field.
|
|
70
|
+
*
|
|
71
|
+
* CONFIG.md §4: never a shallow spread of a nested object. The previous
|
|
72
|
+
* implementation returned the parsed file verbatim, so a `dbInfo.json` missing
|
|
73
|
+
* `host` produced `host: undefined` instead of falling back — and an
|
|
74
|
+
* `allowed.json` missing `appId` produced `undefined`, which then threw on
|
|
75
|
+
* `.join()` at startup.
|
|
76
|
+
*
|
|
77
|
+
* Precedence: config file > environment variable > code default.
|
|
78
|
+
*/
|
|
79
|
+
function resolveField(fileValue, envValue, defaultValue) {
|
|
80
|
+
if (fileValue !== undefined && fileValue !== null && fileValue !== '') return fileValue;
|
|
81
|
+
if (envValue !== undefined && envValue !== null && envValue !== '') return envValue;
|
|
82
|
+
return defaultValue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Unified configuration loader.
|
|
87
|
+
*
|
|
88
|
+
* CONFIG.md §0: this is the only module in the project that reads
|
|
89
|
+
* `process.env`. Everything else imports the resolved object.
|
|
90
|
+
*/
|
|
91
|
+
class Config {
|
|
92
|
+
constructor(env = process.env) {
|
|
93
|
+
this.env = env;
|
|
94
|
+
this.server = this.loadServerConfig();
|
|
95
|
+
this.dbInfo = this.loadDbInfo();
|
|
96
|
+
this.allowed = this.loadAllowed();
|
|
97
|
+
this.auth = this.loadAuthConfig();
|
|
98
|
+
this.privacy = this.loadPrivacyConfig();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Server, logging, and proxy settings.
|
|
103
|
+
*
|
|
104
|
+
* `nodeEnv` defaults to production. It previously defaulted to
|
|
105
|
+
* development, which is also what the setup wizard wrote into `.env` — and
|
|
106
|
+
* ten route handlers echo raw database error text to the caller when the
|
|
107
|
+
* environment is development. A deploy that forgot to set NODE_ENV leaked
|
|
108
|
+
* table names and SQL fragments to anonymous callers.
|
|
109
|
+
*/
|
|
110
|
+
loadServerConfig() {
|
|
111
|
+
const nodeEnv = this.env.NODE_ENV || NODE_ENV.PRODUCTION;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
port: parseIntOr(this.env.PORT, SERVER.DEFAULT_PORT),
|
|
115
|
+
nodeEnv,
|
|
116
|
+
isProduction: nodeEnv === NODE_ENV.PRODUCTION,
|
|
117
|
+
isTest: nodeEnv === NODE_ENV.TEST,
|
|
118
|
+
logLevel: this.env.LOG_LEVEL || (nodeEnv === NODE_ENV.TEST ? LogLevel.SILENT : LogLevel.INFO),
|
|
119
|
+
trustProxy: this.resolveTrustProxy(),
|
|
120
|
+
corsOrigins: parseList(this.env.CORS_ORIGINS),
|
|
121
|
+
rateLimit: {
|
|
122
|
+
windowMs: parseIntOr(this.env.RATE_LIMIT_WINDOW_MS, SERVER.DEFAULT_RATE_LIMIT_WINDOW_MS),
|
|
123
|
+
max: parseIntOr(this.env.RATE_LIMIT_MAX, SERVER.DEFAULT_RATE_LIMIT_MAX),
|
|
124
|
+
// Per-app ceiling on writes, so one tenant cannot exhaust the
|
|
125
|
+
// budget the others depend on.
|
|
126
|
+
perAppMax: parseIntOr(this.env.APP_RATE_LIMIT_MAX, SERVER.DEFAULT_APP_RATE_LIMIT_MAX),
|
|
127
|
+
},
|
|
128
|
+
uniqueVisitorWindowHours: parseIntOr(
|
|
129
|
+
this.env.UNIQUE_VISITOR_WINDOW_HOURS,
|
|
130
|
+
SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS,
|
|
131
|
+
),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the Express `trust proxy` setting.
|
|
137
|
+
*
|
|
138
|
+
* Never returns bare `true`. Trusting every hop lets any caller set
|
|
139
|
+
* `X-Forwarded-For` and be believed, which forges geolocation and rotates
|
|
140
|
+
* the rate-limiter key at will. A hop count or an explicit CIDR list binds
|
|
141
|
+
* the trust to the proxy actually in front of this service.
|
|
142
|
+
*
|
|
143
|
+
* @returns {number|string[]|false}
|
|
144
|
+
*/
|
|
145
|
+
resolveTrustProxy() {
|
|
146
|
+
const raw = this.env.TRUST_PROXY;
|
|
147
|
+
if (raw === undefined || raw === '') return false;
|
|
148
|
+
|
|
149
|
+
const hops = Number.parseInt(raw, 10);
|
|
150
|
+
if (Number.isFinite(hops) && String(hops) === String(raw).trim()) return hops;
|
|
151
|
+
|
|
152
|
+
if (raw === 'true' || raw === '*') {
|
|
153
|
+
logWarning(WarningType.PROXY_TRUST_PERMISSIVE, { value: raw });
|
|
154
|
+
// Downgraded to a single hop rather than honoured as-is.
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return parseList(raw);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Database configuration: dbInfo.json > environment > defaults. */
|
|
162
|
+
loadDbInfo() {
|
|
163
|
+
const file = readJsonFile(DB_INFO_PATH) || {};
|
|
164
|
+
const d = DEFAULTS.dbInfo;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
mode: resolveField(file.mode, this.env.DB_MODE, d.mode),
|
|
168
|
+
host: resolveField(file.host, this.env.DB_HOST, d.host),
|
|
169
|
+
port: parseIntOr(resolveField(file.port, this.env.DB_PORT, d.port), d.port),
|
|
170
|
+
database: resolveField(file.database, this.env.DB_NAME, d.database),
|
|
171
|
+
user: resolveField(file.user, this.env.DB_USER, d.user),
|
|
172
|
+
// Password is the one field where empty is a legitimate explicit
|
|
173
|
+
// value, so it is read directly rather than through resolveField.
|
|
174
|
+
password: file.password ?? this.env.DB_PASSWORD ?? d.password,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Allowed app IDs, device sizes, and per-app origins.
|
|
180
|
+
*
|
|
181
|
+
* `origins` maps an appId to the site origins permitted to write to it.
|
|
182
|
+
* An appId with no entry accepts writes from anywhere, preserving existing
|
|
183
|
+
* behaviour for deployments that have not configured it yet.
|
|
184
|
+
*/
|
|
185
|
+
loadAllowed() {
|
|
186
|
+
const file = readJsonFile(ALLOWED_PATH) || {};
|
|
187
|
+
const d = DEFAULTS.allowed;
|
|
188
|
+
|
|
189
|
+
/** File list > env list > default, each resolved independently. */
|
|
190
|
+
const resolveList = (fileValue, envValue, fallback) => {
|
|
191
|
+
if (Array.isArray(fileValue) && fileValue.length) return fileValue;
|
|
192
|
+
const fromEnv = parseList(envValue);
|
|
193
|
+
return fromEnv.length ? fromEnv : fallback;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
// Filtered because every entry becomes a table identifier; a typo
|
|
198
|
+
// in allowed.json should not reach a CREATE TABLE.
|
|
199
|
+
appId: filterValidAppIds(resolveList(file.appId, this.env.ALLOWED_APP_IDS, d.appId)),
|
|
200
|
+
deviceSize: resolveList(file.deviceSize, this.env.ALLOWED_DEVICE_SIZES, d.deviceSize),
|
|
201
|
+
origins: file.origins && typeof file.origins === 'object' ? file.origins : d.origins,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* API credentials, resolved into a key -> scope map.
|
|
207
|
+
*
|
|
208
|
+
* Two sources, because they serve different deployments:
|
|
209
|
+
* - `READ_API_KEYS` (env): unscoped keys that can read every app. This is
|
|
210
|
+
* the single-operator case — all the apps are yours anyway.
|
|
211
|
+
* - `apiKeys` in allowed.json: `{ "<key>": ["blog"] }`, scoped to named
|
|
212
|
+
* apps. This is the multi-tenant case, where one customer's key must
|
|
213
|
+
* not read another customer's analytics. Use `"*"` for an unscoped key.
|
|
214
|
+
*
|
|
215
|
+
* Admin keys are a separate tier (SECURITY.md §3) and are never implied by
|
|
216
|
+
* a read key, however broadly scoped.
|
|
217
|
+
*/
|
|
218
|
+
loadAuthConfig() {
|
|
219
|
+
const longEnough = (key) => key.length >= PRIVACY.MIN_API_KEY_LENGTH;
|
|
220
|
+
|
|
221
|
+
/** @type {Record<string, string|string[]>} */
|
|
222
|
+
const readKeyScopes = {};
|
|
223
|
+
|
|
224
|
+
for (const key of parseList(this.env.READ_API_KEYS).filter(longEnough)) {
|
|
225
|
+
readKeyScopes[key] = SCOPE_ALL;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const file = readJsonFile(ALLOWED_PATH) || {};
|
|
229
|
+
if (file.apiKeys && typeof file.apiKeys === 'object') {
|
|
230
|
+
for (const [key, scope] of Object.entries(file.apiKeys)) {
|
|
231
|
+
if (!longEnough(key)) {
|
|
232
|
+
logWarning(WarningType.API_KEY_TOO_SHORT, { length: key.length });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (scope === SCOPE_ALL) {
|
|
236
|
+
readKeyScopes[key] = SCOPE_ALL;
|
|
237
|
+
} else if (Array.isArray(scope) && scope.length) {
|
|
238
|
+
readKeyScopes[key] = filterValidAppIds(scope);
|
|
239
|
+
} else {
|
|
240
|
+
logWarning(WarningType.API_KEY_EMPTY_SCOPE, { scope: String(scope) });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const adminApiKeys = parseList(this.env.ADMIN_API_KEYS)
|
|
246
|
+
.filter((key) => key.length >= PRIVACY.MIN_ADMIN_KEY_LENGTH);
|
|
247
|
+
|
|
248
|
+
return { readKeyScopes, adminApiKeys };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Visitor-hash secret.
|
|
253
|
+
*
|
|
254
|
+
* Env var wins so a container can inject it; otherwise it is generated and
|
|
255
|
+
* persisted on first run. Tests get an ephemeral in-memory secret so the
|
|
256
|
+
* suite never writes to disk.
|
|
257
|
+
*/
|
|
258
|
+
loadPrivacyConfig() {
|
|
259
|
+
const secretPath = this.env.VISITOR_SECRET_PATH
|
|
260
|
+
|| path.join(PROJECT_ROOT, PRIVACY.SECRET_FILENAME);
|
|
261
|
+
|
|
262
|
+
if (this.env.VISITOR_SECRET) {
|
|
263
|
+
return { secretPath, visitorSecret: this.env.VISITOR_SECRET };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (this.server.isTest) {
|
|
267
|
+
return {
|
|
268
|
+
secretPath,
|
|
269
|
+
visitorSecret: crypto.randomBytes(PRIVACY.SECRET_BYTES).toString('hex'),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Resolved lazily, on first read rather than at construction.
|
|
274
|
+
//
|
|
275
|
+
// This module is imported by index.js, and index.js is the package
|
|
276
|
+
// entry point — so an application that only wants to mount
|
|
277
|
+
// createAnalyticsRouter would otherwise generate and persist a secret
|
|
278
|
+
// purely as a side effect of `require('@harshankur/viewcounter')`, writing it into
|
|
279
|
+
// node_modules where the next `npm ci` wipes it. Embedders supply their
|
|
280
|
+
// own secret to the router, so for them this never resolves at all.
|
|
281
|
+
// The standalone server forces it during validate(), keeping its
|
|
282
|
+
// fail-fast behaviour.
|
|
283
|
+
let cached = null;
|
|
284
|
+
return {
|
|
285
|
+
secretPath,
|
|
286
|
+
get visitorSecret() {
|
|
287
|
+
if (cached === null) {
|
|
288
|
+
const secretStore = require('../utils/secretStore');
|
|
289
|
+
cached = secretStore.loadOrCreate(secretPath);
|
|
290
|
+
}
|
|
291
|
+
return cached;
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Fail-fast startup validation (CONFIG.md §3, SECURITY.md §1).
|
|
298
|
+
*
|
|
299
|
+
* Refuses to boot a production deployment that is still sitting on the
|
|
300
|
+
* built-in defaults. Previously a completely unconfigured deploy started
|
|
301
|
+
* silently as root@127.0.0.1 with an empty password against appId
|
|
302
|
+
* `example_app`, and looked healthy while doing it.
|
|
303
|
+
*
|
|
304
|
+
* @throws {Error} on the first disqualifying condition
|
|
305
|
+
*/
|
|
306
|
+
validate() {
|
|
307
|
+
// Force the lazy visitor secret to resolve now, so a server that cannot
|
|
308
|
+
// persist it fails at startup rather than on its first request.
|
|
309
|
+
void this.privacy.visitorSecret;
|
|
310
|
+
|
|
311
|
+
if (!this.server.isProduction) {
|
|
312
|
+
this.warnAboutDevelopmentDefaults();
|
|
313
|
+
return this;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (this.dbInfo.user === INSECURE_DEFAULTS.DB_USER
|
|
317
|
+
&& this.dbInfo.password === INSECURE_DEFAULTS.DB_PASSWORD) {
|
|
318
|
+
throw getError(ErrorType.CONFIG_INSECURE_DEFAULT, { field: 'dbInfo.user/password' });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!this.dbInfo.database) {
|
|
322
|
+
throw getError(ErrorType.CONFIG_MISSING_REQUIRED, { field: 'dbInfo.database' });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (this.allowed.appId.length === 1 && this.allowed.appId[0] === INSECURE_DEFAULTS.APP_ID) {
|
|
326
|
+
throw getError(ErrorType.CONFIG_INSECURE_DEFAULT, { field: 'allowed.appId' });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (this.server.corsOrigins.length === 0) {
|
|
330
|
+
throw getError(ErrorType.CONFIG_MISSING_REQUIRED, { field: 'CORS_ORIGINS' });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (Object.keys(this.auth.readKeyScopes).length === 0) {
|
|
334
|
+
// Not fatal: a deployment may legitimately want writes only.
|
|
335
|
+
logWarning(WarningType.READ_API_UNPROTECTED);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return this;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Surface the same problems as warnings outside production. */
|
|
342
|
+
warnAboutDevelopmentDefaults() {
|
|
343
|
+
if (Object.keys(this.auth.readKeyScopes).length === 0) {
|
|
344
|
+
logWarning(WarningType.READ_API_UNPROTECTED);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Config-file presence, for the setup wizard.
|
|
350
|
+
*/
|
|
351
|
+
static hasConfigFiles() {
|
|
352
|
+
return {
|
|
353
|
+
hasDbInfo: fs.existsSync(DB_INFO_PATH),
|
|
354
|
+
hasAllowed: fs.existsSync(ALLOWED_PATH),
|
|
355
|
+
hasEither: fs.existsSync(DB_INFO_PATH) || fs.existsSync(ALLOWED_PATH),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
module.exports = new Config();
|
|
361
|
+
module.exports.Config = Config;
|
|
362
|
+
module.exports.DEFAULTS = DEFAULTS;
|
package/constants.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global constants.
|
|
3
|
+
*
|
|
4
|
+
* Per agent-instructions CODE_STANDARDS.md §0/§1: any literal carrying meaning
|
|
5
|
+
* that is used by more than one module lives here, once. A value typed inline
|
|
6
|
+
* at two call sites is a value that will eventually disagree with itself.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Human-facing product name. Never derived from the repo or package name. */
|
|
10
|
+
const APP_NAME = 'ViewCounter';
|
|
11
|
+
|
|
12
|
+
/** Machine-safe identifier, for storage keys, headers, and log prefixes. */
|
|
13
|
+
const APP_SLUG = 'viewcounter';
|
|
14
|
+
|
|
15
|
+
const HTTP_STATUS = {
|
|
16
|
+
OK: 200,
|
|
17
|
+
BAD_REQUEST: 400,
|
|
18
|
+
UNAUTHORIZED: 401,
|
|
19
|
+
FORBIDDEN: 403,
|
|
20
|
+
UNPROCESSABLE_ENTITY: 422,
|
|
21
|
+
TOO_MANY_REQUESTS: 429,
|
|
22
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
23
|
+
SERVICE_UNAVAILABLE: 503,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maximum accepted length per field, mirroring the column widths in
|
|
28
|
+
* db/schema.sql. Validation rejects anything longer at the boundary so a
|
|
29
|
+
* request can never reach MySQL and fail with a 1406 (strict mode) or be
|
|
30
|
+
* silently truncated (non-strict mode).
|
|
31
|
+
*/
|
|
32
|
+
const FIELD_MAX_LENGTH = {
|
|
33
|
+
MASKED_IP: 45,
|
|
34
|
+
VISITOR_HASH: 64,
|
|
35
|
+
COUNTRY: 2,
|
|
36
|
+
DEVICE_SIZE: 20,
|
|
37
|
+
PAGE_PATH: 500,
|
|
38
|
+
PAGE_TITLE: 200,
|
|
39
|
+
REFERRER: 500,
|
|
40
|
+
REFERRER_DOMAIN: 200,
|
|
41
|
+
SOURCE_TYPE: 20,
|
|
42
|
+
BROWSER: 50,
|
|
43
|
+
BROWSER_VERSION: 20,
|
|
44
|
+
OS: 50,
|
|
45
|
+
OS_VERSION: 20,
|
|
46
|
+
DEVICE_TYPE: 20,
|
|
47
|
+
SESSION_ID: 64,
|
|
48
|
+
EVENT_TYPE: 50,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Bounds for user-supplied pagination and range parameters. */
|
|
52
|
+
const QUERY_LIMITS = {
|
|
53
|
+
VIEWS_LIMIT_MIN: 1,
|
|
54
|
+
VIEWS_LIMIT_MAX: 100,
|
|
55
|
+
VIEWS_LIMIT_DEFAULT: 50,
|
|
56
|
+
OFFSET_MIN: 0,
|
|
57
|
+
OFFSET_MAX: 1_000_000,
|
|
58
|
+
OFFSET_DEFAULT: 0,
|
|
59
|
+
LIST_LIMIT_MIN: 1,
|
|
60
|
+
LIST_LIMIT_MAX: 100,
|
|
61
|
+
LIST_LIMIT_DEFAULT: 20,
|
|
62
|
+
TREND_DAYS_MIN: 1,
|
|
63
|
+
TREND_DAYS_MAX: 365,
|
|
64
|
+
TREND_DAYS_DEFAULT: 30,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Rows returned by the fixed "top N" aggregates. */
|
|
68
|
+
const TOP_N_RESULTS = 10;
|
|
69
|
+
|
|
70
|
+
/** Payload ceilings. Checked before any deep inspection (CODE_STANDARDS §6). */
|
|
71
|
+
const PAYLOAD_LIMITS = {
|
|
72
|
+
/** Total JSON body. Well below body-parser's 100kb default. */
|
|
73
|
+
MAX_BODY_BYTES: 16 * 1024,
|
|
74
|
+
/** Serialized `eventData` blob accepted on POST /event. */
|
|
75
|
+
MAX_EVENT_DATA_BYTES: 4 * 1024,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const DATABASE = {
|
|
79
|
+
CONNECTION_LIMIT: 10,
|
|
80
|
+
/** Finite, so a saturated pool sheds load instead of queueing forever. */
|
|
81
|
+
QUEUE_LIMIT: 50,
|
|
82
|
+
/** Per-statement ceiling; stops one expensive aggregate pinning a worker. */
|
|
83
|
+
QUERY_TIMEOUT_MS: 5_000,
|
|
84
|
+
CONNECT_TIMEOUT_MS: 10_000,
|
|
85
|
+
DEFAULT_PORT: 3306,
|
|
86
|
+
SCHEMA_VERSION: 'enhanced_schema_v3',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const SERVER = {
|
|
90
|
+
DEFAULT_PORT: 3030,
|
|
91
|
+
DEFAULT_RATE_LIMIT_WINDOW_MS: 60_000,
|
|
92
|
+
DEFAULT_RATE_LIMIT_MAX: 100,
|
|
93
|
+
/**
|
|
94
|
+
* Per-app ceiling on the write endpoints, so one tenant's traffic cannot
|
|
95
|
+
* consume the shared budget every other tenant depends on. Sits above the
|
|
96
|
+
* per-IP limit, which stays as the single-abuser backstop.
|
|
97
|
+
*/
|
|
98
|
+
DEFAULT_APP_RATE_LIMIT_MAX: 1_000,
|
|
99
|
+
DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS: 24,
|
|
100
|
+
/** Grace period for in-flight requests before the process exits. */
|
|
101
|
+
SHUTDOWN_TIMEOUT_MS: 10_000,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const PRIVACY = {
|
|
105
|
+
/** Bytes of CSPRNG entropy in the persisted visitor-hash secret. */
|
|
106
|
+
SECRET_BYTES: 32,
|
|
107
|
+
/** Owner-only. The secret is what makes visitor hashes irreversible. */
|
|
108
|
+
SECRET_FILE_MODE: 0o600,
|
|
109
|
+
SECRET_FILENAME: '.visitor-secret',
|
|
110
|
+
/** Rejects a key short enough to be guessable. */
|
|
111
|
+
MIN_API_KEY_LENGTH: 32,
|
|
112
|
+
/**
|
|
113
|
+
* Admin keys are a separate tier from read keys (SECURITY.md §3): leaking a
|
|
114
|
+
* tenant's read key must never grant the ability to provision new apps, and
|
|
115
|
+
* revoking one tier must not force rotation of the other.
|
|
116
|
+
*/
|
|
117
|
+
MIN_ADMIN_KEY_LENGTH: 32,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Recognised event types. `pageview` is the only one the server itself emits. */
|
|
121
|
+
const EVENT_TYPE = {
|
|
122
|
+
PAGEVIEW: 'pageview',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Valid `period` values for the trends endpoint. */
|
|
126
|
+
const TREND_PERIOD = {
|
|
127
|
+
HOURLY: 'hourly',
|
|
128
|
+
DAILY: 'daily',
|
|
129
|
+
WEEKLY: 'weekly',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const TREND_PERIODS = Object.values(TREND_PERIOD);
|
|
133
|
+
|
|
134
|
+
/** Traffic classification assigned by utils/referrerParser.js. */
|
|
135
|
+
const SOURCE_TYPE = {
|
|
136
|
+
DIRECT: 'direct',
|
|
137
|
+
SEARCH: 'search',
|
|
138
|
+
SOCIAL: 'social',
|
|
139
|
+
EMAIL: 'email',
|
|
140
|
+
CAMPAIGN: 'campaign',
|
|
141
|
+
REFERRAL: 'referral',
|
|
142
|
+
UNKNOWN: 'unknown',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const DEVICE_TYPE = {
|
|
146
|
+
MOBILE: 'mobile',
|
|
147
|
+
TABLET: 'tablet',
|
|
148
|
+
WEARABLE: 'wearable',
|
|
149
|
+
TV: 'tv',
|
|
150
|
+
CONSOLE: 'console',
|
|
151
|
+
DESKTOP: 'desktop',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const NODE_ENV = {
|
|
155
|
+
DEVELOPMENT: 'development',
|
|
156
|
+
TEST: 'test',
|
|
157
|
+
PRODUCTION: 'production',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Header carrying the read-API credential. */
|
|
161
|
+
const API_KEY_HEADER = 'x-api-key';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Scope value granting a key access to every app.
|
|
165
|
+
* Anything else is an explicit list of app IDs.
|
|
166
|
+
*/
|
|
167
|
+
const SCOPE_ALL = '*';
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* An app ID becomes a MySQL table name, interpolated into DDL and DML because
|
|
171
|
+
* identifiers cannot be bound as parameters. Until now app IDs only ever came
|
|
172
|
+
* from trusted local config; they can now arrive over HTTP from the admin API,
|
|
173
|
+
* so the character set is restricted to what is unambiguously safe as an
|
|
174
|
+
* identifier. This is the gate — not a nicety.
|
|
175
|
+
*
|
|
176
|
+
* Letters, digits, underscore, and hyphen only. A backtick is the sole
|
|
177
|
+
* character that can terminate a quoted identifier, and none of these can;
|
|
178
|
+
* hyphens are permitted because `my-blog` is a normal name and excluding them
|
|
179
|
+
* would break existing deployments for no security benefit.
|
|
180
|
+
*/
|
|
181
|
+
const APP_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
182
|
+
|
|
183
|
+
/** Reserved prefix for the service's own tables (`_migrations`, `_apps`). */
|
|
184
|
+
const RESERVED_TABLE_PREFIX = '_';
|
|
185
|
+
|
|
186
|
+
/** Internal registry of dynamically provisioned apps. */
|
|
187
|
+
const APP_REGISTRY_TABLE = '_apps';
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Owner-only. `dbInfo.json`, `allowed.json`, and `.env` all carry database
|
|
191
|
+
* credentials; the setup wizard used to write them world-readable.
|
|
192
|
+
*/
|
|
193
|
+
const CONFIG_FILE_MODE = 0o600;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Credential values that must never be accepted in a non-development
|
|
197
|
+
* environment. A missing config file used to silently produce exactly these.
|
|
198
|
+
*/
|
|
199
|
+
const INSECURE_DEFAULTS = {
|
|
200
|
+
DB_USER: 'root',
|
|
201
|
+
DB_PASSWORD: '',
|
|
202
|
+
APP_ID: 'example_app',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
APP_NAME,
|
|
207
|
+
APP_SLUG,
|
|
208
|
+
HTTP_STATUS,
|
|
209
|
+
FIELD_MAX_LENGTH,
|
|
210
|
+
QUERY_LIMITS,
|
|
211
|
+
TOP_N_RESULTS,
|
|
212
|
+
PAYLOAD_LIMITS,
|
|
213
|
+
DATABASE,
|
|
214
|
+
SERVER,
|
|
215
|
+
PRIVACY,
|
|
216
|
+
EVENT_TYPE,
|
|
217
|
+
TREND_PERIOD,
|
|
218
|
+
TREND_PERIODS,
|
|
219
|
+
SOURCE_TYPE,
|
|
220
|
+
DEVICE_TYPE,
|
|
221
|
+
NODE_ENV,
|
|
222
|
+
API_KEY_HEADER,
|
|
223
|
+
SCOPE_ALL,
|
|
224
|
+
APP_ID_PATTERN,
|
|
225
|
+
RESERVED_TABLE_PREFIX,
|
|
226
|
+
APP_REGISTRY_TABLE,
|
|
227
|
+
CONFIG_FILE_MODE,
|
|
228
|
+
INSECURE_DEFAULTS,
|
|
229
|
+
};
|