@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,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 };
@@ -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 };