@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/db/schema.sql ADDED
@@ -0,0 +1,71 @@
1
+ -- Schema for view counter database
2
+ -- This file is used when DB_MODE=create to auto-initialize the database
3
+
4
+ -- Create database if it doesn't exist
5
+ CREATE DATABASE IF NOT EXISTS `{{DB_NAME}}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
6
+
7
+ USE `{{DB_NAME}}`;
8
+
9
+ -- Migration tracking table
10
+ CREATE TABLE IF NOT EXISTS `_migrations` (
11
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
12
+ `version` VARCHAR(50) NOT NULL UNIQUE,
13
+ `applied_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
14
+ INDEX `idx_version` (`version`)
15
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
16
+
17
+ -- Template for app-specific view tables
18
+ -- Actual tables will be created dynamically based on allowed.json appIds
19
+ -- Table name format: {{APP_ID}}
20
+ CREATE TABLE IF NOT EXISTS `{{APP_ID}}` (
21
+ `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
22
+
23
+ -- Identifiers (Anonymized)
24
+ `masked_ip` VARCHAR(45) NOT NULL,
25
+ `visitor_hash` VARCHAR(64) NOT NULL,
26
+ `country` VARCHAR(2) DEFAULT NULL,
27
+ `timestamp` DATETIME NOT NULL,
28
+ `devicesize` VARCHAR(20) NOT NULL,
29
+
30
+ -- Page tracking
31
+ `page_path` VARCHAR(500) DEFAULT NULL,
32
+ `page_title` VARCHAR(200) DEFAULT NULL,
33
+
34
+ -- Referrer tracking
35
+ `referrer` VARCHAR(500) DEFAULT NULL,
36
+ `referrer_domain` VARCHAR(200) DEFAULT NULL,
37
+ `source_type` VARCHAR(20) DEFAULT NULL, -- direct, search, social, email, campaign, referral
38
+
39
+ -- User agent parsing
40
+ `browser` VARCHAR(50) DEFAULT NULL,
41
+ `browser_version` VARCHAR(20) DEFAULT NULL,
42
+ `os` VARCHAR(50) DEFAULT NULL,
43
+ `os_version` VARCHAR(20) DEFAULT NULL,
44
+ `device_type` VARCHAR(20) DEFAULT NULL, -- mobile, tablet, desktop
45
+
46
+ -- Session tracking
47
+ `session_id` VARCHAR(64) DEFAULT NULL,
48
+
49
+ -- Custom events
50
+ `event_type` VARCHAR(50) DEFAULT 'pageview', -- pageview, click, submit, etc.
51
+ `event_data` JSON DEFAULT NULL,
52
+
53
+ -- Uniqueness tracking
54
+ `is_unique` TINYINT(1) DEFAULT 1, -- 1 if first view in window, 0 otherwise
55
+
56
+ -- Indexes
57
+ INDEX `idx_timestamp` (`timestamp`),
58
+ INDEX `idx_visitor_timestamp` (`visitor_hash`, `timestamp`),
59
+ INDEX `idx_masked_ip` (`masked_ip`),
60
+ INDEX `idx_country` (`country`),
61
+ INDEX `idx_devicesize` (`devicesize`),
62
+ INDEX `idx_page_path` (`page_path`(255)),
63
+ INDEX `idx_referrer_domain` (`referrer_domain`),
64
+ INDEX `idx_source_type` (`source_type`),
65
+ INDEX `idx_browser` (`browser`),
66
+ INDEX `idx_os` (`os`),
67
+ INDEX `idx_device_type` (`device_type`),
68
+ INDEX `idx_session_id` (`session_id`),
69
+ INDEX `idx_event_type` (`event_type`),
70
+ INDEX `idx_is_unique` (`is_unique`)
71
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,8 @@
1
+ {
2
+ "mode": "connect",
3
+ "host": "127.0.0.1",
4
+ "port": 3306,
5
+ "database": "viewcounterdb",
6
+ "user": "root",
7
+ "password": ""
8
+ }
package/index.js ADDED
@@ -0,0 +1,184 @@
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const helmet = require('helmet');
4
+ const rateLimit = require('express-rate-limit');
5
+
6
+ const { APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
7
+ const config = require('./config');
8
+ const DatabaseManager = require('./db/DatabaseManager');
9
+ const logger = require('./utils/logger');
10
+ const { buildCorsOptions } = require('./middleware/security');
11
+ const { createAnalyticsRouter } = require('./routes/analytics');
12
+
13
+ logger.configure({ level: config.server.logLevel });
14
+
15
+ const dbManager = new DatabaseManager(config.dbInfo);
16
+ let isServerReady = false;
17
+ let httpServer = null;
18
+
19
+ /**
20
+ * Build the Express application.
21
+ *
22
+ * Kept separate from the bootstrap below so the same wiring can be exercised
23
+ * by tests and reused by embedders.
24
+ */
25
+ function createApp() {
26
+ const app = express();
27
+
28
+ app.disable('x-powered-by');
29
+ app.use(helmet());
30
+ app.use(cors(buildCorsOptions(config.server.corsOrigins)));
31
+
32
+ // Bounded well below body-parser's 100kb default; /event is the only
33
+ // endpoint taking a body and its payload is small.
34
+ app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
35
+
36
+ // Never bare `true`. Trusting every hop lets any caller set
37
+ // X-Forwarded-For and be believed, which forges geolocation and rotates
38
+ // the rate-limiter key at will.
39
+ app.set('trust proxy', config.server.trustProxy);
40
+
41
+ app.use(rateLimit({
42
+ windowMs: config.server.rateLimit.windowMs,
43
+ limit: config.server.rateLimit.max,
44
+ message: { message: 'Too many requests, please try again later.' },
45
+ standardHeaders: true,
46
+ legacyHeaders: false,
47
+ }));
48
+
49
+ app.use(createAnalyticsRouter({
50
+ config,
51
+ dbManager,
52
+ isReady: () => isServerReady,
53
+ }));
54
+
55
+ // Malformed JSON and payloads over the limit surface here.
56
+ // eslint-disable-next-line no-unused-vars
57
+ app.use((err, req, res, next) => {
58
+ const status = err.status || err.statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR;
59
+ logger.warn(`Request rejected: ${err.message}`, { requestId: req.id });
60
+ res.status(status === HTTP_STATUS.INTERNAL_SERVER_ERROR ? HTTP_STATUS.BAD_REQUEST : status)
61
+ .json({ message: 'Malformed or oversized request' });
62
+ });
63
+
64
+ return app;
65
+ }
66
+
67
+ const app = createApp();
68
+
69
+ /**
70
+ * Fold the database-backed app registry into the in-memory allowlist.
71
+ *
72
+ * Config-declared apps and registry-declared apps are unioned: the file stays
73
+ * authoritative for a fixed single-operator deployment, while the registry
74
+ * carries tenants provisioned at runtime. A registry that cannot be read is a
75
+ * warning rather than a startup failure — config-declared apps still work.
76
+ */
77
+ async function mergeRegisteredApps() {
78
+ try {
79
+ const registered = await dbManager.listRegisteredApps();
80
+ const merged = new Set([...config.allowed.appId, ...registered]);
81
+ config.allowed.appId = [...merged];
82
+
83
+ const origins = await dbManager.loadRegisteredOrigins();
84
+ for (const [appId, list] of Object.entries(origins)) {
85
+ // allowed.json wins, so an operator can override a tenant's own
86
+ // origin list without editing the database.
87
+ if (!config.allowed.origins[appId]) config.allowed.origins[appId] = list;
88
+ }
89
+
90
+ if (registered.length) {
91
+ logger.info(`Loaded ${registered.length} registered app(s) from the database`);
92
+ }
93
+ } catch (error) {
94
+ logger.warn(`Could not read the app registry: ${error.message}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate config, connect the database, and start listening.
100
+ */
101
+ const initializeServer = async () => {
102
+ try {
103
+ config.validate();
104
+ await dbManager.initialize(config.allowed.appId);
105
+
106
+ // Merge dynamically registered tenants into the live allowlist, so
107
+ // apps provisioned through the admin API survive a restart without
108
+ // anyone editing allowed.json.
109
+ await mergeRegisteredApps();
110
+
111
+ isServerReady = true;
112
+
113
+ if (require.main === module) {
114
+ httpServer = app.listen(config.server.port, () => {
115
+ logger.info(`${APP_NAME} listening on port ${config.server.port}`);
116
+ logger.info(`Database mode: ${config.dbInfo.mode}`);
117
+ logger.info(`Allowed apps: ${config.allowed.appId.join(', ')}`);
118
+ });
119
+ }
120
+ } catch (error) {
121
+ logger.error(`Failed to start: ${error.message}`);
122
+ if (require.main === module) process.exit(1);
123
+ }
124
+ };
125
+
126
+ // Only when run directly. Requiring this module as a library — to mount
127
+ // createAnalyticsRouter into an existing app — must not validate config,
128
+ // connect to a database, or bind a port as a side effect of the import.
129
+ if (require.main === module) {
130
+ initializeServer();
131
+ }
132
+
133
+ /**
134
+ * Graceful shutdown.
135
+ *
136
+ * Closes the HTTP listener first so in-flight requests can finish; previously
137
+ * the listener was never closed at all, so "graceful" covered only the
138
+ * database pool while active requests were severed.
139
+ */
140
+ const shutdown = async (signal, exitCode = 0) => {
141
+ logger.info(`${signal} received, shutting down gracefully...`);
142
+
143
+ const forceExit = setTimeout(() => {
144
+ logger.error('Shutdown timed out, exiting');
145
+ process.exit(1);
146
+ }, SERVER.SHUTDOWN_TIMEOUT_MS);
147
+ forceExit.unref();
148
+
149
+ try {
150
+ if (httpServer) {
151
+ await new Promise((resolve) => httpServer.close(resolve));
152
+ }
153
+ await dbManager.close();
154
+ // Preserve the caller's code: a crash-triggered shutdown must not
155
+ // report success, or a process manager sees a clean exit and may
156
+ // decline to restart the service.
157
+ process.exit(exitCode);
158
+ } catch (error) {
159
+ logger.error(`Error during shutdown: ${error.message}`);
160
+ process.exit(1);
161
+ }
162
+ };
163
+
164
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
165
+ process.on('SIGINT', () => shutdown('SIGINT'));
166
+
167
+ // A rejected promise with no handler would otherwise terminate the process on
168
+ // modern Node with no log line explaining why.
169
+ process.on('unhandledRejection', (reason) => {
170
+ logger.error(`Unhandled promise rejection: ${reason instanceof Error ? reason.message : reason}`);
171
+ });
172
+
173
+ process.on('uncaughtException', (error) => {
174
+ logger.error(`Uncaught exception: ${error.message}`);
175
+ logger.error(error.stack || '(no stack)');
176
+ shutdown('uncaughtException', 1);
177
+ });
178
+
179
+ module.exports = app;
180
+ module.exports.createApp = createApp;
181
+ module.exports.createAnalyticsRouter = createAnalyticsRouter;
182
+ module.exports.DatabaseManager = DatabaseManager;
183
+ module.exports.dbManager = dbManager;
184
+ module.exports.initializeServer = initializeServer;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Authentication and authorization for the analytics API.
3
+ *
4
+ * Two distinct steps, deliberately separate:
5
+ *
6
+ * requireReadApiKey — *authentication*: is this a key we issued?
7
+ * requireAppScope — *authorization*: may THIS key read THIS app?
8
+ *
9
+ * The second step is what makes the service multi-tenant. Without it a valid
10
+ * key read every tenant's analytics, because the appId allowlist only ever
11
+ * constrained which table was queried, never who was entitled to query it.
12
+ */
13
+
14
+ const crypto = require('crypto');
15
+
16
+ const { API_KEY_HEADER, HTTP_STATUS, SCOPE_ALL } = require('../constants');
17
+
18
+ /**
19
+ * Constant-time string comparison (agent-instructions SECURITY.md §2).
20
+ *
21
+ * A plain `===` returns as soon as two bytes differ, so response timing leaks
22
+ * how many leading characters were correct. The mismatched-length path still
23
+ * performs an equal-cost dummy comparison so "wrong length" and "right length,
24
+ * wrong value" are not distinguishable by timing either.
25
+ *
26
+ * @param {string} a
27
+ * @param {string} b
28
+ * @returns {boolean}
29
+ */
30
+ function safeEqual(a, b) {
31
+ const ab = Buffer.from(String(a));
32
+ const bb = Buffer.from(String(b));
33
+ if (ab.length !== bb.length) {
34
+ crypto.timingSafeEqual(ab, ab);
35
+ return false;
36
+ }
37
+ return crypto.timingSafeEqual(ab, bb);
38
+ }
39
+
40
+ /**
41
+ * Check a presented key against every configured key.
42
+ *
43
+ * Deliberately does not short-circuit on the first match: returning early
44
+ * would make the response time depend on the key's position in the list.
45
+ *
46
+ * @param {string} presented
47
+ * @param {string[]} configured
48
+ * @returns {boolean}
49
+ */
50
+ function matchesAnyKey(presented, configured) {
51
+ let matched = false;
52
+ for (const key of configured) {
53
+ if (safeEqual(presented, key)) matched = true;
54
+ }
55
+ return matched;
56
+ }
57
+
58
+ /**
59
+ * Resolve a presented key to its scope, without short-circuiting.
60
+ *
61
+ * @param {string} presented
62
+ * @param {Record<string, string|string[]>} keyScopes key -> '*' | [appId]
63
+ * @returns {string|string[]|null} the scope, or null when no key matched
64
+ */
65
+ function resolveScope(presented, keyScopes) {
66
+ let scope = null;
67
+ for (const [key, keyScope] of Object.entries(keyScopes)) {
68
+ if (safeEqual(presented, key)) scope = keyScope;
69
+ }
70
+ return scope;
71
+ }
72
+
73
+ /**
74
+ * Does a resolved scope permit this app?
75
+ * @param {string|string[]} scope
76
+ * @param {string} appId
77
+ * @returns {boolean}
78
+ */
79
+ function scopeAllows(scope, appId) {
80
+ if (scope === SCOPE_ALL) return true;
81
+ return Array.isArray(scope) && scope.includes(appId);
82
+ }
83
+
84
+ /**
85
+ * Expand a scope into the concrete list of apps it can see.
86
+ * @param {string|string[]} scope
87
+ * @param {string[]} allApps
88
+ * @returns {string[]}
89
+ */
90
+ function appsInScope(scope, allApps) {
91
+ if (scope === SCOPE_ALL) return [...allApps];
92
+ if (!Array.isArray(scope)) return [];
93
+ return allApps.filter((appId) => scope.includes(appId));
94
+ }
95
+
96
+ /**
97
+ * Authenticate a read request and attach its scope as `req.auth`.
98
+ *
99
+ * Fails closed: with no keys configured the read API is unavailable rather
100
+ * than unprotected, so a deployment that forgets to set them cannot silently
101
+ * serve another party's analytics to the internet.
102
+ *
103
+ * @param {{ readKeyScopes: Record<string, string|string[]> }} authConfig
104
+ * @returns {import('express').RequestHandler}
105
+ */
106
+ function requireReadApiKey(authConfig) {
107
+ const keyScopes = authConfig?.readKeyScopes || {};
108
+
109
+ return (req, res, next) => {
110
+ if (Object.keys(keyScopes).length === 0) {
111
+ return res.status(HTTP_STATUS.SERVICE_UNAVAILABLE).json({
112
+ message: 'Read API is not configured on this server',
113
+ });
114
+ }
115
+
116
+ const presented = req.get(API_KEY_HEADER);
117
+ const scope = presented ? resolveScope(presented, keyScopes) : null;
118
+
119
+ if (scope === null) {
120
+ return res.status(HTTP_STATUS.UNAUTHORIZED).json({
121
+ message: 'Missing or invalid API key',
122
+ });
123
+ }
124
+
125
+ req.auth = { scope };
126
+ return next();
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Authorize the requested `:appId` against the authenticated key's scope.
132
+ *
133
+ * Runs after requireReadApiKey. Returns 403 rather than 404 for an app that
134
+ * exists but is out of scope, and the same 403 for one that does not exist, so
135
+ * the response does not disclose which tenants are registered.
136
+ *
137
+ * @returns {import('express').RequestHandler}
138
+ */
139
+ function requireAppScope() {
140
+ return (req, res, next) => {
141
+ const { appId } = req.params;
142
+ if (!appId) return next();
143
+
144
+ if (!req.auth || !scopeAllows(req.auth.scope, appId)) {
145
+ return res.status(HTTP_STATUS.FORBIDDEN).json({
146
+ message: 'This API key is not authorized for that appId',
147
+ });
148
+ }
149
+
150
+ return next();
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Require an admin credential.
156
+ *
157
+ * A separate tier from read keys (SECURITY.md §3): provisioning apps is a
158
+ * different privilege from reading them, so leaking a tenant's read key must
159
+ * not confer it, and revoking one tier must not force rotating the other.
160
+ *
161
+ * @param {{ adminApiKeys: string[] }} authConfig
162
+ * @returns {import('express').RequestHandler}
163
+ */
164
+ function requireAdminApiKey(authConfig) {
165
+ const keys = Array.isArray(authConfig?.adminApiKeys) ? authConfig.adminApiKeys : [];
166
+
167
+ return (req, res, next) => {
168
+ if (keys.length === 0) {
169
+ return res.status(HTTP_STATUS.SERVICE_UNAVAILABLE).json({
170
+ message: 'Admin API is not configured on this server',
171
+ });
172
+ }
173
+
174
+ const presented = req.get(API_KEY_HEADER);
175
+ if (!presented || !matchesAnyKey(presented, keys)) {
176
+ return res.status(HTTP_STATUS.UNAUTHORIZED).json({
177
+ message: 'Missing or invalid admin key',
178
+ });
179
+ }
180
+
181
+ return next();
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ requireReadApiKey,
187
+ requireAppScope,
188
+ requireAdminApiKey,
189
+ safeEqual,
190
+ matchesAnyKey,
191
+ resolveScope,
192
+ scopeAllows,
193
+ appsInScope,
194
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Transport-level security middleware: CORS, origin binding, cache policy.
3
+ */
4
+
5
+ const { HTTP_STATUS } = require('../constants');
6
+
7
+ /**
8
+ * Build CORS options from an explicit allowlist.
9
+ *
10
+ * agent-instructions SECURITY.md §9: `cors()` with no options is never the
11
+ * default. The wildcard mattered more here than the usual "no credentials, so
12
+ * it's harmless" reasoning suggests, because the read endpoints served real
13
+ * data — `*` made them script-readable from any origin, not merely reachable.
14
+ *
15
+ * @param {string[]} allowedOrigins
16
+ * @returns {import('cors').CorsOptions}
17
+ */
18
+ function buildCorsOptions(allowedOrigins) {
19
+ const allowlist = new Set(allowedOrigins);
20
+
21
+ return {
22
+ origin(origin, callback) {
23
+ // No Origin header: a same-origin request, a server-side caller, or
24
+ // curl. There is no browser to protect in that case.
25
+ if (!origin) return callback(null, true);
26
+ return callback(null, allowlist.has(origin));
27
+ },
28
+ methods: ['GET', 'POST'],
29
+ allowedHeaders: ['Content-Type', 'x-api-key'],
30
+ credentials: false,
31
+ maxAge: 600,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Extract the requesting origin, falling back to the referrer's origin.
37
+ * @returns {string|null}
38
+ */
39
+ function requestOrigin(req) {
40
+ const origin = req.get('origin');
41
+ if (origin) return origin;
42
+
43
+ const referer = req.get('referer') || req.get('referrer');
44
+ if (!referer) return null;
45
+
46
+ try {
47
+ return new URL(referer).origin;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Bind writes for an appId to the site origins registered for it.
55
+ *
56
+ * Without this, any page anywhere could embed
57
+ * `<img src="…/registerView?appId=victim&deviceSize=large">` and inject
58
+ * traffic into someone else's analytics. Being a GET, no CORS preflight is
59
+ * involved, so the browser's same-origin policy never came into it.
60
+ *
61
+ * Enforced only for appIds that declare an `origins` list in allowed.json; an
62
+ * appId with no list keeps accepting writes from anywhere, so adding this
63
+ * cannot silently break a running deployment. Startup validation warns about
64
+ * every appId still in that state.
65
+ *
66
+ * @param {{ origins: Record<string, string[]> }} allowed
67
+ * @returns {import('express').RequestHandler}
68
+ */
69
+ function requireRegisteredOrigin(allowed) {
70
+ const origins = allowed?.origins || {};
71
+
72
+ return (req, res, next) => {
73
+ const appId = req.query.appId || req.body?.appId;
74
+ const registered = origins[appId];
75
+
76
+ if (!Array.isArray(registered) || registered.length === 0) {
77
+ return next();
78
+ }
79
+
80
+ const origin = requestOrigin(req);
81
+ if (origin && registered.includes(origin)) {
82
+ return next();
83
+ }
84
+
85
+ return res.status(HTTP_STATUS.FORBIDDEN).json({
86
+ message: 'Request origin is not registered for this appId',
87
+ });
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Mark a response as uncacheable.
93
+ *
94
+ * Analytics responses are per-caller and must never be served from a shared
95
+ * proxy cache to a different caller. Neither express nor helmet sets this.
96
+ *
97
+ * @type {import('express').RequestHandler}
98
+ */
99
+ function noStore(req, res, next) {
100
+ res.set('Cache-Control', 'no-store, max-age=0');
101
+ next();
102
+ }
103
+
104
+ module.exports = {
105
+ buildCorsOptions,
106
+ requireRegisteredOrigin,
107
+ requestOrigin,
108
+ noStore,
109
+ };