hearald 0.0.5 → 0.1.1
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 +101 -3
- package/README.md +120 -46
- package/dist/analytics.html +6 -6
- package/dist/index.js +6 -6
- package/package.json +21 -5
- package/src/client.js +27 -7
- package/src/index.js +30 -6
- package/src/public/js/App.js +5 -5
- package/src/public/js/util.js +1 -1
- package/src/server/analytics.js +221 -64
- package/src/server/asyncHandler.js +6 -0
- package/src/server/auth.js +89 -0
- package/src/server/configuration.js +21 -1
- package/src/server/logger.js +12 -10
- package/src/server/server.js +47 -9
- package/src/server/serverEvents.js +146 -38
- package/src/server/validate.js +56 -0
- package/src/shared/utils.js +32 -14
- package/.eslintrc +0 -14
- package/.prettierignore +0 -9
- package/TODO.md +0 -34
- package/rollup.config.js +0 -59
package/src/server/analytics.js
CHANGED
|
@@ -1,13 +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
9
|
import hearaldConfiguration from "./configuration.js";
|
|
9
|
-
import { omitProperties, defaultEndDate } from "../shared/utils.js";
|
|
10
|
-
import { loadEventsOverWindow } from "./serverEvents.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
|
+
};
|
|
11
42
|
|
|
12
43
|
export function generateRawMetrics(events, rawMetricDefinitions) {
|
|
13
44
|
const metrics = {};
|
|
@@ -18,12 +49,14 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
|
|
|
18
49
|
} else if (rawMetricDefinition.aggregator === "set_of_users") {
|
|
19
50
|
metrics[name] = new Set();
|
|
20
51
|
} else if (rawMetricDefinition.aggregator === "histogram") {
|
|
21
|
-
|
|
52
|
+
// no prototype, so a value named "constructor" or "__proto__" is just a key
|
|
53
|
+
metrics[name] = Object.create(null);
|
|
22
54
|
}
|
|
23
55
|
}
|
|
24
56
|
);
|
|
25
57
|
|
|
26
|
-
events.forEach(({ e, p, u }) => {
|
|
58
|
+
events.forEach(({ e, p: eventParams, u }) => {
|
|
59
|
+
const p = eventParams || {};
|
|
27
60
|
Object.entries(rawMetricDefinitions).forEach(
|
|
28
61
|
([name, rawMetricDefinition]) => {
|
|
29
62
|
if (rawMetricDefinition.filter) {
|
|
@@ -32,19 +65,26 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
|
|
|
32
65
|
);
|
|
33
66
|
if (!match) return;
|
|
34
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
|
+
}
|
|
35
76
|
if (
|
|
36
77
|
rawMetricDefinition.aggregator === "count" &&
|
|
37
78
|
rawMetricDefinition.event === e
|
|
38
79
|
) {
|
|
39
80
|
metrics[name] += 1;
|
|
40
81
|
} else if (rawMetricDefinition.aggregator === "set_of_users") {
|
|
41
|
-
metrics[name].add(u);
|
|
82
|
+
if (u !== null && u !== undefined) metrics[name].add(u);
|
|
42
83
|
} else if (
|
|
43
84
|
rawMetricDefinition.aggregator === "histogram" &&
|
|
44
85
|
rawMetricDefinition.event === e
|
|
45
86
|
) {
|
|
46
|
-
const
|
|
47
|
-
const value = p && p[field] ? p[field] : null;
|
|
87
|
+
const value = histogramValue(p, rawMetricDefinition.field);
|
|
48
88
|
if (value !== null) {
|
|
49
89
|
metrics[name][value] = (metrics[name][value] || 0) + 1;
|
|
50
90
|
}
|
|
@@ -88,39 +128,70 @@ export function generateComputedMetric(metrics, definition, timeWindow) {
|
|
|
88
128
|
// Compute histogram for given event type and field
|
|
89
129
|
const sourceEvent = definition.source;
|
|
90
130
|
const field = definition.field;
|
|
91
|
-
const histogram =
|
|
131
|
+
const histogram = Object.create(null);
|
|
92
132
|
// Find all events of the given type
|
|
93
133
|
(metrics._allEvents || []).forEach((ev) => {
|
|
94
|
-
|
|
95
|
-
|
|
134
|
+
const value = ev.e === sourceEvent ? histogramValue(ev.p, field) : null;
|
|
135
|
+
if (value !== null) {
|
|
96
136
|
histogram[value] = (histogram[value] || 0) + 1;
|
|
97
137
|
}
|
|
98
138
|
});
|
|
99
|
-
|
|
139
|
+
// back to a plain object (fromEntries keeps a "__proto__" key as an ordinary key)
|
|
140
|
+
return Object.fromEntries(Object.entries(histogram));
|
|
100
141
|
}
|
|
142
|
+
default:
|
|
143
|
+
throw new Error(`hearald: unknown formula "${definition.formula}"`);
|
|
101
144
|
}
|
|
102
145
|
}
|
|
103
146
|
|
|
104
|
-
|
|
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) {
|
|
105
163
|
const endDateIso =
|
|
106
164
|
typeof endDate === "string" ? endDate : hearaldConfiguration.isoFn(endDate);
|
|
107
|
-
return
|
|
165
|
+
return path.join(
|
|
166
|
+
hearaldConfiguration.eventsDir,
|
|
167
|
+
`summary:${endDateIso}:${timeWindow}:${cacheKey(statDefinitions)}.json`
|
|
168
|
+
);
|
|
108
169
|
}
|
|
109
170
|
|
|
110
|
-
export async function readMetricsFromCache(
|
|
111
|
-
|
|
171
|
+
export async function readMetricsFromCache(
|
|
172
|
+
endDateIso,
|
|
173
|
+
timeWindow,
|
|
174
|
+
statDefinitions
|
|
175
|
+
) {
|
|
176
|
+
const filePath = cacheFilePath(endDateIso, timeWindow, statDefinitions);
|
|
112
177
|
try {
|
|
113
178
|
return (await readFile(filePath)).toString("utf8");
|
|
114
179
|
} catch (e) {
|
|
115
|
-
if (e.
|
|
180
|
+
if (e.code === "ENOENT") {
|
|
116
181
|
return null;
|
|
117
182
|
}
|
|
118
183
|
throw e;
|
|
119
184
|
}
|
|
120
185
|
}
|
|
121
186
|
|
|
122
|
-
export async function writeMetricsToCache(
|
|
123
|
-
|
|
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 });
|
|
124
195
|
return writeFile(filePath, statsJson);
|
|
125
196
|
}
|
|
126
197
|
|
|
@@ -134,8 +205,17 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
134
205
|
if (def.source_file_suffix) fileSuffixes.add(def.source_file_suffix);
|
|
135
206
|
});
|
|
136
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
|
+
|
|
137
217
|
// Load events from all relevant files for raw metrics
|
|
138
|
-
let
|
|
218
|
+
let processedEventCount = 0;
|
|
139
219
|
const eventsBySuffix = {};
|
|
140
220
|
for (const suffix of fileSuffixes) {
|
|
141
221
|
const events = await loadEventsOverWindow(
|
|
@@ -143,7 +223,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
143
223
|
timeWindow,
|
|
144
224
|
suffix || undefined
|
|
145
225
|
);
|
|
146
|
-
|
|
226
|
+
processedEventCount += events.length;
|
|
147
227
|
eventsBySuffix[suffix || ""] = events;
|
|
148
228
|
}
|
|
149
229
|
|
|
@@ -156,8 +236,18 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
156
236
|
];
|
|
157
237
|
});
|
|
158
238
|
|
|
159
|
-
//
|
|
160
|
-
|
|
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
|
+
}
|
|
161
251
|
|
|
162
252
|
let anyComputed = true;
|
|
163
253
|
while (anyComputed) {
|
|
@@ -179,7 +269,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
179
269
|
computedMetricDefinition,
|
|
180
270
|
timeWindow
|
|
181
271
|
);
|
|
182
|
-
anyComputed =
|
|
272
|
+
anyComputed = typeof metrics[name] !== "undefined";
|
|
183
273
|
}
|
|
184
274
|
}
|
|
185
275
|
}
|
|
@@ -212,7 +302,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
|
212
302
|
metrics: nestedMetrics,
|
|
213
303
|
endDate: hearaldConfiguration.isoFn(endDate),
|
|
214
304
|
timeWindow,
|
|
215
|
-
processedEventCount
|
|
305
|
+
processedEventCount,
|
|
216
306
|
};
|
|
217
307
|
}
|
|
218
308
|
|
|
@@ -221,39 +311,81 @@ export async function handleAnalyticsDataRequest({
|
|
|
221
311
|
response,
|
|
222
312
|
statDefinitions,
|
|
223
313
|
}) {
|
|
224
|
-
const defaultEndDateIso = hearaldConfiguration.isoFn(
|
|
314
|
+
const defaultEndDateIso = hearaldConfiguration.isoFn(
|
|
315
|
+
defaultEndDate(hearaldConfiguration.nowFn())
|
|
316
|
+
);
|
|
225
317
|
const endDateIso = request.params.date
|
|
226
318
|
? request.params.date
|
|
227
319
|
: defaultEndDateIso;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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" });
|
|
231
328
|
return;
|
|
232
329
|
}
|
|
233
|
-
const
|
|
234
|
-
|
|
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) => {
|
|
235
341
|
log.info(
|
|
236
342
|
`Analytics for ${timeWindow} days ending on ${endDateIso} returned from cache`
|
|
237
343
|
);
|
|
238
|
-
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);
|
|
239
351
|
return;
|
|
240
352
|
}
|
|
241
|
-
log.info(
|
|
242
|
-
`Analytics for ${timeWindow} days ending on ${endDateIso} computing from events`
|
|
243
|
-
);
|
|
244
353
|
try {
|
|
245
|
-
const stats = await
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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));
|
|
256
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
|
+
}
|
|
257
389
|
log.error("failed to compute analytics", e);
|
|
258
390
|
response
|
|
259
391
|
.status(500)
|
|
@@ -268,31 +400,56 @@ const __dirname = path.dirname(__filename);
|
|
|
268
400
|
log.info("filename is", __filename);
|
|
269
401
|
log.info("dirname is", __dirname);
|
|
270
402
|
|
|
271
|
-
|
|
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
|
+
}
|
|
272
421
|
log.info("statManifest", statDefinitions);
|
|
273
422
|
const router = express.Router();
|
|
423
|
+
if (typeof auth === "function") router.use(auth);
|
|
274
424
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
);
|
|
284
438
|
|
|
285
|
-
router.get(`/layout`,
|
|
439
|
+
router.get(`/layout`, (req, res) => {
|
|
286
440
|
res.json(statDefinitions.ui || {});
|
|
287
441
|
});
|
|
288
442
|
|
|
289
|
-
router.get(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
);
|
|
296
453
|
|
|
297
454
|
return router;
|
|
298
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
|
+
}
|
|
@@ -4,13 +4,19 @@ class Configuration {
|
|
|
4
4
|
constructor({
|
|
5
5
|
nowFn = nowAsPstDate,
|
|
6
6
|
isoFn = iso,
|
|
7
|
-
logLevel = "
|
|
7
|
+
logLevel = "warn",
|
|
8
8
|
getUserId = (req) => req.user?.id,
|
|
9
|
+
eventsDir = "events",
|
|
10
|
+
idleCloseMs = 60 * 1000,
|
|
11
|
+
maxWindowBytes = 64 * 1024 * 1024,
|
|
9
12
|
} = {}) {
|
|
10
13
|
this.nowFn = nowFn;
|
|
11
14
|
this.isoFn = isoFn;
|
|
12
15
|
this.logLevel = logLevel;
|
|
13
16
|
this.getUserId = getUserId;
|
|
17
|
+
this.eventsDir = eventsDir;
|
|
18
|
+
this.idleCloseMs = idleCloseMs;
|
|
19
|
+
this.maxWindowBytes = maxWindowBytes;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
setLogLevel(level) {
|
|
@@ -25,6 +31,20 @@ class Configuration {
|
|
|
25
31
|
this.getUserId = getUserId;
|
|
26
32
|
}
|
|
27
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
|
+
|
|
28
48
|
setIsoFn(isoFn) {
|
|
29
49
|
this.isoFn = isoFn;
|
|
30
50
|
}
|
package/src/server/logger.js
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
import hearaldConfiguration from "./configuration.js";
|
|
2
2
|
|
|
3
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
4
|
+
|
|
3
5
|
class Logger {
|
|
4
6
|
constructor(config = hearaldConfiguration) {
|
|
5
7
|
this.config = config;
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
enabled(level) {
|
|
11
|
+
const configured = LEVELS[this.config.logLevel] ?? LEVELS.warn;
|
|
12
|
+
return LEVELS[level] >= configured;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
debug(...args) {
|
|
9
|
-
if (this.
|
|
10
|
-
console.log("[DEBUG]", ...args);
|
|
11
|
-
}
|
|
16
|
+
if (this.enabled("debug")) console.log("[DEBUG]", ...args);
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
info(...args) {
|
|
15
|
-
if (this.
|
|
16
|
-
console.log("[INFO]", ...args);
|
|
17
|
-
}
|
|
20
|
+
if (this.enabled("info")) console.log("[INFO]", ...args);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
warn(...args) {
|
|
21
|
-
if (this.
|
|
22
|
-
console.warn("[WARN]", ...args);
|
|
23
|
-
}
|
|
24
|
+
if (this.enabled("warn")) console.warn("[WARN]", ...args);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
error(...args) {
|
|
@@ -28,4 +29,5 @@ class Logger {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export
|
|
32
|
+
export { Logger };
|
|
33
|
+
export default new Logger(hearaldConfiguration);
|