@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,456 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const rateLimit = require('express-rate-limit');
|
|
4
|
+
const geoip = require('geoip-country');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
EVENT_TYPE,
|
|
8
|
+
HTTP_STATUS,
|
|
9
|
+
QUERY_LIMITS,
|
|
10
|
+
TREND_PERIOD,
|
|
11
|
+
} = require('../constants');
|
|
12
|
+
const UserAgentParser = require('../utils/userAgentParser');
|
|
13
|
+
const ReferrerParser = require('../utils/referrerParser');
|
|
14
|
+
const PrivacyUtils = require('../utils/privacyUtils');
|
|
15
|
+
const logger = require('../utils/logger');
|
|
16
|
+
const { getClientIp, isValidIP, normalizeIp } = require('../utils/ipUtils');
|
|
17
|
+
const { requireReadApiKey, requireAppScope, requireAdminApiKey, appsInScope } = require('../middleware/auth');
|
|
18
|
+
const { requireRegisteredOrigin, noStore } = require('../middleware/security');
|
|
19
|
+
const {
|
|
20
|
+
validateAppRegistration,
|
|
21
|
+
validateRegisterView,
|
|
22
|
+
validateEvent,
|
|
23
|
+
validateStatsRequest,
|
|
24
|
+
validateTrendsRequest,
|
|
25
|
+
validateListRequest,
|
|
26
|
+
validateViewsRequest,
|
|
27
|
+
validateSessionRequest,
|
|
28
|
+
handleValidationErrors,
|
|
29
|
+
} = require('../middleware/validation');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Analytics routes.
|
|
33
|
+
*
|
|
34
|
+
* Exported as a factory returning an Express Router so the same code can run
|
|
35
|
+
* as a standalone server (index.js) or be mounted into an existing Express
|
|
36
|
+
* application as middleware.
|
|
37
|
+
*
|
|
38
|
+
* Trust model:
|
|
39
|
+
* - WRITE endpoints (/registerView, /event) are public, because the whole
|
|
40
|
+
* point is that a browser on someone else's site can reach them. They are
|
|
41
|
+
* bounded by validation, rate limiting, and per-appId origin binding.
|
|
42
|
+
* - READ endpoints are authenticated. They return another party's analytics
|
|
43
|
+
* and must never have been open.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read an integer query parameter.
|
|
48
|
+
*
|
|
49
|
+
* Validation has already rejected anything non-integer or out of range with a
|
|
50
|
+
* 422, so this only ever coerces a known-good value or applies the default for
|
|
51
|
+
* an absent one. Done here rather than with an express-validator `.toInt()`
|
|
52
|
+
* sanitizer because Express 5 exposes `req.query` as a getter-only property,
|
|
53
|
+
* so sanitizers cannot write the coerced value back.
|
|
54
|
+
*
|
|
55
|
+
* @param {import('express').Request} req
|
|
56
|
+
* @param {string} name
|
|
57
|
+
* @param {number} fallback
|
|
58
|
+
* @returns {number}
|
|
59
|
+
*/
|
|
60
|
+
function intQuery(req, name, fallback) {
|
|
61
|
+
const raw = req.query[name];
|
|
62
|
+
if (raw === undefined || raw === '') return fallback;
|
|
63
|
+
const parsed = Number.parseInt(raw, 10);
|
|
64
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per-app write limiter.
|
|
69
|
+
*
|
|
70
|
+
* Sits alongside the per-IP limiter rather than replacing it. The per-IP limit
|
|
71
|
+
* stops one abusive visitor; this stops one *tenant* consuming the budget every
|
|
72
|
+
* other tenant on the instance depends on, which is the failure mode that
|
|
73
|
+
* matters once the apps belong to different people.
|
|
74
|
+
*
|
|
75
|
+
* Keyed on appId only — never on IP — so it is unaffected by how the client's
|
|
76
|
+
* address is derived, and cannot be rotated away by a caller changing address.
|
|
77
|
+
*
|
|
78
|
+
* @param {{ perAppMax: number, windowMs: number }} rateLimitConfig
|
|
79
|
+
* @returns {import('express').RequestHandler}
|
|
80
|
+
*/
|
|
81
|
+
function buildPerAppLimiter(rateLimitConfig) {
|
|
82
|
+
const { perAppMax, windowMs } = rateLimitConfig || {};
|
|
83
|
+
// Zero disables it, for single-tenant deployments where the per-IP limit
|
|
84
|
+
// is the only bound that means anything.
|
|
85
|
+
if (!perAppMax || perAppMax <= 0) return (req, res, next) => next();
|
|
86
|
+
|
|
87
|
+
return rateLimit({
|
|
88
|
+
windowMs,
|
|
89
|
+
limit: perAppMax,
|
|
90
|
+
standardHeaders: true,
|
|
91
|
+
legacyHeaders: false,
|
|
92
|
+
message: { message: 'This app has exceeded its request budget, please try again later.' },
|
|
93
|
+
// A request with no appId lands in one shared bucket rather than
|
|
94
|
+
// falling back to the IP, which would reintroduce the address-rotation
|
|
95
|
+
// bypass this limiter exists to be immune to.
|
|
96
|
+
keyGenerator: (req) => String(req.query?.appId || req.body?.appId || '__unattributed__'),
|
|
97
|
+
validate: { keyGeneratorIpFallback: false },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Attach a request id used for correlating a client-visible error with the
|
|
103
|
+
* server-side log line that has the real detail.
|
|
104
|
+
*/
|
|
105
|
+
function withRequestId(req, res, next) {
|
|
106
|
+
req.id = crypto.randomUUID();
|
|
107
|
+
res.set('X-Request-Id', req.id);
|
|
108
|
+
next();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the correlation context for a log line.
|
|
113
|
+
*
|
|
114
|
+
* Logs the MASKED address, never the raw one. `logRequest` previously wrote
|
|
115
|
+
* the unmasked IP on every view, event, and error — and on any normal
|
|
116
|
+
* deployment stdout is persisted to disk, so the raw addresses the privacy
|
|
117
|
+
* design goes to lengths to keep out of the database were being written beside
|
|
118
|
+
* it anyway.
|
|
119
|
+
*/
|
|
120
|
+
function logContext(req) {
|
|
121
|
+
const ip = getClientIp(req);
|
|
122
|
+
return { ip: ip ? PrivacyUtils.maskIP(normalizeIp(ip)) : '-', requestId: req.id };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Report a handler failure.
|
|
127
|
+
*
|
|
128
|
+
* The client gets a stable message plus the request id; the detail goes to the
|
|
129
|
+
* server log only. Previously the raw database error text was returned to the
|
|
130
|
+
* caller whenever NODE_ENV was not exactly "development" — which was the
|
|
131
|
+
* default, and which the setup wizard wrote into .env.
|
|
132
|
+
*/
|
|
133
|
+
function handleRouteError(req, res, error, operation) {
|
|
134
|
+
logger.error(`${operation} failed: ${error.message}`, logContext(req));
|
|
135
|
+
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
|
|
136
|
+
message: `Failed to ${operation}`,
|
|
137
|
+
requestId: req.id,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {{ config: object, dbManager: object, isReady: () => boolean }} deps
|
|
143
|
+
* @returns {import('express').Router}
|
|
144
|
+
*/
|
|
145
|
+
function createAnalyticsRouter({ config, dbManager, isReady = () => true }) {
|
|
146
|
+
const router = express.Router();
|
|
147
|
+
// Authentication and authorization are separate steps: `requireKey` proves
|
|
148
|
+
// the caller holds a key we issued, `requireScope` proves that key is
|
|
149
|
+
// entitled to the specific appId in the path. Read routes need both.
|
|
150
|
+
const requireKey = requireReadApiKey(config.auth);
|
|
151
|
+
const requireScope = requireAppScope();
|
|
152
|
+
const requireAdmin = requireAdminApiKey(config.auth);
|
|
153
|
+
const requireOrigin = requireRegisteredOrigin(config.allowed);
|
|
154
|
+
const limitPerApp = buildPerAppLimiter(config.server?.rateLimit);
|
|
155
|
+
|
|
156
|
+
router.use(withRequestId);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Health check. Public, and deliberately reveals nothing but liveness.
|
|
160
|
+
*/
|
|
161
|
+
router.get('/health', noStore, async (req, res) => {
|
|
162
|
+
const dbHealth = await dbManager.healthCheck();
|
|
163
|
+
|
|
164
|
+
if (!isReady() || !dbHealth.healthy) {
|
|
165
|
+
return res.status(HTTP_STATUS.SERVICE_UNAVAILABLE).json({ status: 'unhealthy' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return res.json({ status: 'healthy', uptime: process.uptime() });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Register a page view.
|
|
173
|
+
*/
|
|
174
|
+
router.get('/registerView',
|
|
175
|
+
limitPerApp,
|
|
176
|
+
requireOrigin,
|
|
177
|
+
validateRegisterView(config.allowed),
|
|
178
|
+
handleValidationErrors,
|
|
179
|
+
async (req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
const { appId, deviceSize, page, title, referrer, sessionId } = req.query;
|
|
182
|
+
const ip = normalizeIp(getClientIp(req));
|
|
183
|
+
|
|
184
|
+
if (!isValidIP(ip)) {
|
|
185
|
+
logger.warn(`Rejected request with unparseable client IP`, logContext(req));
|
|
186
|
+
return res.status(HTTP_STATUS.BAD_REQUEST).json({ message: 'Invalid IP address format' });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const ipInfo = geoip.lookup(ip);
|
|
190
|
+
const userAgent = req.get('user-agent') || '';
|
|
191
|
+
const uaData = UserAgentParser.parse(userAgent);
|
|
192
|
+
|
|
193
|
+
const referrerHeader = referrer || req.get('referer') || req.get('referrer');
|
|
194
|
+
const referrerData = ReferrerParser.parse(referrerHeader);
|
|
195
|
+
|
|
196
|
+
const result = await dbManager.registerEvent(appId, {
|
|
197
|
+
ip,
|
|
198
|
+
country: ipInfo?.country || null,
|
|
199
|
+
deviceSize,
|
|
200
|
+
pagePath: page,
|
|
201
|
+
pageTitle: title,
|
|
202
|
+
referrer: referrerData.referrer,
|
|
203
|
+
referrerDomain: referrerData.referrerDomain,
|
|
204
|
+
sourceType: referrerData.sourceType,
|
|
205
|
+
browser: uaData.browser,
|
|
206
|
+
browserVersion: uaData.browserVersion,
|
|
207
|
+
os: uaData.os,
|
|
208
|
+
osVersion: uaData.osVersion,
|
|
209
|
+
deviceType: uaData.deviceType,
|
|
210
|
+
sessionId,
|
|
211
|
+
eventType: EVENT_TYPE.PAGEVIEW,
|
|
212
|
+
userAgent,
|
|
213
|
+
visitorSecret: config.privacy.visitorSecret,
|
|
214
|
+
uniqueWindowHours: config.server.uniqueVisitorWindowHours,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
logger.audit('registerView', { ...logContext(req), appId, duplicate: result.duplicate });
|
|
218
|
+
|
|
219
|
+
if (result.duplicate) {
|
|
220
|
+
return res.status(HTTP_STATUS.OK).json({
|
|
221
|
+
message: 'View already registered recently',
|
|
222
|
+
duplicate: true,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return res.status(HTTP_STATUS.OK).json({ message: 'Success!', duplicate: false });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return handleRouteError(req, res, error, 'register view');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Track a custom event.
|
|
235
|
+
*/
|
|
236
|
+
router.post('/event',
|
|
237
|
+
limitPerApp,
|
|
238
|
+
requireOrigin,
|
|
239
|
+
validateEvent(config.allowed),
|
|
240
|
+
handleValidationErrors,
|
|
241
|
+
async (req, res) => {
|
|
242
|
+
try {
|
|
243
|
+
const { appId, eventType, eventData, sessionId, page, title } = req.body;
|
|
244
|
+
const ip = normalizeIp(getClientIp(req));
|
|
245
|
+
|
|
246
|
+
if (!isValidIP(ip)) {
|
|
247
|
+
return res.status(HTTP_STATUS.BAD_REQUEST).json({ message: 'Invalid IP address format' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const ipInfo = geoip.lookup(ip);
|
|
251
|
+
const userAgent = req.get('user-agent') || '';
|
|
252
|
+
const uaData = UserAgentParser.parse(userAgent);
|
|
253
|
+
|
|
254
|
+
const result = await dbManager.registerEvent(appId, {
|
|
255
|
+
ip,
|
|
256
|
+
country: ipInfo?.country || null,
|
|
257
|
+
deviceSize: UserAgentParser.getDeviceSize(userAgent),
|
|
258
|
+
pagePath: page,
|
|
259
|
+
pageTitle: title,
|
|
260
|
+
browser: uaData.browser,
|
|
261
|
+
browserVersion: uaData.browserVersion,
|
|
262
|
+
os: uaData.os,
|
|
263
|
+
osVersion: uaData.osVersion,
|
|
264
|
+
deviceType: uaData.deviceType,
|
|
265
|
+
sessionId,
|
|
266
|
+
eventType,
|
|
267
|
+
eventData,
|
|
268
|
+
userAgent,
|
|
269
|
+
visitorSecret: config.privacy.visitorSecret,
|
|
270
|
+
// Custom events are never deduplicated.
|
|
271
|
+
uniqueWindowHours: 0,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
logger.audit('trackEvent', { ...logContext(req), appId, eventType });
|
|
275
|
+
|
|
276
|
+
return res.status(HTTP_STATUS.OK).json({
|
|
277
|
+
message: 'Event tracked successfully',
|
|
278
|
+
insertId: result.insertId,
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return handleRouteError(req, res, error, 'track event');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// ---- Read API. Everything below requires a valid key. -------------------
|
|
287
|
+
|
|
288
|
+
router.use(noStore);
|
|
289
|
+
|
|
290
|
+
router.get('/stats/:appId',
|
|
291
|
+
requireKey,
|
|
292
|
+
requireScope,
|
|
293
|
+
validateStatsRequest(config.allowed),
|
|
294
|
+
handleValidationErrors,
|
|
295
|
+
async (req, res) => {
|
|
296
|
+
try {
|
|
297
|
+
const stats = await dbManager.getStats(req.params.appId);
|
|
298
|
+
return res.json({ appId: req.params.appId, stats });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return handleRouteError(req, res, error, 'fetch statistics');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
router.get('/trends/:appId',
|
|
306
|
+
requireKey,
|
|
307
|
+
requireScope,
|
|
308
|
+
validateTrendsRequest(config.allowed),
|
|
309
|
+
handleValidationErrors,
|
|
310
|
+
async (req, res) => {
|
|
311
|
+
try {
|
|
312
|
+
const period = req.query.period || TREND_PERIOD.DAILY;
|
|
313
|
+
const days = intQuery(req, 'days', QUERY_LIMITS.TREND_DAYS_DEFAULT);
|
|
314
|
+
const trends = await dbManager.getTrends(req.params.appId, period, days);
|
|
315
|
+
return res.json({ appId: req.params.appId, period, days, trends });
|
|
316
|
+
} catch (error) {
|
|
317
|
+
return handleRouteError(req, res, error, 'fetch trends');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
router.get('/referrers/:appId',
|
|
323
|
+
requireKey,
|
|
324
|
+
requireScope,
|
|
325
|
+
validateListRequest(config.allowed),
|
|
326
|
+
handleValidationErrors,
|
|
327
|
+
async (req, res) => {
|
|
328
|
+
try {
|
|
329
|
+
const limit = intQuery(req, 'limit', QUERY_LIMITS.LIST_LIMIT_DEFAULT);
|
|
330
|
+
const stats = await dbManager.getReferrerStats(req.params.appId, limit);
|
|
331
|
+
return res.json({ appId: req.params.appId, ...stats });
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return handleRouteError(req, res, error, 'fetch referrer statistics');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
router.get('/browsers/:appId',
|
|
339
|
+
requireKey,
|
|
340
|
+
requireScope,
|
|
341
|
+
validateStatsRequest(config.allowed),
|
|
342
|
+
handleValidationErrors,
|
|
343
|
+
async (req, res) => {
|
|
344
|
+
try {
|
|
345
|
+
const stats = await dbManager.getBrowserStats(req.params.appId);
|
|
346
|
+
return res.json({ appId: req.params.appId, ...stats });
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return handleRouteError(req, res, error, 'fetch browser statistics');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
router.get('/pages/:appId',
|
|
354
|
+
requireKey,
|
|
355
|
+
requireScope,
|
|
356
|
+
validateListRequest(config.allowed),
|
|
357
|
+
handleValidationErrors,
|
|
358
|
+
async (req, res) => {
|
|
359
|
+
try {
|
|
360
|
+
const limit = intQuery(req, 'limit', QUERY_LIMITS.LIST_LIMIT_DEFAULT);
|
|
361
|
+
const pages = await dbManager.getPageStats(req.params.appId, limit);
|
|
362
|
+
return res.json({ appId: req.params.appId, pages });
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return handleRouteError(req, res, error, 'fetch page statistics');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
router.get('/sessions/:appId/:sessionId',
|
|
370
|
+
requireKey,
|
|
371
|
+
requireScope,
|
|
372
|
+
validateSessionRequest(config.allowed),
|
|
373
|
+
handleValidationErrors,
|
|
374
|
+
async (req, res) => {
|
|
375
|
+
try {
|
|
376
|
+
const { appId, sessionId } = req.params;
|
|
377
|
+
const events = await dbManager.getSessionDetails(appId, sessionId);
|
|
378
|
+
return res.json({ appId, sessionId, events, count: events.length });
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return handleRouteError(req, res, error, 'fetch session details');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
router.get('/views/:appId',
|
|
386
|
+
requireKey,
|
|
387
|
+
requireScope,
|
|
388
|
+
validateViewsRequest(config.allowed),
|
|
389
|
+
handleValidationErrors,
|
|
390
|
+
async (req, res) => {
|
|
391
|
+
try {
|
|
392
|
+
const limit = intQuery(req, 'limit', QUERY_LIMITS.VIEWS_LIMIT_DEFAULT);
|
|
393
|
+
const offset = intQuery(req, 'offset', QUERY_LIMITS.OFFSET_DEFAULT);
|
|
394
|
+
const result = await dbManager.getViews(req.params.appId, limit, offset);
|
|
395
|
+
return res.json({ appId: req.params.appId, ...result });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return handleRouteError(req, res, error, 'fetch views');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* List apps visible to the presented key.
|
|
404
|
+
*
|
|
405
|
+
* Filtered by scope, not just authenticated: returning the full list to a
|
|
406
|
+
* tenant-scoped key would disclose every other tenant's existence, which is
|
|
407
|
+
* the same enumeration problem this endpoint had when it was public.
|
|
408
|
+
*/
|
|
409
|
+
router.get('/apps', requireKey, (req, res) => {
|
|
410
|
+
const apps = appsInScope(req.auth.scope, config.allowed.appId);
|
|
411
|
+
res.json({ apps, count: apps.length });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Provision a new app. Admin tier only.
|
|
416
|
+
*
|
|
417
|
+
* Creates the app's table and records it in the registry, then adds it to
|
|
418
|
+
* the live allowlist so it accepts traffic immediately — no restart. The
|
|
419
|
+
* appId becomes a table identifier, so it is validated against a strict
|
|
420
|
+
* pattern before it reaches any DDL.
|
|
421
|
+
*/
|
|
422
|
+
router.post('/apps',
|
|
423
|
+
requireAdmin,
|
|
424
|
+
validateAppRegistration(),
|
|
425
|
+
handleValidationErrors,
|
|
426
|
+
async (req, res) => {
|
|
427
|
+
try {
|
|
428
|
+
const { appId, origins = [] } = req.body;
|
|
429
|
+
const result = await dbManager.registerApp(appId, origins);
|
|
430
|
+
|
|
431
|
+
// Reassigning (not mutating) is fine: the validators read
|
|
432
|
+
// `config.allowed.appId` per request via a custom validator.
|
|
433
|
+
if (!config.allowed.appId.includes(appId)) {
|
|
434
|
+
config.allowed.appId = [...config.allowed.appId, appId];
|
|
435
|
+
}
|
|
436
|
+
if (origins.length) {
|
|
437
|
+
config.allowed.origins[appId] = origins;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
logger.audit('registerApp', { ...logContext(req), appId, created: result.created });
|
|
441
|
+
|
|
442
|
+
return res.status(HTTP_STATUS.OK).json({
|
|
443
|
+
appId,
|
|
444
|
+
created: result.created,
|
|
445
|
+
message: result.created ? 'App registered' : 'App already registered',
|
|
446
|
+
});
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return handleRouteError(req, res, error, 'register app');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
return router;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
module.exports = { createAnalyticsRouter, handleRouteError, logContext, withRequestId };
|
package/scripts/setup.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const { CONFIG_FILE_MODE, PRIVACY } = require('../constants');
|
|
9
|
+
|
|
10
|
+
const rl = readline.createInterface({
|
|
11
|
+
input: process.stdin,
|
|
12
|
+
output: process.stdout
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const question = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
16
|
+
|
|
17
|
+
const dbInfoPath = path.join(__dirname, '..', 'dbInfo.json');
|
|
18
|
+
const allowedPath = path.join(__dirname, '..', 'allowed.json');
|
|
19
|
+
const envPath = path.join(__dirname, '..', '.env');
|
|
20
|
+
|
|
21
|
+
async function setup() {
|
|
22
|
+
console.log('\n🚀 View Counter Backend Setup\n');
|
|
23
|
+
|
|
24
|
+
// Check for existing configuration
|
|
25
|
+
const hasDbInfo = fs.existsSync(dbInfoPath);
|
|
26
|
+
const hasAllowed = fs.existsSync(allowedPath);
|
|
27
|
+
const hasEnv = fs.existsSync(envPath);
|
|
28
|
+
|
|
29
|
+
if (hasDbInfo || hasAllowed || hasEnv) {
|
|
30
|
+
console.log('⚠️ Existing configuration detected:');
|
|
31
|
+
if (hasDbInfo) console.log(' - dbInfo.json');
|
|
32
|
+
if (hasAllowed) console.log(' - allowed.json');
|
|
33
|
+
if (hasEnv) console.log(' - .env');
|
|
34
|
+
console.log('');
|
|
35
|
+
|
|
36
|
+
const action = await question('What would you like to do?\n 1) Overwrite existing config\n 2) Keep existing and exit\n 3) Edit specific files\nChoice (1-3): ');
|
|
37
|
+
|
|
38
|
+
if (action === '2') {
|
|
39
|
+
console.log('✓ Keeping existing configuration. Exiting...');
|
|
40
|
+
rl.close();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (action === '3') {
|
|
45
|
+
await editSpecificFiles(hasDbInfo, hasAllowed, hasEnv);
|
|
46
|
+
rl.close();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// action === '1' continues to full setup
|
|
51
|
+
console.log('\n⚠️ This will overwrite your existing configuration!\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Database Configuration
|
|
55
|
+
console.log('--- Database Configuration ---\n');
|
|
56
|
+
|
|
57
|
+
const mode = await question('Database mode?\n 1) Connect to existing database\n 2) Auto-create database and tables\nChoice (1-2): ');
|
|
58
|
+
const dbMode = mode === '2' ? 'create' : 'connect';
|
|
59
|
+
|
|
60
|
+
const host = await question('Database host (default: 127.0.0.1): ') || '127.0.0.1';
|
|
61
|
+
const port = await question('Database port (default: 3306): ') || '3306';
|
|
62
|
+
const database = await question('Database name (default: viewcounterdb): ') || 'viewcounterdb';
|
|
63
|
+
const user = await question('Database user (default: root): ') || 'root';
|
|
64
|
+
const password = await question('Database password: ');
|
|
65
|
+
|
|
66
|
+
const dbConfig = {
|
|
67
|
+
mode: dbMode,
|
|
68
|
+
host,
|
|
69
|
+
port: parseInt(port, 10),
|
|
70
|
+
database,
|
|
71
|
+
user,
|
|
72
|
+
password
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
fs.writeFileSync(dbInfoPath, JSON.stringify(dbConfig, null, 4), { mode: CONFIG_FILE_MODE });
|
|
76
|
+
fs.chmodSync(dbInfoPath, CONFIG_FILE_MODE);
|
|
77
|
+
console.log('✓ Created dbInfo.json\n');
|
|
78
|
+
|
|
79
|
+
// Allowed Values Configuration
|
|
80
|
+
console.log('--- Allowed Values Configuration ---\n');
|
|
81
|
+
|
|
82
|
+
const appIds = await question('Enter allowed app IDs (comma-separated, e.g., blog,portfolio): ');
|
|
83
|
+
const deviceSizes = await question('Enter allowed device sizes (comma-separated, default: small,medium,large): ') || 'small,medium,large';
|
|
84
|
+
|
|
85
|
+
const allowedConfig = {
|
|
86
|
+
appId: appIds.split(',').map(s => s.trim()).filter(Boolean),
|
|
87
|
+
deviceSize: deviceSizes.split(',').map(s => s.trim()).filter(Boolean)
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
fs.writeFileSync(allowedPath, JSON.stringify(allowedConfig, null, 4), { mode: CONFIG_FILE_MODE });
|
|
91
|
+
fs.chmodSync(allowedPath, CONFIG_FILE_MODE);
|
|
92
|
+
console.log('✓ Created allowed.json\n');
|
|
93
|
+
|
|
94
|
+
// Optional .env file
|
|
95
|
+
const createEnv = await question('Create .env file for additional config? (y/n, default: n): ');
|
|
96
|
+
if (createEnv.toLowerCase() === 'y') {
|
|
97
|
+
const serverPort = await question('Server port (default: 3030): ') || '3030';
|
|
98
|
+
const rateLimitMax = await question('Rate limit max requests per minute (default: 100): ') || '100';
|
|
99
|
+
const uniqueWindow = await question('Unique visitor window in hours (default: 24, 0 to disable): ') || '24';
|
|
100
|
+
const corsOrigins = await question('Allowed browser origins (comma-separated, e.g. https://example.com): ');
|
|
101
|
+
const generatedApiKey = crypto.randomBytes(PRIVACY.MIN_API_KEY_LENGTH).toString('hex');
|
|
102
|
+
console.log(`\n Generated read API key: ${generatedApiKey}`);
|
|
103
|
+
console.log(' Save it now; send it as the x-api-key header on read endpoints.\n');
|
|
104
|
+
|
|
105
|
+
const envContent = `# Database Mode
|
|
106
|
+
DB_MODE=${dbMode}
|
|
107
|
+
|
|
108
|
+
# Database Connection
|
|
109
|
+
DB_HOST=${host}
|
|
110
|
+
DB_PORT=${port}
|
|
111
|
+
DB_NAME=${database}
|
|
112
|
+
DB_USER=${user}
|
|
113
|
+
DB_PASSWORD=${password}
|
|
114
|
+
|
|
115
|
+
# Server Configuration
|
|
116
|
+
PORT=${serverPort}
|
|
117
|
+
NODE_ENV=production
|
|
118
|
+
|
|
119
|
+
# Rate Limiting
|
|
120
|
+
RATE_LIMIT_WINDOW_MS=60000
|
|
121
|
+
RATE_LIMIT_MAX=${rateLimitMax}
|
|
122
|
+
|
|
123
|
+
# Unique Visitor Tracking (hours)
|
|
124
|
+
UNIQUE_VISITOR_WINDOW_HOURS=${uniqueWindow}
|
|
125
|
+
|
|
126
|
+
# Origins permitted to call the write endpoints from a browser.
|
|
127
|
+
CORS_ORIGINS=${corsOrigins}
|
|
128
|
+
|
|
129
|
+
# Credential for the analytics read endpoints. Generated here; rotate by
|
|
130
|
+
# replacing it. Several may be listed comma-separated so one consumer's key
|
|
131
|
+
# can be revoked without disturbing the others.
|
|
132
|
+
READ_API_KEYS=${generatedApiKey}
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
fs.writeFileSync(envPath, envContent, { mode: CONFIG_FILE_MODE });
|
|
136
|
+
fs.chmodSync(envPath, CONFIG_FILE_MODE);
|
|
137
|
+
console.log('✓ Created .env\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log('✅ Setup complete!\n');
|
|
141
|
+
console.log('Next steps:');
|
|
142
|
+
console.log(' 1. Install dependencies: npm install');
|
|
143
|
+
console.log(' 2. Start the server: npm start');
|
|
144
|
+
console.log('');
|
|
145
|
+
|
|
146
|
+
rl.close();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function editSpecificFiles(hasDbInfo, hasAllowed, _hasEnv) {
|
|
150
|
+
console.log('\nWhich files would you like to reconfigure?');
|
|
151
|
+
|
|
152
|
+
if (hasDbInfo) {
|
|
153
|
+
const editDb = await question(' - Reconfigure dbInfo.json? (y/n): ');
|
|
154
|
+
if (editDb.toLowerCase() === 'y') {
|
|
155
|
+
// Simplified re-config for dbInfo
|
|
156
|
+
const mode = await question('Database mode (connect/create): ') || 'connect';
|
|
157
|
+
const host = await question('Database host: ') || '127.0.0.1';
|
|
158
|
+
const port = await question('Database port: ') || '3306';
|
|
159
|
+
const database = await question('Database name: ') || 'viewcounterdb';
|
|
160
|
+
const user = await question('Database user: ') || 'root';
|
|
161
|
+
const password = await question('Database password: ');
|
|
162
|
+
|
|
163
|
+
fs.writeFileSync(dbInfoPath, JSON.stringify({ mode, host, port: parseInt(port), database, user, password }, null, 4), { mode: CONFIG_FILE_MODE });
|
|
164
|
+
fs.chmodSync(dbInfoPath, CONFIG_FILE_MODE);
|
|
165
|
+
console.log('✓ Updated dbInfo.json');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (hasAllowed) {
|
|
170
|
+
const editAllowed = await question(' - Reconfigure allowed.json? (y/n): ');
|
|
171
|
+
if (editAllowed.toLowerCase() === 'y') {
|
|
172
|
+
const appIds = await question('Allowed app IDs (comma-separated): ');
|
|
173
|
+
const deviceSizes = await question('Allowed device sizes (comma-separated): ');
|
|
174
|
+
|
|
175
|
+
fs.writeFileSync(allowedPath, JSON.stringify({
|
|
176
|
+
appId: appIds.split(',').map(s => s.trim()).filter(Boolean),
|
|
177
|
+
deviceSize: deviceSizes.split(',').map(s => s.trim()).filter(Boolean)
|
|
178
|
+
}, null, 4), { mode: CONFIG_FILE_MODE });
|
|
179
|
+
fs.chmodSync(allowedPath, CONFIG_FILE_MODE);
|
|
180
|
+
console.log('✓ Updated allowed.json');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log('\n✅ Configuration updated!');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
setup().catch(error => {
|
|
188
|
+
console.error('Setup failed:', error);
|
|
189
|
+
rl.close();
|
|
190
|
+
process.exit(1);
|
|
191
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App ID validation.
|
|
3
|
+
*
|
|
4
|
+
* An app ID becomes a MySQL table name. Identifiers cannot be bound as query
|
|
5
|
+
* parameters, so they are interpolated — which is safe only because the value
|
|
6
|
+
* is checked here first. App IDs used to come exclusively from local config;
|
|
7
|
+
* the admin API now accepts them over HTTP, so this is a live injection
|
|
8
|
+
* boundary, not a formatting preference.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { APP_ID_PATTERN, RESERVED_TABLE_PREFIX } = require('../constants');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {unknown} appId
|
|
15
|
+
* @returns {boolean} true when safe to use as a table identifier
|
|
16
|
+
*/
|
|
17
|
+
function isValidAppId(appId) {
|
|
18
|
+
if (typeof appId !== 'string') return false;
|
|
19
|
+
// Reserved for the service's own tables (`_migrations`, `_apps`); allowing
|
|
20
|
+
// one would let a caller collide with or shadow internal state.
|
|
21
|
+
if (appId.startsWith(RESERVED_TABLE_PREFIX)) return false;
|
|
22
|
+
return APP_ID_PATTERN.test(appId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Filter a list down to app IDs safe to use.
|
|
27
|
+
* Applied to config-sourced lists too: a typo in `allowed.json` should not be
|
|
28
|
+
* able to produce a malformed CREATE TABLE.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown[]} appIds
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
function filterValidAppIds(appIds) {
|
|
34
|
+
if (!Array.isArray(appIds)) return [];
|
|
35
|
+
return appIds.filter(isValidAppId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { isValidAppId, filterValidAppIds };
|