analytics-platform 0.0.0-stage → 0.2.2

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/lib/errors.js ADDED
@@ -0,0 +1,178 @@
1
+ // Error monitoring module: the server's errors go to the collector (POST
2
+ // /events/server-errors), where they join the browser's errors in the Errors
3
+ // report. Uncaught exceptions and unhandled rejections are captured on their
4
+ // own and the process still exits as Node would. errorHandler() captures
5
+ // Express request errors (5xx only); captureException() captures errors you
6
+ // catch. Errors are sent in small batches; sending never throws into your app.
7
+ "use strict";
8
+
9
+ const os = require("node:os");
10
+ const { sessionOf } = require("./core.js");
11
+
12
+ const BATCH = 10;
13
+ const QUEUE_MAX = 100;
14
+ const FLUSH_MS = 2000;
15
+ const EXIT_FLUSH_MS = 2000;
16
+ // Every instance's uncaught-exception handler, with its flush: two platforms in
17
+ // one process are still "no other handler", and the exit waits for both.
18
+ const handlers = new Map();
19
+ let exiting = false;
20
+
21
+ // Options: serverName (default the host name), root (paths in stacks are
22
+ // made relative to it; default process.cwd()), release (which build this is,
23
+ // for "introduced in" in the Errors report; defaults to ANALYTICS_RELEASE in
24
+ // the environment), captureUncaught (default true), beforeSend(event, error) to
25
+ // change an event or drop it (return null).
26
+ function createErrors(core, options = {}) {
27
+ const config = {
28
+ serverName: String(options.serverName ?? os.hostname()).slice(0, 100),
29
+ root: String(options.root ?? process.cwd()),
30
+ // Letters, numbers and .-_+ only, as the collector stores it.
31
+ release: String(options.release ?? process.env.ANALYTICS_RELEASE ?? "")
32
+ .trim().replace(/[^\w.+-]/g, "").slice(0, 64),
33
+ beforeSend: typeof options.beforeSend === "function" ? options.beforeSend : null,
34
+ };
35
+ let queue = [];
36
+ let timer = null;
37
+ let sending = Promise.resolve();
38
+ const log = (...args) => core.log("errors", ...args);
39
+
40
+ // Paths relative to the app's root, so an issue keeps its identity across
41
+ // servers and deploy directories.
42
+ function relative(stack) {
43
+ const root = config.root.replace(/\/+$/, "");
44
+ return root ? stack.split(`file://${root}/`).join("").split(`${root}/`).join("") : stack;
45
+ }
46
+
47
+ // Fields of the request the error happened in (Express, or Node's own
48
+ // http.IncomingMessage).
49
+ function requestFields(req) {
50
+ if (!req || typeof req !== "object") return {};
51
+ const url = String(req.originalUrl || req.url || "");
52
+ const route = req.route?.path && typeof req.route.path === "string" ? `${req.baseUrl || ""}${req.route.path}` : "";
53
+ return {
54
+ method: String(req.method || ""),
55
+ path: url.split("?")[0],
56
+ route,
57
+ session_id: sessionOf(req),
58
+ };
59
+ }
60
+
61
+ function toEvent(error, { req, status, kind = "handled" } = {}) {
62
+ const isError = error instanceof Error;
63
+ const message = isError ? error.message : typeof error === "string" ? error : safeString(error);
64
+ return {
65
+ message: message || (isError ? error.name : "Unknown error"),
66
+ error_type: isError ? error.name || error.constructor?.name || "Error" : "Error",
67
+ stack: isError && typeof error.stack === "string" ? relative(error.stack) : "",
68
+ kind,
69
+ status_code: Number(status) || 0,
70
+ runtime: "Node",
71
+ runtime_version: process.versions.node,
72
+ os: process.platform,
73
+ server_name: config.serverName,
74
+ release: config.release,
75
+ ...requestFields(req),
76
+ };
77
+ }
78
+
79
+ function safeString(value) {
80
+ try {
81
+ return JSON.stringify(value);
82
+ } catch {
83
+ return String(value);
84
+ }
85
+ }
86
+
87
+ function schedule() {
88
+ if (timer || !queue.length) return;
89
+ timer = setTimeout(() => {
90
+ timer = null;
91
+ flush();
92
+ }, FLUSH_MS);
93
+ timer.unref?.();
94
+ }
95
+
96
+ const send = (errors) => core.send("errors", "/events/server-errors", { body: { errors } });
97
+
98
+ // Sends everything queued; resolves when sent (or failed).
99
+ function flush() {
100
+ if (timer) {
101
+ clearTimeout(timer);
102
+ timer = null;
103
+ }
104
+ const batches = [];
105
+ while (queue.length) batches.push(queue.splice(0, BATCH));
106
+ sending = sending.then(() => Promise.all(batches.map(send)));
107
+ return sending;
108
+ }
109
+
110
+ // Queues one error; `context` is { req, status, kind }. Returns nothing and
111
+ // never throws.
112
+ function captureException(error, context = {}) {
113
+ if (!core.isOn("errors")) return;
114
+ try {
115
+ let event = toEvent(error, context);
116
+ if (config.beforeSend) event = config.beforeSend(event, error);
117
+ if (!event) return;
118
+ if (queue.length >= QUEUE_MAX) {
119
+ log("queue full, error dropped");
120
+ return;
121
+ }
122
+ queue.push(event);
123
+ if (queue.length >= BATCH) flush();
124
+ else schedule();
125
+ } catch (failure) {
126
+ log("could not capture error:", failure?.message);
127
+ }
128
+ }
129
+
130
+ // Express error middleware; use after your routes. Captures 5xx errors (4xx
131
+ // are the client's) and passes the error on unchanged.
132
+ function errorHandler({ shouldCapture } = {}) {
133
+ return function analyticsErrorHandler(error, req, res, next) {
134
+ const status = Number(error?.status || error?.statusCode) || 500;
135
+ const capture = shouldCapture ? shouldCapture(error, req) : status >= 500;
136
+ if (capture) captureException(error, { req, status, kind: "handled" });
137
+ next(error);
138
+ };
139
+ }
140
+
141
+ // Uncaught exceptions, and unhandled rejections (which Node turns into
142
+ // uncaught exceptions by default). When no other handler is listening, the
143
+ // error is printed and the process exits with code 1, as without the SDK,
144
+ // after up to EXIT_FLUSH_MS to send it.
145
+ function onUncaught(error, origin) {
146
+ captureException(error, { kind: origin === "unhandledRejection" ? "rejection" : "error" });
147
+ if (exiting || process.listeners("uncaughtException").some((listener) => !handlers.has(listener))) return;
148
+ exiting = true;
149
+ console.error(error);
150
+ // After the other instances' handlers (called right after this one) have
151
+ // queued the error too.
152
+ process.nextTick(() => {
153
+ const exit = setTimeout(() => process.exit(1), EXIT_FLUSH_MS);
154
+ Promise.allSettled([...handlers.values()].map((send) => send())).finally(() => {
155
+ clearTimeout(exit);
156
+ process.exit(1);
157
+ });
158
+ });
159
+ }
160
+
161
+ if (options.captureUncaught !== false) {
162
+ handlers.set(onUncaught, flush);
163
+ process.on("uncaughtException", onUncaught);
164
+ }
165
+
166
+ return {
167
+ captureException,
168
+ errorHandler,
169
+ flush,
170
+ close() {
171
+ process.off("uncaughtException", onUncaught);
172
+ handlers.delete(onUncaught);
173
+ return flush();
174
+ },
175
+ };
176
+ }
177
+
178
+ module.exports = { createErrors };
@@ -0,0 +1,262 @@
1
+ // Firewall module: rules from the collector (GET /events/firewall/rules,
2
+ // protocol in sdk/protocols/firewall.md) every minute or so; every request is
3
+ // checked against them in memory, so the app never waits on the collector.
4
+ // What was blocked (or, in monitor mode, would have been) goes back in
5
+ // batches. Fails open: until rules arrive, and whenever the collector can't be
6
+ // reached, requests pass (with the last rules that arrived, if any).
7
+ "use strict";
8
+
9
+ const fs = require("node:fs");
10
+ const { parseIp, parseRange, IpSet } = require("./ip.js");
11
+ const { createLocalBans, compileProbes, isProbe } = require("./bans.js");
12
+ const { compileRequestRules, matchRequest } = require("./requestRules.js");
13
+
14
+ const PROTOCOL = 1;
15
+ const MIN_SYNC_S = 30;
16
+ const RETRY_S = 60;
17
+ const FLUSH_MS = 5000;
18
+ const BATCH = 200;
19
+ // Distinct (address, rule, path) rows waiting to be sent; more are dropped.
20
+ const QUEUE_MAX = 2000;
21
+ // Addresses the rate limit counts at once; past it the counts start over.
22
+ const COUNTER_MAX = 100000;
23
+
24
+ // ---- The client's address -------------------------------------------------
25
+
26
+ function header(req, name) {
27
+ const value = req.headers?.[name];
28
+ return String(Array.isArray(value) ? value[0] : value || "").trim();
29
+ }
30
+
31
+ // trustProxy: undefined uses Express's req.ip (its own "trust proxy"
32
+ // setting), else the connection's address; a number trusts that many proxies
33
+ // in X-Forwarded-For (true = 1); "cloudflare" reads CF-Connecting-IP (only
34
+ // when every request comes through Cloudflare); a function returns the address.
35
+ function clientIp(req, trustProxy) {
36
+ if (typeof trustProxy === "function") return String(trustProxy(req) || "");
37
+ const direct = req.socket?.remoteAddress || "";
38
+ if (trustProxy === "cloudflare") return header(req, "cf-connecting-ip") || direct;
39
+ if (trustProxy === true || (Number.isInteger(trustProxy) && trustProxy > 0)) {
40
+ const hops = trustProxy === true ? 1 : trustProxy;
41
+ const chain = header(req, "x-forwarded-for").split(",").map((s) => s.trim()).filter(Boolean);
42
+ return chain.length ? chain[Math.max(0, chain.length - hops)] : direct;
43
+ }
44
+ return req.ip || direct;
45
+ }
46
+
47
+ // ---- The firewall ---------------------------------------------------------
48
+
49
+ // Options: trustProxy (see clientIp); mode ("monitor" or "block": overrides
50
+ // the dashboard's); skip(req) (true leaves a request unchecked);
51
+ // onBlock(req, res, decision) (send your own response); cacheFile (rules are
52
+ // kept there between restarts).
53
+ function createFirewall(core, options = {}) {
54
+ const valid = core.valid;
55
+ const log = (...args) => core.log("firewall", ...args);
56
+
57
+ let rules = null;
58
+ let etag = "";
59
+ let syncTimer = null;
60
+ let flushTimer = null;
61
+ let closed = false;
62
+ const counts = new Map();
63
+ const local = createLocalBans();
64
+ let queue = new Map();
65
+
66
+ function compile(data) {
67
+ if (!data || data.v !== PROTOCOL) throw new Error(`unknown rules version ${data?.v}`);
68
+ return {
69
+ enabled: data.enabled === true,
70
+ mode: data.mode === "block" ? "block" : "monitor",
71
+ sync: Math.max(MIN_SYNC_S, Number(data.sync) || RETRY_S),
72
+ allow: new IpSet(data.allow),
73
+ // Crawlers the rate limit must not catch; absent from older rules, which
74
+ // is simply an empty set.
75
+ goodbots: new IpSet(data.goodbots),
76
+ deny: new IpSet(data.deny),
77
+ list: new IpSet(data.lists),
78
+ shared: new IpSet(data.shared),
79
+ rate: data.rate && Number(data.rate.limit) > 0
80
+ ? { limit: Number(data.rate.limit), window: Math.max(1, Number(data.rate.window) || 60) * 1000 }
81
+ : null,
82
+ banned: new IpSet(data.banned),
83
+ ban: data.ban && Number(data.ban.seconds) > 0 ? { seconds: Number(data.ban.seconds) } : null,
84
+ login: data.login && Number(data.login.limit) > 0
85
+ ? { limit: Number(data.login.limit), window: Number(data.login.window) || 3600, ban: Number(data.login.ban) || 3600 }
86
+ : null,
87
+ probes: compileProbes(data.probes),
88
+ // Virtual patching: rules that match the request, not the address.
89
+ requests: compileRequestRules(data.requests),
90
+ };
91
+ }
92
+
93
+ if (options.cacheFile) {
94
+ try {
95
+ const cached = JSON.parse(fs.readFileSync(options.cacheFile, "utf8"));
96
+ rules = compile(cached.rules);
97
+ etag = cached.etag || "";
98
+ } catch (error) {
99
+ if (error.code !== "ENOENT") log("could not read cacheFile:", error.message);
100
+ }
101
+ }
102
+
103
+ // Fixed window per address.
104
+ function overRate(key, rate) {
105
+ const now = Date.now();
106
+ let entry = counts.get(key);
107
+ if (!entry || now - entry.start >= rate.window) {
108
+ if (!entry && counts.size >= COUNTER_MAX) counts.clear();
109
+ entry = { start: now, hits: 0 };
110
+ counts.set(key, entry);
111
+ }
112
+ entry.hits += 1;
113
+ return entry.hits > rate.limit;
114
+ }
115
+
116
+ function report(event) {
117
+ const key = `${event.ip}|${event.rule}|${event.action}|${event.method}|${event.path}`;
118
+ const row = queue.get(key);
119
+ if (row) row.hits += 1;
120
+ else if (queue.size < QUEUE_MAX) queue.set(key, { ...event, at: new Date().toISOString(), hits: 1 });
121
+ if (queue.size >= BATCH) flush();
122
+ else if (!flushTimer) {
123
+ flushTimer = setTimeout(flush, FLUSH_MS);
124
+ flushTimer.unref?.();
125
+ }
126
+ }
127
+
128
+ async function flush() {
129
+ clearTimeout(flushTimer);
130
+ flushTimer = null;
131
+ if (!queue.size || !valid) return;
132
+ const rows = [...queue.values()];
133
+ queue = new Map();
134
+ for (let i = 0; i < rows.length; i += BATCH) {
135
+ await core.send("firewall", "/events/firewall/events", {
136
+ auth: "bearer",
137
+ body: { site_id: core.siteId, events: rows.slice(i, i + BATCH) },
138
+ });
139
+ }
140
+ }
141
+
142
+ async function sync() {
143
+ let wait = rules?.sync || RETRY_S;
144
+ try {
145
+ const res = await core.send("firewall", `/events/firewall/rules?site_id=${encodeURIComponent(core.siteId)}`, {
146
+ method: "GET", auth: "bearer", headers: etag ? { "if-none-match": etag } : {},
147
+ });
148
+ if (res?.status === 200) {
149
+ const data = await res.json();
150
+ rules = compile(data);
151
+ etag = res.headers.get("etag") || "";
152
+ wait = rules.sync;
153
+ if (options.cacheFile) {
154
+ fs.promises.writeFile(options.cacheFile, JSON.stringify({ etag, rules: data }))
155
+ .catch((error) => log("could not write cacheFile:", error.message));
156
+ }
157
+ }
158
+ } catch (error) {
159
+ log("could not fetch rules:", error?.message);
160
+ }
161
+ if (!closed) {
162
+ syncTimer = setTimeout(sync, wait * 1000);
163
+ syncTimer.unref?.();
164
+ }
165
+ }
166
+
167
+ // { ip, method, path, userAgent } -> { action: "allow" | "block" |
168
+ // "monitor", rule, status }. Allowed addresses skip every other rule.
169
+ function check({ ip, method = "", path = "", userAgent = "" } = {}) {
170
+ const allow = { action: "allow", rule: "", status: 200 };
171
+ const current = rules;
172
+ if (!current?.enabled) return allow;
173
+ const address = parseIp(ip);
174
+ if (!address || current.allow.has(address)) return allow;
175
+ const mode = options.mode === "block" || options.mode === "monitor" ? options.mode : current.mode;
176
+ const action = mode === "block" ? "block" : "monitor";
177
+ // A request rule may only watch, even when the site blocks.
178
+ let effective = action;
179
+ let rule = current.deny.has(address) ? "deny"
180
+ : current.banned.has(address) || local.banned(String(ip)) ? "ban"
181
+ : current.list.has(address) ? "list"
182
+ : current.shared.has(address) ? "shared" : "";
183
+ if (!rule && isProbe(path, current.probes)) {
184
+ rule = "probe";
185
+ // Asking for a secret file: banned here in block mode; the report bans it on the other servers.
186
+ if (action === "block") local.ban(String(ip), current.probes.ban);
187
+ }
188
+ if (!rule) {
189
+ const matched = matchRequest(current.requests, { method, path, userAgent });
190
+ if (matched) {
191
+ rule = `request:${matched.id}`;
192
+ effective = action === "block" && matched.action === "block" ? "block" : "monitor";
193
+ // Banned here straight away; the report bans it on the site's other servers.
194
+ if (effective === "block" && matched.ban) local.ban(String(ip), matched.ban);
195
+ }
196
+ }
197
+ if (!rule && current.rate && !current.goodbots.has(address) && overRate(String(ip), current.rate)) {
198
+ rule = "rate";
199
+ // Banned here straight away in block mode, as a probe is; the report bans it on the other servers.
200
+ if (action === "block" && current.ban) local.ban(String(ip), current.ban.seconds);
201
+ }
202
+ if (!rule) return allow;
203
+ report({
204
+ ip: String(ip),
205
+ rule,
206
+ action: effective,
207
+ method: String(method).slice(0, 10),
208
+ path: String(path).split("?")[0].slice(0, 300),
209
+ user_agent: String(userAgent).slice(0, 300),
210
+ });
211
+ return { action: effective, rule, status: rule === "rate" ? 429 : 403 };
212
+ }
213
+
214
+ // A failed login on your site (req, or the address). Counted here: past the
215
+ // site's limit the address is banned straight away, and reported, so it
216
+ // counts on the Security page and gets banned on the other servers too.
217
+ function loginFailed(reqOrIp) {
218
+ try {
219
+ const ip = typeof reqOrIp === "string" ? reqOrIp : clientIp(reqOrIp, options.trustProxy);
220
+ if (!parseIp(ip) || rules?.allow.has(parseIp(ip))) return;
221
+ local.loginFailed(String(ip), rules?.login);
222
+ report({ ip: String(ip), rule: "login", action: "monitor", method: "", path: "", user_agent: "" });
223
+ } catch (error) {
224
+ log("could not count a failed login:", error?.message);
225
+ }
226
+ }
227
+
228
+ // Express / Connect / node:http middleware.
229
+ function middleware(req, res, next = () => {}) {
230
+ let decision;
231
+ try {
232
+ if (options.skip?.(req)) return next();
233
+ decision = check({
234
+ ip: clientIp(req, options.trustProxy),
235
+ method: req.method,
236
+ path: req.originalUrl || req.url,
237
+ userAgent: header(req, "user-agent"),
238
+ });
239
+ } catch (error) {
240
+ log("check failed, request passed:", error?.message);
241
+ return next();
242
+ }
243
+ if (decision.action !== "block") return next();
244
+ if (options.onBlock) return options.onBlock(req, res, decision);
245
+ res.statusCode = decision.status;
246
+ if (decision.status === 429 && rules?.rate) res.setHeader("retry-after", String(Math.ceil(rules.rate.window / 1000)));
247
+ res.setHeader("content-type", "text/plain; charset=utf-8");
248
+ res.end(decision.status === 429 ? "Too Many Requests" : "Forbidden");
249
+ }
250
+
251
+ // Stops syncing and sends what's waiting.
252
+ function close() {
253
+ closed = true;
254
+ clearTimeout(syncTimer);
255
+ return flush();
256
+ }
257
+
258
+ const ready = valid ? sync() : Promise.resolve();
259
+ return { middleware, check, loginFailed, flush, close, ready, clientIp: (req) => clientIp(req, options.trustProxy) };
260
+ }
261
+
262
+ module.exports = { createFirewall, clientIp, parseIp, parseRange, IpSet };
package/lib/flags.js ADDED
@@ -0,0 +1,41 @@
1
+ // Feature flags module: turn features of your app on or off from the
2
+ // dashboard (Security → Server package), for everyone, for a share of users, or
3
+ // for listed user IDs. isEnabled() answers from memory; the flags come with
4
+ // the module switches (core.js), about once a minute.
5
+ "use strict";
6
+
7
+ // FNV-1a over "key:user", 0-99: the same user always gets the same answer,
8
+ // in every package (Python and PHP work it out the same way).
9
+ function bucket(key, user) {
10
+ let hash = 0x811c9dc5;
11
+ for (const byte of new TextEncoder().encode(`${key}:${user}`)) {
12
+ hash ^= byte;
13
+ hash = Math.imul(hash, 0x01000193) >>> 0;
14
+ }
15
+ return hash % 100;
16
+ }
17
+
18
+ function evaluate(flag, key, user) {
19
+ if (!flag.on) return false;
20
+ const id = user === undefined || user === null ? "" : String(user);
21
+ if (id && flag.users?.includes(id)) return true;
22
+ if (flag.rollout >= 100) return true;
23
+ if (flag.rollout <= 0 || !id) return false;
24
+ return bucket(key, id) < flag.rollout;
25
+ }
26
+
27
+ function createFlags(core) {
28
+ // key: the flag's key; options: user (for rollouts and user lists),
29
+ // default (when the flag isn't known yet, or the module is off; false).
30
+ function isEnabled(key, { user, default: fallback = false } = {}) {
31
+ try {
32
+ const flag = core.isOn("flags") ? core.flags()[key] : undefined;
33
+ return flag ? evaluate(flag, key, user) : fallback;
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
38
+ return { isEnabled };
39
+ }
40
+
41
+ module.exports = { createFlags, bucket, evaluate };
package/lib/forms.js ADDED
@@ -0,0 +1,81 @@
1
+ // Form protection module: checks a form your server received before you
2
+ // accept it (POST /collectanalytics/shield/verify, the same check as spam
3
+ // protection's). It reads the hidden "analytics-shield" answer the tracker
4
+ // puts in protected forms, and checks the email, name and text for fake
5
+ // sign-ups. Spam lands in the site's spam folder (Leads → Spam). Passwords,
6
+ // card numbers and tokens are never sent. Fails open: when the check can't be
7
+ // made, the form is accepted (checked: false).
8
+ "use strict";
9
+
10
+ const PRIVATE_FIELD = /pass|card|cvc|cvv|secret|token|ssn|iban|csrf|analytics-shield/i;
11
+ const MAX_FIELDS = 50;
12
+ const ANSWER = "analytics-shield";
13
+
14
+ function plainFields(body) {
15
+ const out = {};
16
+ if (!body || typeof body !== "object") return out;
17
+ for (const [label, value] of Object.entries(body).slice(0, MAX_FIELDS)) {
18
+ if (PRIVATE_FIELD.test(label)) continue;
19
+ if (typeof value === "string") out[label] = value.slice(0, 2000);
20
+ else if (Array.isArray(value)) out[label] = value.filter((v) => typeof v === "string").join(", ").slice(0, 2000);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ // clientIp(req): the firewall's way of finding the visitor's address.
26
+ function createForms(core, { clientIp } = {}) {
27
+ const log = (...args) => core.log("forms", ...args);
28
+ const open = (reason) => ({ ok: true, spam: false, reasons: reason ? [reason] : [], checked: false });
29
+
30
+ // req: the request (its body is the form, parsed by your body parser), or
31
+ // null with options.fields. Options: fields, email, name, form (its name in
32
+ // the spam folder). Resolves to { ok, spam, reasons, checked }.
33
+ async function checkForm(req, options = {}) {
34
+ if (!core.isOn("forms")) return open();
35
+ try {
36
+ const body = options.fields || req?.body || {};
37
+ const token = typeof body[ANSWER] === "string" ? body[ANSWER] : "";
38
+ const ip = req ? (clientIp ? clientIp(req) : req.ip || req.socket?.remoteAddress || "") : "";
39
+ const res = await core.send("forms", "/collectanalytics/shield/verify", {
40
+ body: {
41
+ token,
42
+ fields: plainFields(body),
43
+ email: typeof options.email === "string" ? options.email : undefined,
44
+ name: typeof options.name === "string" ? options.name : undefined,
45
+ form: typeof options.form === "string" ? options.form : undefined,
46
+ ip,
47
+ },
48
+ });
49
+ if (!res || res.status >= 500 || res.status === 429) return open("unchecked");
50
+ const data = await res.json().catch(() => null);
51
+ if (res.status === 403) {
52
+ log("the site secret was refused; forms aren't checked");
53
+ return open("unchecked");
54
+ }
55
+ const spam = data?.success === false;
56
+ return { ok: !spam, spam, reasons: Array.isArray(data?.reasons) ? data.reasons : [], checked: true };
57
+ } catch (error) {
58
+ log("could not check a form:", error?.message);
59
+ return open("unchecked");
60
+ }
61
+ }
62
+
63
+ // Express middleware for a form's route (after your body parser). Spam gets
64
+ // a 422 (or your onSpam(req, res, result)); otherwise req.formCheck holds the
65
+ // result and the route runs.
66
+ function protectForm(options = {}) {
67
+ return async function analyticsProtectForm(req, res, next = () => {}) {
68
+ const result = await checkForm(req, options);
69
+ req.formCheck = result;
70
+ if (!result.spam) return next();
71
+ if (options.onSpam) return options.onSpam(req, res, result);
72
+ res.statusCode = 422;
73
+ res.setHeader("content-type", "text/plain; charset=utf-8");
74
+ res.end("Your message looks automated, so it wasn't sent. Please try again.");
75
+ };
76
+ }
77
+
78
+ return { checkForm, protectForm };
79
+ }
80
+
81
+ module.exports = { createForms, plainFields };
package/lib/headers.js ADDED
@@ -0,0 +1,22 @@
1
+ // Security headers module: adds the headers picked on the dashboard's
2
+ // Security page (they come with the module switches, core.js) to every
3
+ // response, unless the app sets that header itself: your own code wins.
4
+ "use strict";
5
+
6
+ function createHeaders(core) {
7
+ function middleware(req, res, next = () => {}) {
8
+ try {
9
+ if (core.isOn("headers")) {
10
+ for (const [name, value] of core.headers()) {
11
+ if (!res.hasHeader(name)) res.setHeader(name, value);
12
+ }
13
+ }
14
+ } catch (error) {
15
+ core.log("headers", "could not add headers:", error?.message);
16
+ }
17
+ next();
18
+ }
19
+ return { middleware };
20
+ }
21
+
22
+ module.exports = { createHeaders };