hearald 0.0.4 → 0.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/CHANGELOG.md +133 -0
- package/README.md +120 -46
- package/dist/analytics.html +4 -4
- package/dist/index.css +2 -2
- package/dist/index.js +2 -2
- package/package.json +19 -5
- package/src/client.js +27 -7
- package/src/index.js +57 -11
- package/src/public/css/analytics.css +2 -2
- package/src/public/js/App.js +2 -2
- package/src/server/analytics.js +225 -72
- package/src/server/asyncHandler.js +6 -0
- package/src/server/auth.js +89 -0
- package/src/server/configuration.js +53 -0
- package/src/server/logger.js +14 -14
- package/src/server/server.js +99 -11
- package/src/server/serverEvents.js +154 -43
- package/src/server/validate.js +56 -0
- package/src/shared/utils.js +32 -16
- package/.eslintrc +0 -14
- package/TODO.md +0 -34
- package/rollup.config.js +0 -59
- package/src/server/serverTrackError.js +0 -47
package/src/server/analytics.js
CHANGED
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
// Analytics router and helpers for hearald
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import path from "path";
|
|
4
|
+
import { createHash } from "crypto";
|
|
4
5
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { readFile, writeFile } from "fs/promises";
|
|
6
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
6
7
|
import express from "express";
|
|
7
8
|
import log from "./logger.js";
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
import hearaldConfiguration from "./configuration.js";
|
|
10
|
+
import { omitProperties, defaultEndDate, iso } from "../shared/utils.js";
|
|
11
|
+
import { loadEventsOverWindow, windowBytes } from "./serverEvents.js";
|
|
12
|
+
import { asyncHandler } from "./asyncHandler.js";
|
|
13
|
+
|
|
14
|
+
const MAX_TIME_WINDOW_DAYS = 366;
|
|
15
|
+
|
|
16
|
+
// Bump when the way numbers are computed or stored changes, so summaries cached by an older version
|
|
17
|
+
// are not served.
|
|
18
|
+
const CACHE_FORMAT_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
// Thrown when a window's event files are bigger than the memory budget.
|
|
21
|
+
export class WindowTooLargeError extends Error {
|
|
22
|
+
constructor(bytes, limit) {
|
|
23
|
+
super(`window too large (${bytes} bytes of events, the limit is ${limit})`);
|
|
24
|
+
this.name = "WindowTooLargeError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Computing stats holds a whole window of events in memory, so run one computation at a time: with
|
|
29
|
+
// several at once, memory use multiplies.
|
|
30
|
+
let queue = Promise.resolve();
|
|
31
|
+
export function runExclusive(task) {
|
|
32
|
+
const result = queue.then(task);
|
|
33
|
+
queue = result.catch(() => {});
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A histogram value is a string, number or boolean. 0, false and "" are real values.
|
|
38
|
+
const histogramValue = (params, field) => {
|
|
39
|
+
const value = params ? params[field] : undefined;
|
|
40
|
+
return ["string", "number", "boolean"].includes(typeof value) ? value : null;
|
|
41
|
+
};
|
|
15
42
|
|
|
16
43
|
export function generateRawMetrics(events, rawMetricDefinitions) {
|
|
17
44
|
const metrics = {};
|
|
@@ -22,12 +49,14 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
|
|
|
22
49
|
} else if (rawMetricDefinition.aggregator === "set_of_users") {
|
|
23
50
|
metrics[name] = new Set();
|
|
24
51
|
} else if (rawMetricDefinition.aggregator === "histogram") {
|
|
25
|
-
|
|
52
|
+
// no prototype, so a value named "constructor" or "__proto__" is just a key
|
|
53
|
+
metrics[name] = Object.create(null);
|
|
26
54
|
}
|
|
27
55
|
}
|
|
28
56
|
);
|
|
29
57
|
|
|
30
|
-
events.forEach(({ e, p, u }) => {
|
|
58
|
+
events.forEach(({ e, p: eventParams, u }) => {
|
|
59
|
+
const p = eventParams || {};
|
|
31
60
|
Object.entries(rawMetricDefinitions).forEach(
|
|
32
61
|
([name, rawMetricDefinition]) => {
|
|
33
62
|
if (rawMetricDefinition.filter) {
|
|
@@ -36,19 +65,26 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
|
|
|
36
65
|
);
|
|
37
66
|
if (!match) return;
|
|
38
67
|
}
|
|
68
|
+
// A set_of_users metric that names an event counts only users who sent that event.
|
|
69
|
+
if (
|
|
70
|
+
rawMetricDefinition.aggregator === "set_of_users" &&
|
|
71
|
+
rawMetricDefinition.event &&
|
|
72
|
+
rawMetricDefinition.event !== e
|
|
73
|
+
) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
39
76
|
if (
|
|
40
77
|
rawMetricDefinition.aggregator === "count" &&
|
|
41
78
|
rawMetricDefinition.event === e
|
|
42
79
|
) {
|
|
43
80
|
metrics[name] += 1;
|
|
44
81
|
} else if (rawMetricDefinition.aggregator === "set_of_users") {
|
|
45
|
-
metrics[name].add(u);
|
|
82
|
+
if (u !== null && u !== undefined) metrics[name].add(u);
|
|
46
83
|
} else if (
|
|
47
84
|
rawMetricDefinition.aggregator === "histogram" &&
|
|
48
85
|
rawMetricDefinition.event === e
|
|
49
86
|
) {
|
|
50
|
-
const
|
|
51
|
-
const value = p && p[field] ? p[field] : null;
|
|
87
|
+
const value = histogramValue(p, rawMetricDefinition.field);
|
|
52
88
|
if (value !== null) {
|
|
53
89
|
metrics[name][value] = (metrics[name][value] || 0) + 1;
|
|
54
90
|
}
|
|
@@ -92,38 +128,70 @@ export function generateComputedMetric(metrics, definition, timeWindow) {
|
|
|
92
128
|
// Compute histogram for given event type and field
|
|
93
129
|
const sourceEvent = definition.source;
|
|
94
130
|
const field = definition.field;
|
|
95
|
-
const histogram =
|
|
131
|
+
const histogram = Object.create(null);
|
|
96
132
|
// Find all events of the given type
|
|
97
133
|
(metrics._allEvents || []).forEach((ev) => {
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
const value = ev.e === sourceEvent ? histogramValue(ev.p, field) : null;
|
|
135
|
+
if (value !== null) {
|
|
100
136
|
histogram[value] = (histogram[value] || 0) + 1;
|
|
101
137
|
}
|
|
102
138
|
});
|
|
103
|
-
|
|
139
|
+
// back to a plain object (fromEntries keeps a "__proto__" key as an ordinary key)
|
|
140
|
+
return Object.fromEntries(Object.entries(histogram));
|
|
104
141
|
}
|
|
142
|
+
default:
|
|
143
|
+
throw new Error(`hearald: unknown formula "${definition.formula}"`);
|
|
105
144
|
}
|
|
106
145
|
}
|
|
107
146
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
147
|
+
// A short fingerprint of what the numbers depend on: the metric definitions (not `ui`) and the
|
|
148
|
+
// format version. Changing a definition must not serve summaries computed with the old one.
|
|
149
|
+
export function cacheKey(statDefinitions) {
|
|
150
|
+
return createHash("sha1")
|
|
151
|
+
.update(
|
|
152
|
+
JSON.stringify([
|
|
153
|
+
CACHE_FORMAT_VERSION,
|
|
154
|
+
statDefinitions.raw_metrics,
|
|
155
|
+
statDefinitions.computed_metrics,
|
|
156
|
+
])
|
|
157
|
+
)
|
|
158
|
+
.digest("hex")
|
|
159
|
+
.slice(0, 8);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function cacheFilePath(endDate, timeWindow, statDefinitions) {
|
|
163
|
+
const endDateIso =
|
|
164
|
+
typeof endDate === "string" ? endDate : hearaldConfiguration.isoFn(endDate);
|
|
165
|
+
return path.join(
|
|
166
|
+
hearaldConfiguration.eventsDir,
|
|
167
|
+
`summary:${endDateIso}:${timeWindow}:${cacheKey(statDefinitions)}.json`
|
|
168
|
+
);
|
|
111
169
|
}
|
|
112
170
|
|
|
113
|
-
export async function readMetricsFromCache(
|
|
114
|
-
|
|
171
|
+
export async function readMetricsFromCache(
|
|
172
|
+
endDateIso,
|
|
173
|
+
timeWindow,
|
|
174
|
+
statDefinitions
|
|
175
|
+
) {
|
|
176
|
+
const filePath = cacheFilePath(endDateIso, timeWindow, statDefinitions);
|
|
115
177
|
try {
|
|
116
178
|
return (await readFile(filePath)).toString("utf8");
|
|
117
179
|
} catch (e) {
|
|
118
|
-
if (e.
|
|
180
|
+
if (e.code === "ENOENT") {
|
|
119
181
|
return null;
|
|
120
182
|
}
|
|
121
183
|
throw e;
|
|
122
184
|
}
|
|
123
185
|
}
|
|
124
186
|
|
|
125
|
-
export async function writeMetricsToCache(
|
|
126
|
-
|
|
187
|
+
export async function writeMetricsToCache(
|
|
188
|
+
endDateIso,
|
|
189
|
+
timeWindow,
|
|
190
|
+
statsJson,
|
|
191
|
+
statDefinitions
|
|
192
|
+
) {
|
|
193
|
+
const filePath = cacheFilePath(endDateIso, timeWindow, statDefinitions);
|
|
194
|
+
await mkdir(hearaldConfiguration.eventsDir, { recursive: true });
|
|
127
195
|
return writeFile(filePath, statsJson);
|
|
128
196
|
}
|
|
129
197
|
|
|
@@ -137,8 +205,17 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
137
205
|
if (def.source_file_suffix) fileSuffixes.add(def.source_file_suffix);
|
|
138
206
|
});
|
|
139
207
|
|
|
208
|
+
// Refuse a window too big for memory before reading any of it
|
|
209
|
+
let bytes = 0;
|
|
210
|
+
for (const suffix of fileSuffixes) {
|
|
211
|
+
bytes += await windowBytes(endDate, timeWindow, suffix || undefined);
|
|
212
|
+
}
|
|
213
|
+
if (bytes > hearaldConfiguration.maxWindowBytes) {
|
|
214
|
+
throw new WindowTooLargeError(bytes, hearaldConfiguration.maxWindowBytes);
|
|
215
|
+
}
|
|
216
|
+
|
|
140
217
|
// Load events from all relevant files for raw metrics
|
|
141
|
-
let
|
|
218
|
+
let processedEventCount = 0;
|
|
142
219
|
const eventsBySuffix = {};
|
|
143
220
|
for (const suffix of fileSuffixes) {
|
|
144
221
|
const events = await loadEventsOverWindow(
|
|
@@ -146,7 +223,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
146
223
|
timeWindow,
|
|
147
224
|
suffix || undefined
|
|
148
225
|
);
|
|
149
|
-
|
|
226
|
+
processedEventCount += events.length;
|
|
150
227
|
eventsBySuffix[suffix || ""] = events;
|
|
151
228
|
}
|
|
152
229
|
|
|
@@ -159,8 +236,18 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
159
236
|
];
|
|
160
237
|
});
|
|
161
238
|
|
|
162
|
-
//
|
|
163
|
-
|
|
239
|
+
// Only a computed histogram reads the raw events, so only then keep them all together
|
|
240
|
+
if (
|
|
241
|
+
Object.values(computedMetricDefinitions).some(
|
|
242
|
+
(def) => def.formula === "histogram"
|
|
243
|
+
)
|
|
244
|
+
) {
|
|
245
|
+
const allEvents = [];
|
|
246
|
+
for (const events of Object.values(eventsBySuffix)) {
|
|
247
|
+
for (const event of events) allEvents.push(event);
|
|
248
|
+
}
|
|
249
|
+
metrics._allEvents = allEvents;
|
|
250
|
+
}
|
|
164
251
|
|
|
165
252
|
let anyComputed = true;
|
|
166
253
|
while (anyComputed) {
|
|
@@ -182,7 +269,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
182
269
|
computedMetricDefinition,
|
|
183
270
|
timeWindow
|
|
184
271
|
);
|
|
185
|
-
anyComputed =
|
|
272
|
+
anyComputed = typeof metrics[name] !== "undefined";
|
|
186
273
|
}
|
|
187
274
|
}
|
|
188
275
|
}
|
|
@@ -213,9 +300,9 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
213
300
|
});
|
|
214
301
|
return {
|
|
215
302
|
metrics: nestedMetrics,
|
|
216
|
-
endDate:
|
|
303
|
+
endDate: hearaldConfiguration.isoFn(endDate),
|
|
217
304
|
timeWindow,
|
|
218
|
-
processedEventCount
|
|
305
|
+
processedEventCount,
|
|
219
306
|
};
|
|
220
307
|
}
|
|
221
308
|
|
|
@@ -224,39 +311,81 @@ export async function handleAnalyticsDataRequest({
|
|
|
224
311
|
response,
|
|
225
312
|
statDefinitions,
|
|
226
313
|
}) {
|
|
227
|
-
const defaultEndDateIso =
|
|
314
|
+
const defaultEndDateIso = hearaldConfiguration.isoFn(
|
|
315
|
+
defaultEndDate(hearaldConfiguration.nowFn())
|
|
316
|
+
);
|
|
228
317
|
const endDateIso = request.params.date
|
|
229
318
|
? request.params.date
|
|
230
319
|
: defaultEndDateIso;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
320
|
+
// The date becomes part of a file name, so it must be a real calendar date and nothing else. The
|
|
321
|
+
// round trip rejects dates like 2026-02-31, which Date would quietly move to March.
|
|
322
|
+
if (
|
|
323
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(endDateIso) ||
|
|
324
|
+
Number.isNaN(new Date(`${endDateIso}T00:00:00Z`).getTime()) ||
|
|
325
|
+
iso(new Date(`${endDateIso}T00:00:00Z`)) !== endDateIso
|
|
326
|
+
) {
|
|
327
|
+
response.status(400).json({ error: "invalid date" });
|
|
234
328
|
return;
|
|
235
329
|
}
|
|
236
|
-
const
|
|
237
|
-
|
|
330
|
+
const timeWindow = /^\d+$/.test(String(request.params.last))
|
|
331
|
+
? parseInt(request.params.last, 10)
|
|
332
|
+
: 0;
|
|
333
|
+
if (!timeWindow || timeWindow > MAX_TIME_WINDOW_DAYS) {
|
|
334
|
+
response.status(400).json({ error: "invalid time window" });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// A window that ends today (or later) is still filling up, so it is never cached.
|
|
338
|
+
const cacheable =
|
|
339
|
+
endDateIso < hearaldConfiguration.isoFn(hearaldConfiguration.nowFn());
|
|
340
|
+
const sendCached = (json) => {
|
|
238
341
|
log.info(
|
|
239
342
|
`Analytics for ${timeWindow} days ending on ${endDateIso} returned from cache`
|
|
240
343
|
);
|
|
241
|
-
response.status(200).type("application/json").send(
|
|
344
|
+
response.status(200).type("application/json").send(json);
|
|
345
|
+
};
|
|
346
|
+
const dataFromFile = cacheable
|
|
347
|
+
? await readMetricsFromCache(endDateIso, timeWindow, statDefinitions)
|
|
348
|
+
: null;
|
|
349
|
+
if (dataFromFile) {
|
|
350
|
+
sendCached(dataFromFile);
|
|
242
351
|
return;
|
|
243
352
|
}
|
|
244
|
-
log.info(
|
|
245
|
-
`Analytics for ${timeWindow} days ending on ${endDateIso} computing from events`
|
|
246
|
-
);
|
|
247
353
|
try {
|
|
248
|
-
const stats = await
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
354
|
+
const stats = await runExclusive(async () => {
|
|
355
|
+
// someone ahead of us in the queue may just have computed this same window
|
|
356
|
+
const cached = cacheable
|
|
357
|
+
? await readMetricsFromCache(endDateIso, timeWindow, statDefinitions)
|
|
358
|
+
: null;
|
|
359
|
+
if (cached) return { cached };
|
|
360
|
+
log.info(
|
|
361
|
+
`Analytics for ${timeWindow} days ending on ${endDateIso} computing from events`
|
|
362
|
+
);
|
|
363
|
+
const computed = await generateStats(
|
|
364
|
+
statDefinitions,
|
|
365
|
+
new Date(`${endDateIso}T00:00:00Z`),
|
|
366
|
+
timeWindow
|
|
367
|
+
);
|
|
368
|
+
if (cacheable) {
|
|
369
|
+
await writeMetricsToCache(
|
|
370
|
+
endDateIso,
|
|
371
|
+
timeWindow,
|
|
372
|
+
JSON.stringify(computed, null, 2),
|
|
373
|
+
statDefinitions
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return { computed };
|
|
377
|
+
});
|
|
378
|
+
if (stats.cached) {
|
|
379
|
+
sendCached(stats.cached);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
response.status(200).type("application/json").send(JSON.stringify(stats.computed));
|
|
259
383
|
} catch (e) {
|
|
384
|
+
if (e instanceof WindowTooLargeError) {
|
|
385
|
+
log.warn(e.message);
|
|
386
|
+
response.status(413).json({ error: "window too large" });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
260
389
|
log.error("failed to compute analytics", e);
|
|
261
390
|
response
|
|
262
391
|
.status(500)
|
|
@@ -271,32 +400,56 @@ const __dirname = path.dirname(__filename);
|
|
|
271
400
|
log.info("filename is", __filename);
|
|
272
401
|
log.info("dirname is", __dirname);
|
|
273
402
|
|
|
274
|
-
|
|
403
|
+
/**
|
|
404
|
+
* @param {Object} options
|
|
405
|
+
* @param {Object} options.statDefinitions
|
|
406
|
+
* @param {function} options.auth - Express middleware that rejects anyone who may not see the stats
|
|
407
|
+
* (for example basicAuth from hearald). Required: the dashboard shows your usage numbers.
|
|
408
|
+
* @param {boolean} [options.allowUnauthenticated=false] - Skip `auth`. Only for local development.
|
|
409
|
+
*/
|
|
410
|
+
export function createAnalyticsRouter({
|
|
411
|
+
statDefinitions,
|
|
412
|
+
auth,
|
|
413
|
+
allowUnauthenticated = false,
|
|
414
|
+
}) {
|
|
415
|
+
if (typeof auth !== "function" && !allowUnauthenticated) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
"hearald: the analytics dashboard needs an `auth` middleware (see basicAuth), " +
|
|
418
|
+
"or allowUnauthenticated: true for local development"
|
|
419
|
+
);
|
|
420
|
+
}
|
|
275
421
|
log.info("statManifest", statDefinitions);
|
|
276
422
|
const router = express.Router();
|
|
423
|
+
if (typeof auth === "function") router.use(auth);
|
|
277
424
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
425
|
+
// Two routes rather than an optional `:date?`, which Express 5 (path-to-regexp 8) rejects.
|
|
426
|
+
// asyncHandler sends a failed read to the error handler; Express 4 would crash on it.
|
|
427
|
+
router.get(
|
|
428
|
+
["/data/:last", "/data/:last/:date"],
|
|
429
|
+
asyncHandler(async (request, response) => {
|
|
430
|
+
log.info(chalk.cyan("[API] GET /analytics/data/:last/:date?"));
|
|
431
|
+
await handleAnalyticsDataRequest({
|
|
432
|
+
request,
|
|
433
|
+
response,
|
|
434
|
+
statDefinitions,
|
|
435
|
+
});
|
|
436
|
+
})
|
|
437
|
+
);
|
|
288
438
|
|
|
289
|
-
router.get(`/layout`,
|
|
439
|
+
router.get(`/layout`, (req, res) => {
|
|
290
440
|
res.json(statDefinitions.ui || {});
|
|
291
441
|
});
|
|
292
442
|
|
|
293
|
-
router.get(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
443
|
+
router.get(
|
|
444
|
+
`/`,
|
|
445
|
+
asyncHandler(async (request, response) => {
|
|
446
|
+
const fileContents = await readFile(
|
|
447
|
+
path.join(__dirname, "..", "..", "dist", "analytics.html")
|
|
448
|
+
);
|
|
449
|
+
const analyticsHtml = fileContents.toString("utf-8");
|
|
450
|
+
response.send(analyticsHtml);
|
|
451
|
+
})
|
|
452
|
+
);
|
|
300
453
|
|
|
301
454
|
return router;
|
|
302
455
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Express 4 does not catch a rejected promise from an async handler, so one failed read would be an
|
|
2
|
+
// unhandled rejection that kills the process. Express 5 does, but hearald supports both.
|
|
3
|
+
export const asyncHandler = (handler) => (req, res, next) =>
|
|
4
|
+
Promise.resolve()
|
|
5
|
+
.then(() => handler(req, res, next))
|
|
6
|
+
.catch(next);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { timingSafeEqual } from "crypto";
|
|
2
|
+
import log from "./logger.js";
|
|
3
|
+
import { asyncHandler } from "./asyncHandler.js";
|
|
4
|
+
|
|
5
|
+
// Constant-time string comparison.
|
|
6
|
+
export function safeEqual(a, b) {
|
|
7
|
+
const x = Buffer.from(String(a));
|
|
8
|
+
const y = Buffer.from(String(b));
|
|
9
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HTTP Basic authentication for the analytics dashboard.
|
|
14
|
+
*
|
|
15
|
+
* Wrong guesses are counted per `req.ip`. That is the caller's address only when your app sets
|
|
16
|
+
* Express's `trust proxy` to match your setup: behind a reverse proxy without it, every visitor
|
|
17
|
+
* shares the proxy's address (one attacker locks everybody out), and with a `trust proxy` that
|
|
18
|
+
* trusts too much a caller can send a forged X-Forwarded-For to dodge the lockout.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} options
|
|
21
|
+
* @param {(username: string, password: string) => boolean | Promise<boolean>} options.verify
|
|
22
|
+
* Decides whether the credentials are right. Compare with a constant-time function, and store a
|
|
23
|
+
* hash of the password, never the password. If it throws, the request gets a 500 (and is not
|
|
24
|
+
* counted as a wrong guess).
|
|
25
|
+
* @param {string} [options.realm="hearald"]
|
|
26
|
+
* @param {number} [options.maxFailures=10] wrong guesses allowed per IP in the window, before 429
|
|
27
|
+
* @param {number} [options.windowMs=900000] the window, 15 minutes by default
|
|
28
|
+
* @param {number} [options.maxTrackedIps=10000] the most addresses remembered at once, so a flood of
|
|
29
|
+
* distinct addresses cannot grow memory without limit (the oldest are forgotten first)
|
|
30
|
+
*/
|
|
31
|
+
export function basicAuth({ verify, realm = "hearald", maxFailures = 10, windowMs = 15 * 60 * 1000, maxTrackedIps = 10000, now = Date.now } = {}) {
|
|
32
|
+
if (typeof verify !== "function") throw new Error("basicAuth needs a verify(username, password) function");
|
|
33
|
+
const failures = new Map(); // ip -> { count, resetAt }, oldest first
|
|
34
|
+
|
|
35
|
+
function prune(t) {
|
|
36
|
+
for (const [ip, entry] of failures) {
|
|
37
|
+
if (entry.resetAt <= t) failures.delete(ip);
|
|
38
|
+
}
|
|
39
|
+
// Still too many (a burst inside one window): forget the oldest
|
|
40
|
+
for (const ip of failures.keys()) {
|
|
41
|
+
if (failures.size < maxTrackedIps) break;
|
|
42
|
+
failures.delete(ip);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return asyncHandler(async function basicAuthMiddleware(req, res, next) {
|
|
47
|
+
const ip = req.ip || "unknown";
|
|
48
|
+
const t = now();
|
|
49
|
+
let entry = failures.get(ip);
|
|
50
|
+
if (entry && entry.resetAt <= t) {
|
|
51
|
+
failures.delete(ip);
|
|
52
|
+
entry = undefined;
|
|
53
|
+
}
|
|
54
|
+
if (entry && entry.count >= maxFailures) {
|
|
55
|
+
res.set("Retry-After", String(Math.ceil((entry.resetAt - t) / 1000)));
|
|
56
|
+
return res.status(429).send("Too many attempts");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The scheme name is case-insensitive (RFC 7235)
|
|
60
|
+
const match = /^basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(req.get("authorization") || "");
|
|
61
|
+
if (match) {
|
|
62
|
+
// Count the attempt now, before the (possibly slow) verify: if it were counted afterwards,
|
|
63
|
+
// any number of parallel guesses would all be checked against a count of zero.
|
|
64
|
+
if (!entry) {
|
|
65
|
+
prune(t);
|
|
66
|
+
entry = { count: 0, resetAt: t + windowMs };
|
|
67
|
+
failures.set(ip, entry);
|
|
68
|
+
}
|
|
69
|
+
entry.count += 1;
|
|
70
|
+
|
|
71
|
+
const decoded = Buffer.from(match[1], "base64").toString("utf8");
|
|
72
|
+
const colon = decoded.indexOf(":");
|
|
73
|
+
let ok = false;
|
|
74
|
+
try {
|
|
75
|
+
ok = colon > -1 && (await verify(decoded.slice(0, colon), decoded.slice(colon + 1)));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
entry.count -= 1; // a broken verify is not the caller's wrong guess
|
|
78
|
+
log.error("basicAuth: verify threw", err);
|
|
79
|
+
return res.status(500).send("Authentication error");
|
|
80
|
+
}
|
|
81
|
+
if (ok) {
|
|
82
|
+
failures.delete(ip);
|
|
83
|
+
return next();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
res.set("WWW-Authenticate", `Basic realm="${realm}", charset="UTF-8"`);
|
|
87
|
+
return res.status(401).send("Authentication required");
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { iso, nowAsPstDate } from "../shared/utils.js";
|
|
2
|
+
|
|
3
|
+
class Configuration {
|
|
4
|
+
constructor({
|
|
5
|
+
nowFn = nowAsPstDate,
|
|
6
|
+
isoFn = iso,
|
|
7
|
+
logLevel = "warn",
|
|
8
|
+
getUserId = (req) => req.user?.id,
|
|
9
|
+
eventsDir = "events",
|
|
10
|
+
idleCloseMs = 60 * 1000,
|
|
11
|
+
maxWindowBytes = 64 * 1024 * 1024,
|
|
12
|
+
} = {}) {
|
|
13
|
+
this.nowFn = nowFn;
|
|
14
|
+
this.isoFn = isoFn;
|
|
15
|
+
this.logLevel = logLevel;
|
|
16
|
+
this.getUserId = getUserId;
|
|
17
|
+
this.eventsDir = eventsDir;
|
|
18
|
+
this.idleCloseMs = idleCloseMs;
|
|
19
|
+
this.maxWindowBytes = maxWindowBytes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
setLogLevel(level) {
|
|
23
|
+
this.logLevel = level;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setNowFn(nowFn) {
|
|
27
|
+
this.nowFn = nowFn;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setGetUserId(getUserId) {
|
|
31
|
+
this.getUserId = getUserId;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setEventsDir(eventsDir) {
|
|
35
|
+
this.eventsDir = eventsDir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// How long an event file stays open after its last write
|
|
39
|
+
setIdleCloseMs(idleCloseMs) {
|
|
40
|
+
this.idleCloseMs = idleCloseMs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The most event-log bytes one dashboard request may read
|
|
44
|
+
setMaxWindowBytes(maxWindowBytes) {
|
|
45
|
+
this.maxWindowBytes = maxWindowBytes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setIsoFn(isoFn) {
|
|
49
|
+
this.isoFn = isoFn;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default new Configuration();
|
package/src/server/logger.js
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
|
+
import hearaldConfiguration from "./configuration.js";
|
|
2
|
+
|
|
3
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
4
|
+
|
|
1
5
|
class Logger {
|
|
2
|
-
constructor(
|
|
3
|
-
this.
|
|
6
|
+
constructor(config = hearaldConfiguration) {
|
|
7
|
+
this.config = config;
|
|
4
8
|
}
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
this.
|
|
10
|
+
enabled(level) {
|
|
11
|
+
const configured = LEVELS[this.config.logLevel] ?? LEVELS.warn;
|
|
12
|
+
return LEVELS[level] >= configured;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
debug(...args) {
|
|
11
|
-
if (this.
|
|
12
|
-
console.log("[DEBUG]", ...args);
|
|
13
|
-
}
|
|
16
|
+
if (this.enabled("debug")) console.log("[DEBUG]", ...args);
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
info(...args) {
|
|
17
|
-
if (this.
|
|
18
|
-
console.log("[INFO]", ...args);
|
|
19
|
-
}
|
|
20
|
+
if (this.enabled("info")) console.log("[INFO]", ...args);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
warn(...args) {
|
|
23
|
-
if (this.
|
|
24
|
-
console.warn("[WARN]", ...args);
|
|
25
|
-
}
|
|
24
|
+
if (this.enabled("warn")) console.warn("[WARN]", ...args);
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
error(...args) {
|
|
@@ -30,4 +29,5 @@ class Logger {
|
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
export
|
|
32
|
+
export { Logger };
|
|
33
|
+
export default new Logger(hearaldConfiguration);
|