@harshankur/viewcounter 3.0.1 → 3.1.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 +20 -0
- package/README.md +174 -4
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +1875 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +89 -0
- package/admin/js/api.js +100 -0
- package/admin/js/charts.js +502 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +150 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +79 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/insights.js +192 -0
- package/admin/js/listbox.js +144 -0
- package/admin/js/logs.js +167 -0
- package/admin/js/main.js +235 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/table.js +134 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +208 -0
- package/admin/js/views.js +685 -0
- package/admin/locales/en.json +394 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +83 -0
- package/constants.js +215 -2
- package/db/AdminRepository.js +562 -0
- package/db/DatabaseManager.js +94 -20
- package/db/LogRepository.js +217 -0
- package/db/adminSchema.js +244 -0
- package/db/retention.js +97 -0
- package/index.js +39 -6
- package/middleware/adminAuth.js +204 -0
- package/middleware/adminValidation.js +253 -0
- package/package.json +16 -9
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- package/utils/errorUtils.js +35 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema for the admin feature: per-app admin columns, the two log tables, and
|
|
3
|
+
* the idempotent migration that brings an existing deployment up to date.
|
|
4
|
+
*
|
|
5
|
+
* Every statement here is additive. Nothing is dropped or rewritten, so a
|
|
6
|
+
* deployment can move to this version and back without losing a row.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
ADMIN_LOG_TABLE,
|
|
13
|
+
DATABASE,
|
|
14
|
+
FIELD_MAX_LENGTH,
|
|
15
|
+
VIEW_LOG_TABLE,
|
|
16
|
+
} = require('../constants');
|
|
17
|
+
const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
|
|
18
|
+
const { isValidAppId } = require('../utils/appIdUtils');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Columns added to every app table.
|
|
22
|
+
*
|
|
23
|
+
* `public_id` is the identity the admin API and UI use (CODE_STANDARDS.md §8):
|
|
24
|
+
* the auto-increment `id` is enumerable and reveals how many rows exist, so it
|
|
25
|
+
* never leaves the server. It starts nullable only so an existing table can be
|
|
26
|
+
* backfilled; the migration then makes it NOT NULL and unique.
|
|
27
|
+
*
|
|
28
|
+
* `admin_modified_at` is the "modified by an admin" marker: NULL means the row
|
|
29
|
+
* is exactly as observed, a timestamp says when an admin last changed its
|
|
30
|
+
* content. `deleted_at` is the soft-delete marker.
|
|
31
|
+
*/
|
|
32
|
+
const ADMIN_COLUMNS = [
|
|
33
|
+
{ name: 'public_id', ddl: `CHAR(${FIELD_MAX_LENGTH.UUID}) DEFAULT NULL` },
|
|
34
|
+
{ name: 'note', ddl: `VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL` },
|
|
35
|
+
{ name: 'admin_modified_at', ddl: 'DATETIME DEFAULT NULL' },
|
|
36
|
+
{ name: 'deleted_at', ddl: 'DATETIME DEFAULT NULL' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Indexes the admin columns need, keyed by index name. */
|
|
40
|
+
const ADMIN_INDEXES = {
|
|
41
|
+
uq_public_id: 'UNIQUE INDEX `uq_public_id` (`public_id`)',
|
|
42
|
+
idx_deleted_at: 'INDEX `idx_deleted_at` (`deleted_at`)',
|
|
43
|
+
idx_admin_modified_at: 'INDEX `idx_admin_modified_at` (`admin_modified_at`)',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The admin operation log: who did what, when, to which rows.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately records WHICH fields an edit touched, never their values, and
|
|
50
|
+
* never an IP beyond its masked form. A log that kept copies of row content
|
|
51
|
+
* would survive the row's permanent erasure and defeat it (GDPR Art. 17).
|
|
52
|
+
*/
|
|
53
|
+
const ADMIN_LOG_DDL = `
|
|
54
|
+
CREATE TABLE IF NOT EXISTS \`${ADMIN_LOG_TABLE}\` (
|
|
55
|
+
\`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) PRIMARY KEY,
|
|
56
|
+
\`created_at\` DATETIME(3) NOT NULL,
|
|
57
|
+
\`action\` VARCHAR(32) NOT NULL,
|
|
58
|
+
\`session_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) DEFAULT NULL,
|
|
59
|
+
\`masked_ip\` VARCHAR(${FIELD_MAX_LENGTH.MASKED_IP}) DEFAULT NULL,
|
|
60
|
+
\`app_id\` VARCHAR(64) DEFAULT NULL,
|
|
61
|
+
\`target_count\` INT NOT NULL DEFAULT 0,
|
|
62
|
+
\`target_ids\` JSON DEFAULT NULL,
|
|
63
|
+
\`fields\` JSON DEFAULT NULL,
|
|
64
|
+
INDEX \`idx_created_at\` (\`created_at\`),
|
|
65
|
+
INDEX \`idx_action\` (\`action\`),
|
|
66
|
+
INDEX \`idx_app_id\` (\`app_id\`)
|
|
67
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The view register log: one entry per accepted view or event.
|
|
72
|
+
*
|
|
73
|
+
* Independent of the app tables on purpose, so it still says a view was
|
|
74
|
+
* reported, and when, after an admin edits, deletes, or erases that view. It
|
|
75
|
+
* holds no IP, hash, or User-Agent, so it contains no personal data to erase.
|
|
76
|
+
*/
|
|
77
|
+
const VIEW_LOG_DDL = `
|
|
78
|
+
CREATE TABLE IF NOT EXISTS \`${VIEW_LOG_TABLE}\` (
|
|
79
|
+
\`id\` CHAR(${FIELD_MAX_LENGTH.UUID}) PRIMARY KEY,
|
|
80
|
+
\`created_at\` DATETIME(3) NOT NULL,
|
|
81
|
+
\`app_id\` VARCHAR(64) NOT NULL,
|
|
82
|
+
\`source\` VARCHAR(16) NOT NULL,
|
|
83
|
+
\`view_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL,
|
|
84
|
+
\`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT NULL,
|
|
85
|
+
\`is_unique\` TINYINT(1) NOT NULL,
|
|
86
|
+
INDEX \`idx_created_at\` (\`created_at\`),
|
|
87
|
+
INDEX \`idx_app_created\` (\`app_id\`, \`created_at\`)
|
|
88
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Column and index definitions for a brand-new app table, spliced into
|
|
93
|
+
* `appTableDDL` so a fresh table is born in the migrated shape.
|
|
94
|
+
*/
|
|
95
|
+
const NEW_TABLE_ADMIN_COLUMNS = [
|
|
96
|
+
`\`public_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL`,
|
|
97
|
+
`\`note\` VARCHAR(${FIELD_MAX_LENGTH.NOTE}) DEFAULT NULL`,
|
|
98
|
+
'`admin_modified_at` DATETIME DEFAULT NULL',
|
|
99
|
+
'`deleted_at` DATETIME DEFAULT NULL',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const NEW_TABLE_ADMIN_INDEXES = Object.values(ADMIN_INDEXES);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {object} pool mysql2 promise pool
|
|
106
|
+
* @param {string} table
|
|
107
|
+
* @returns {Promise<Map<string, {nullable: boolean}>>} existing columns
|
|
108
|
+
*/
|
|
109
|
+
async function readColumns(pool, table) {
|
|
110
|
+
const [rows] = await pool.query(
|
|
111
|
+
`SELECT COLUMN_NAME AS name, IS_NULLABLE AS nullable
|
|
112
|
+
FROM information_schema.COLUMNS
|
|
113
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
|
|
114
|
+
[table]
|
|
115
|
+
);
|
|
116
|
+
return new Map(rows.map((row) => [row.name, { nullable: row.nullable === 'YES' }]));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {object} pool
|
|
121
|
+
* @param {string} table
|
|
122
|
+
* @returns {Promise<Set<string>>} existing index names
|
|
123
|
+
*/
|
|
124
|
+
async function readIndexes(pool, table) {
|
|
125
|
+
const [rows] = await pool.query(
|
|
126
|
+
`SELECT DISTINCT INDEX_NAME AS name
|
|
127
|
+
FROM information_schema.STATISTICS
|
|
128
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
|
|
129
|
+
[table]
|
|
130
|
+
);
|
|
131
|
+
return new Set(rows.map((row) => row.name));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Give every row without a public_id a fresh UUID, a batch at a time.
|
|
136
|
+
*
|
|
137
|
+
* Generated in Node with a CSPRNG rather than MySQL's UUID(), which is a
|
|
138
|
+
* time-and-host based v1 value (CODE_STANDARDS.md §8), and batched so a large
|
|
139
|
+
* table is not rewritten in one giant statement. Each batch resumes after the
|
|
140
|
+
* last id it saw (keyset pagination on the primary key), so the whole backfill
|
|
141
|
+
* reads every row once: restarting from the top each time would rescan all
|
|
142
|
+
* rows already done, quadratic in table size, and on a large table a single
|
|
143
|
+
* scan would outlast the pool's statement timeout and fail the migration.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} pool
|
|
146
|
+
* @param {string} table already validated
|
|
147
|
+
* @returns {Promise<number>} rows backfilled
|
|
148
|
+
*/
|
|
149
|
+
async function backfillPublicIds(pool, table) {
|
|
150
|
+
let total = 0;
|
|
151
|
+
let lastId = 0;
|
|
152
|
+
|
|
153
|
+
for (;;) {
|
|
154
|
+
const [rows] = await pool.query(
|
|
155
|
+
`SELECT id FROM \`${table}\` WHERE id > ? AND public_id IS NULL ORDER BY id LIMIT ?`,
|
|
156
|
+
[lastId, DATABASE.BACKFILL_BATCH_SIZE]
|
|
157
|
+
);
|
|
158
|
+
if (rows.length === 0) return total;
|
|
159
|
+
|
|
160
|
+
const ids = rows.map((row) => row.id);
|
|
161
|
+
lastId = ids[ids.length - 1];
|
|
162
|
+
const cases = ids.map(() => 'WHEN ? THEN ?').join(' ');
|
|
163
|
+
const params = ids.flatMap((id) => [id, crypto.randomUUID()]);
|
|
164
|
+
|
|
165
|
+
await pool.query(
|
|
166
|
+
`UPDATE \`${table}\` SET public_id = CASE id ${cases} END WHERE id IN (?)`,
|
|
167
|
+
[...params, ids]
|
|
168
|
+
);
|
|
169
|
+
total += ids.length;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Bring one app table up to the admin schema. Idempotent: a table already in
|
|
175
|
+
* shape is read and left alone.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} pool
|
|
178
|
+
* @param {string} appId
|
|
179
|
+
* @returns {Promise<{migrated: boolean, backfilled: number}>}
|
|
180
|
+
* @throws {Error} ErrorType.MIGRATION_FAILED, so startup fails loudly rather
|
|
181
|
+
* than serving an admin UI over a half-migrated table
|
|
182
|
+
*/
|
|
183
|
+
async function migrateAppTable(pool, appId) {
|
|
184
|
+
if (!isValidAppId(appId)) {
|
|
185
|
+
throw getError(ErrorType.INVALID_APP_ID, { appId });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const columns = await readColumns(pool, appId);
|
|
190
|
+
if (columns.size === 0) {
|
|
191
|
+
logWarning(WarningType.MIGRATION_TABLE_MISSING, { table: appId });
|
|
192
|
+
return { migrated: false, backfilled: 0 };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const column of ADMIN_COLUMNS) {
|
|
196
|
+
if (!columns.has(column.name)) {
|
|
197
|
+
await pool.query(`ALTER TABLE \`${appId}\` ADD COLUMN \`${column.name}\` ${column.ddl}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const backfilled = await backfillPublicIds(pool, appId);
|
|
202
|
+
|
|
203
|
+
const publicId = columns.get('public_id');
|
|
204
|
+
if (!publicId || publicId.nullable) {
|
|
205
|
+
await pool.query(
|
|
206
|
+
`ALTER TABLE \`${appId}\` MODIFY \`public_id\` CHAR(${FIELD_MAX_LENGTH.UUID}) NOT NULL`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const indexes = await readIndexes(pool, appId);
|
|
211
|
+
for (const [name, definition] of Object.entries(ADMIN_INDEXES)) {
|
|
212
|
+
if (!indexes.has(name)) {
|
|
213
|
+
await pool.query(`ALTER TABLE \`${appId}\` ADD ${definition}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { migrated: true, backfilled };
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
throw getError(ErrorType.MIGRATION_FAILED, { table: appId, cause: cause.message });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Create the log tables. Safe in `connect` mode for the same reason the app
|
|
225
|
+
* registry is: they are the service's own bookkeeping, not the operator's
|
|
226
|
+
* schema.
|
|
227
|
+
* @param {object} pool
|
|
228
|
+
*/
|
|
229
|
+
async function ensureLogTables(pool) {
|
|
230
|
+
await pool.query(ADMIN_LOG_DDL);
|
|
231
|
+
await pool.query(VIEW_LOG_DDL);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
ADMIN_COLUMNS,
|
|
236
|
+
ADMIN_INDEXES,
|
|
237
|
+
ADMIN_LOG_DDL,
|
|
238
|
+
VIEW_LOG_DDL,
|
|
239
|
+
NEW_TABLE_ADMIN_COLUMNS,
|
|
240
|
+
NEW_TABLE_ADMIN_INDEXES,
|
|
241
|
+
backfillPublicIds,
|
|
242
|
+
ensureLogTables,
|
|
243
|
+
migrateAppTable,
|
|
244
|
+
};
|
package/db/retention.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled retention: expired trash, and old view-log entries.
|
|
3
|
+
*
|
|
4
|
+
* Trash (GDPR Art. 5(1)(e), storage limitation): a soft-deleted view is kept
|
|
5
|
+
* for TRASH_RETENTION_DAYS so a mistaken delete can be undone, then erased for
|
|
6
|
+
* good.
|
|
7
|
+
*
|
|
8
|
+
* View log: the register of accepted views gains a row per view. It holds no
|
|
9
|
+
* personal data, but unbounded it grows as large as every app table together,
|
|
10
|
+
* so entries older than VIEW_LOG_RETENTION_DAYS are removed.
|
|
11
|
+
*
|
|
12
|
+
* Every run that removes anything leaves an entry in the admin operation log,
|
|
13
|
+
* attributed to the system rather than a session, so nothing is removed
|
|
14
|
+
* silently. The admin log itself is never pruned.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { ADMIN, ADMIN_ACTION } = require('../constants');
|
|
18
|
+
const { logWarning, WarningType } = require('../utils/errorUtils');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Erase expired trash in every app once.
|
|
22
|
+
*
|
|
23
|
+
* One app failing does not stop the others; the failure is reported and the
|
|
24
|
+
* next run tries again.
|
|
25
|
+
*
|
|
26
|
+
* @param {{ adminRepo: object, logRepo: object, appIds: string[], days: number }} deps
|
|
27
|
+
* @returns {Promise<Record<string, number>>} rows erased per app
|
|
28
|
+
*/
|
|
29
|
+
async function purgeExpiredTrash({ adminRepo, logRepo, appIds, days }) {
|
|
30
|
+
const erased = {};
|
|
31
|
+
if (!(days > 0)) return erased;
|
|
32
|
+
|
|
33
|
+
for (const appId of appIds) {
|
|
34
|
+
try {
|
|
35
|
+
const count = await adminRepo.purgeExpired(appId, days);
|
|
36
|
+
erased[appId] = count;
|
|
37
|
+
if (count > 0) {
|
|
38
|
+
await logRepo.writeAdminLog({
|
|
39
|
+
action: ADMIN_ACTION.TRASH_AUTO_PURGED,
|
|
40
|
+
appId,
|
|
41
|
+
targetCount: count,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
logWarning(WarningType.TRASH_PURGE_FAILED, { appId, cause: cause.message });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return erased;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove view-log entries older than `days` once. A failure is reported and
|
|
53
|
+
* the next run tries again.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ logRepo: object, days: number }} deps
|
|
56
|
+
* @returns {Promise<number>} entries removed
|
|
57
|
+
*/
|
|
58
|
+
async function pruneViewLog({ logRepo, days }) {
|
|
59
|
+
if (!(days > 0)) return 0;
|
|
60
|
+
try {
|
|
61
|
+
const count = await logRepo.pruneViewLog(days);
|
|
62
|
+
if (count > 0) {
|
|
63
|
+
await logRepo.writeAdminLog({ action: ADMIN_ACTION.VIEW_LOG_PRUNED, targetCount: count });
|
|
64
|
+
}
|
|
65
|
+
return count;
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
logWarning(WarningType.VIEW_LOG_PRUNE_FAILED, { cause: cause.message });
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run both jobs now and then on an interval. A job whose retention is zero
|
|
74
|
+
* (keep forever) is skipped; when both are, nothing is scheduled.
|
|
75
|
+
*
|
|
76
|
+
* `getAppIds` is called on every run, so apps provisioned at runtime are
|
|
77
|
+
* covered without a restart. The timer is unref'd so it never keeps the
|
|
78
|
+
* process alive on its own.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ adminRepo: object, logRepo: object, getAppIds: () => string[],
|
|
81
|
+
* trashDays: number, viewLogDays: number, intervalMs?: number }} deps
|
|
82
|
+
* @returns {() => void} stops the schedule
|
|
83
|
+
*/
|
|
84
|
+
function startRetention({ adminRepo, logRepo, getAppIds, trashDays, viewLogDays, intervalMs = ADMIN.RETENTION_INTERVAL_MS }) {
|
|
85
|
+
if (!(trashDays > 0) && !(viewLogDays > 0)) return () => {};
|
|
86
|
+
|
|
87
|
+
const run = async () => {
|
|
88
|
+
await purgeExpiredTrash({ adminRepo, logRepo, appIds: getAppIds(), days: trashDays });
|
|
89
|
+
await pruneViewLog({ logRepo, days: viewLogDays });
|
|
90
|
+
};
|
|
91
|
+
run();
|
|
92
|
+
const timer = setInterval(run, intervalMs);
|
|
93
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
94
|
+
return () => clearInterval(timer);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { purgeExpiredTrash, pruneViewLog, startRetention };
|
package/index.js
CHANGED
|
@@ -3,18 +3,21 @@ const cors = require('cors');
|
|
|
3
3
|
const helmet = require('helmet');
|
|
4
4
|
const rateLimit = require('express-rate-limit');
|
|
5
5
|
|
|
6
|
-
const { APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
|
|
6
|
+
const { ADMIN, APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
|
|
7
7
|
const config = require('./config');
|
|
8
8
|
const DatabaseManager = require('./db/DatabaseManager');
|
|
9
9
|
const logger = require('./utils/logger');
|
|
10
10
|
const { buildCorsOptions } = require('./middleware/security');
|
|
11
11
|
const { createAnalyticsRouter } = require('./routes/analytics');
|
|
12
|
+
const { createAdminRouter } = require('./routes/admin');
|
|
13
|
+
const { startRetention } = require('./db/retention');
|
|
12
14
|
|
|
13
15
|
logger.configure({ level: config.server.logLevel });
|
|
14
16
|
|
|
15
17
|
const dbManager = new DatabaseManager(config.dbInfo);
|
|
16
18
|
let isServerReady = false;
|
|
17
19
|
let httpServer = null;
|
|
20
|
+
let stopRetention = () => {};
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Build the Express application.
|
|
@@ -27,17 +30,30 @@ function createApp() {
|
|
|
27
30
|
|
|
28
31
|
app.disable('x-powered-by');
|
|
29
32
|
app.use(helmet());
|
|
33
|
+
|
|
34
|
+
// Trusting the proxy is needed before the admin router sees a request: it
|
|
35
|
+
// decides whether the session cookie is marked Secure from req.secure.
|
|
36
|
+
app.set('trust proxy', config.server.trustProxy);
|
|
37
|
+
|
|
38
|
+
// The admin surface exists only when a password is configured, and is
|
|
39
|
+
// mounted ahead of CORS and the public rate limit: it is same-origin only,
|
|
40
|
+
// must never carry the analytics sites' CORS headers, and has its own
|
|
41
|
+
// limits.
|
|
42
|
+
if (config.admin.enabled) {
|
|
43
|
+
app.use(ADMIN.PATH_PREFIX, createAdminRouter({
|
|
44
|
+
config,
|
|
45
|
+
adminRepo: dbManager.admin,
|
|
46
|
+
logRepo: dbManager.logs,
|
|
47
|
+
isReady: () => isServerReady,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
app.use(cors(buildCorsOptions(config.server.corsOrigins)));
|
|
31
52
|
|
|
32
53
|
// Bounded well below body-parser's 100kb default; /event is the only
|
|
33
54
|
// endpoint taking a body and its payload is small.
|
|
34
55
|
app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
|
|
35
56
|
|
|
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
57
|
app.use(rateLimit({
|
|
42
58
|
windowMs: config.server.rateLimit.windowMs,
|
|
43
59
|
limit: config.server.rateLimit.max,
|
|
@@ -108,6 +124,20 @@ const initializeServer = async () => {
|
|
|
108
124
|
// anyone editing allowed.json.
|
|
109
125
|
await mergeRegisteredApps();
|
|
110
126
|
|
|
127
|
+
// initialize() migrated the configured apps; this also covers the ones
|
|
128
|
+
// registered at runtime and merged in above. Idempotent, so apps
|
|
129
|
+
// already in shape cost one information_schema read. Fails startup
|
|
130
|
+
// rather than serving over a half-migrated table.
|
|
131
|
+
await dbManager.migrate(config.allowed.appId);
|
|
132
|
+
|
|
133
|
+
stopRetention = startRetention({
|
|
134
|
+
adminRepo: dbManager.admin,
|
|
135
|
+
logRepo: dbManager.logs,
|
|
136
|
+
getAppIds: () => config.allowed.appId,
|
|
137
|
+
trashDays: config.admin.trashRetentionDays,
|
|
138
|
+
viewLogDays: config.admin.viewLogRetentionDays,
|
|
139
|
+
});
|
|
140
|
+
|
|
111
141
|
isServerReady = true;
|
|
112
142
|
|
|
113
143
|
if (require.main === module) {
|
|
@@ -147,6 +177,7 @@ const shutdown = async (signal, exitCode = 0) => {
|
|
|
147
177
|
forceExit.unref();
|
|
148
178
|
|
|
149
179
|
try {
|
|
180
|
+
stopRetention();
|
|
150
181
|
if (httpServer) {
|
|
151
182
|
await new Promise((resolve) => httpServer.close(resolve));
|
|
152
183
|
}
|
|
@@ -179,6 +210,8 @@ process.on('uncaughtException', (error) => {
|
|
|
179
210
|
module.exports = app;
|
|
180
211
|
module.exports.createApp = createApp;
|
|
181
212
|
module.exports.createAnalyticsRouter = createAnalyticsRouter;
|
|
213
|
+
module.exports.createAdminRouter = createAdminRouter;
|
|
214
|
+
module.exports.startRetention = startRetention;
|
|
182
215
|
module.exports.DatabaseManager = DatabaseManager;
|
|
183
216
|
module.exports.dbManager = dbManager;
|
|
184
217
|
module.exports.initializeServer = initializeServer;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin UI authentication: password login, server-side sessions, and CSRF.
|
|
3
|
+
*
|
|
4
|
+
* Sessions live in memory. A restart signs every admin out, which is the safe
|
|
5
|
+
* direction to fail in and costs one login. The browser holds only an opaque
|
|
6
|
+
* random token in an HttpOnly cookie; the store is keyed by that token's
|
|
7
|
+
* SHA-256, so a memory dump of the store does not yield usable cookies.
|
|
8
|
+
*
|
|
9
|
+
* CSRF is defeated three ways, each sufficient on its own in a modern browser:
|
|
10
|
+
* the cookie is SameSite=Strict, every mutating request must echo a per-session
|
|
11
|
+
* token in a header that a cross-site form cannot set, and a present Origin
|
|
12
|
+
* header must match this server.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
const { ADMIN, ADMIN_ERROR_CODE, HTTP_STATUS } = require('../constants');
|
|
18
|
+
const { safeEqual } = require('./auth');
|
|
19
|
+
const { parseCookies } = require('../utils/cookieUtils');
|
|
20
|
+
const { WarningType, logWarning } = require('../utils/errorUtils');
|
|
21
|
+
|
|
22
|
+
/** Methods that never change state and so need no CSRF token. */
|
|
23
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
24
|
+
|
|
25
|
+
/** @param {string} token @returns {string} */
|
|
26
|
+
function hashToken(token) {
|
|
27
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{ idleMs?: number, absoluteMs?: number, maxSessions?: number,
|
|
32
|
+
* now?: () => number }} [options]
|
|
33
|
+
*/
|
|
34
|
+
function createSessionStore({
|
|
35
|
+
idleMs = ADMIN.SESSION_IDLE_TIMEOUT_MS,
|
|
36
|
+
absoluteMs = ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
|
|
37
|
+
maxSessions = ADMIN.MAX_SESSIONS,
|
|
38
|
+
now = Date.now,
|
|
39
|
+
} = {}) {
|
|
40
|
+
/** @type {Map<string, {id: string, csrfToken: string, createdAt: number, lastSeenAt: number}>} */
|
|
41
|
+
const sessions = new Map();
|
|
42
|
+
|
|
43
|
+
function isExpired(session, at) {
|
|
44
|
+
return at - session.lastSeenAt > idleMs || at - session.createdAt > absoluteMs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
/**
|
|
49
|
+
* Start a session.
|
|
50
|
+
* @returns {{ token: string, session: {id: string, csrfToken: string, createdAt: number, lastSeenAt: number} }}
|
|
51
|
+
*/
|
|
52
|
+
create() {
|
|
53
|
+
const at = now();
|
|
54
|
+
const token = crypto.randomBytes(ADMIN.SESSION_TOKEN_BYTES).toString('base64url');
|
|
55
|
+
const session = {
|
|
56
|
+
id: crypto.randomUUID(),
|
|
57
|
+
csrfToken: crypto.randomBytes(ADMIN.CSRF_TOKEN_BYTES).toString('base64url'),
|
|
58
|
+
createdAt: at,
|
|
59
|
+
lastSeenAt: at,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Bounded: the oldest session goes first. Map preserves insertion
|
|
63
|
+
// order, so the first key is always the oldest.
|
|
64
|
+
while (sessions.size >= maxSessions) {
|
|
65
|
+
sessions.delete(sessions.keys().next().value);
|
|
66
|
+
}
|
|
67
|
+
sessions.set(hashToken(token), session);
|
|
68
|
+
return { token, session };
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Look up a live session and extend its idle window.
|
|
73
|
+
* @param {string|undefined} token
|
|
74
|
+
*/
|
|
75
|
+
get(token) {
|
|
76
|
+
if (typeof token !== 'string' || token.length === 0) return null;
|
|
77
|
+
const key = hashToken(token);
|
|
78
|
+
const session = sessions.get(key);
|
|
79
|
+
if (!session) return null;
|
|
80
|
+
|
|
81
|
+
const at = now();
|
|
82
|
+
if (isExpired(session, at)) {
|
|
83
|
+
sessions.delete(key);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
session.lastSeenAt = at;
|
|
87
|
+
return session;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** @param {string|undefined} token */
|
|
91
|
+
destroy(token) {
|
|
92
|
+
if (typeof token !== 'string' || token.length === 0) return false;
|
|
93
|
+
return sessions.delete(hashToken(token));
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
get size() {
|
|
97
|
+
return sessions.size;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read the session token from the request's cookies. */
|
|
103
|
+
function readToken(req) {
|
|
104
|
+
return parseCookies(req.headers.cookie)[ADMIN.SESSION_COOKIE];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Cookie attributes. `Secure` follows the connection: a deployment behind a
|
|
109
|
+
* TLS-terminating proxy gets it through `trust proxy`, and plain-HTTP local
|
|
110
|
+
* development still works. Scoped to wherever the admin router is mounted
|
|
111
|
+
* (`req.adminBasePath`), so the cookie never travels to the public analytics
|
|
112
|
+
* endpoints and still works when another app mounts the router elsewhere.
|
|
113
|
+
*/
|
|
114
|
+
function cookieOptions(req) {
|
|
115
|
+
return {
|
|
116
|
+
httpOnly: true,
|
|
117
|
+
sameSite: 'strict',
|
|
118
|
+
secure: req.secure,
|
|
119
|
+
path: req.adminBasePath || ADMIN.PATH_PREFIX,
|
|
120
|
+
maxAge: ADMIN.SESSION_ABSOLUTE_TIMEOUT_MS,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @returns {import('express').RequestHandler} */
|
|
125
|
+
function requireAdminSession(store) {
|
|
126
|
+
return (req, res, next) => {
|
|
127
|
+
const token = readToken(req);
|
|
128
|
+
const session = store.get(token);
|
|
129
|
+
if (!session) {
|
|
130
|
+
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ code: ADMIN_ERROR_CODE.UNAUTHENTICATED });
|
|
131
|
+
}
|
|
132
|
+
req.adminSession = session;
|
|
133
|
+
req.adminToken = token;
|
|
134
|
+
return next();
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The origin this request was addressed to, as the browser would write it.
|
|
140
|
+
* @param {import('express').Request} req
|
|
141
|
+
*/
|
|
142
|
+
function expectedOrigin(req) {
|
|
143
|
+
return `${req.protocol}://${req.get('host')}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a browser-supplied Origin (absent for same-origin GETs and non-browser
|
|
148
|
+
* clients) matches this server. A mismatch is logged with both values: behind a
|
|
149
|
+
* misconfigured proxy every admin request is refused, and the log is the only
|
|
150
|
+
* place that says why.
|
|
151
|
+
* @param {import('express').Request} req
|
|
152
|
+
*/
|
|
153
|
+
function originAllowed(req) {
|
|
154
|
+
const presented = req.get('origin');
|
|
155
|
+
if (!presented) return true;
|
|
156
|
+
const expected = expectedOrigin(req);
|
|
157
|
+
if (presented === expected) return true;
|
|
158
|
+
logWarning(WarningType.ADMIN_ORIGIN_REJECTED, { presented, expected });
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reject a state-changing request that does not carry this session's CSRF
|
|
164
|
+
* token, or that a browser says came from another origin.
|
|
165
|
+
* @returns {import('express').RequestHandler}
|
|
166
|
+
*/
|
|
167
|
+
function requireCsrf() {
|
|
168
|
+
return (req, res, next) => {
|
|
169
|
+
if (SAFE_METHODS.has(req.method)) return next();
|
|
170
|
+
|
|
171
|
+
const presented = req.get(ADMIN.CSRF_HEADER) || '';
|
|
172
|
+
const session = req.adminSession;
|
|
173
|
+
|
|
174
|
+
const originOk = originAllowed(req);
|
|
175
|
+
const tokenOk = Boolean(session) && safeEqual(presented, session.csrfToken);
|
|
176
|
+
|
|
177
|
+
if (!originOk || !tokenOk) {
|
|
178
|
+
return res.status(HTTP_STATUS.FORBIDDEN).json({ code: ADMIN_ERROR_CODE.CSRF_REJECTED });
|
|
179
|
+
}
|
|
180
|
+
return next();
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Constant-time password check. An unset password never matches anything,
|
|
186
|
+
* including an empty submission.
|
|
187
|
+
*/
|
|
188
|
+
function verifyPassword(presented, configured) {
|
|
189
|
+
if (!configured) return false;
|
|
190
|
+
return safeEqual(String(presented ?? ''), configured);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
createSessionStore,
|
|
195
|
+
requireAdminSession,
|
|
196
|
+
requireCsrf,
|
|
197
|
+
verifyPassword,
|
|
198
|
+
cookieOptions,
|
|
199
|
+
expectedOrigin,
|
|
200
|
+
originAllowed,
|
|
201
|
+
readToken,
|
|
202
|
+
hashToken,
|
|
203
|
+
SAFE_METHODS,
|
|
204
|
+
};
|