@reddoorla/maintenance 0.50.0 → 0.52.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/dist/cli/bin.js +510 -2
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +98 -2
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/forms/index.js +42 -2
- package/dist/forms/index.js.map +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +179 -9
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/forms/index.js
CHANGED
|
@@ -32,6 +32,24 @@ async function submitToIngest(opts) {
|
|
|
32
32
|
const error = obj && typeof obj.error === "string" ? obj.error : `ingest failed (${res.status})`;
|
|
33
33
|
return { ok: false, status: res.status, error };
|
|
34
34
|
}
|
|
35
|
+
async function submitScreenOut(opts) {
|
|
36
|
+
const doFetch = opts.fetch ?? fetch;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1500);
|
|
39
|
+
try {
|
|
40
|
+
const res = await doFetch(opts.url, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "content-type": "application/json", "x-forms-token": opts.token },
|
|
43
|
+
body: JSON.stringify({ screenOut: opts.reason }),
|
|
44
|
+
signal: controller.signal
|
|
45
|
+
});
|
|
46
|
+
return { ok: res.ok, status: res.status };
|
|
47
|
+
} catch {
|
|
48
|
+
return { ok: false, status: 0 };
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
35
53
|
var MIN_FILL_MS = 800;
|
|
36
54
|
function screenSubmission(input) {
|
|
37
55
|
if (typeof input.botField === "string" && input.botField.trim().length > 0) {
|
|
@@ -63,7 +81,18 @@ function createIngestAction(opts) {
|
|
|
63
81
|
botField: form.get(botFieldName)?.toString() ?? null,
|
|
64
82
|
elapsedMs: elapsedMs(form.get(tsFieldName), now)
|
|
65
83
|
});
|
|
66
|
-
if (!screen.ok)
|
|
84
|
+
if (!screen.ok) {
|
|
85
|
+
const cfg = opts.getConfig();
|
|
86
|
+
if (cfg.url && cfg.token) {
|
|
87
|
+
await submitScreenOut({
|
|
88
|
+
url: cfg.url,
|
|
89
|
+
token: cfg.token,
|
|
90
|
+
reason: screen.reason,
|
|
91
|
+
fetch: event.fetch
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return succeed();
|
|
95
|
+
}
|
|
67
96
|
const { url, token } = opts.getConfig();
|
|
68
97
|
if (!url || !token) {
|
|
69
98
|
console.error(`[forms-ingest] config missing for formType=${opts.formType}`);
|
|
@@ -115,7 +144,18 @@ function createIngestEndpoint(opts) {
|
|
|
115
144
|
return json({ ok: false, error: failed }, { status: 400 });
|
|
116
145
|
}
|
|
117
146
|
const screen = screenSubmission({ botField: str(body[botFieldName]) ?? null });
|
|
118
|
-
if (!screen.ok)
|
|
147
|
+
if (!screen.ok) {
|
|
148
|
+
const cfg = opts.getConfig();
|
|
149
|
+
if (cfg.url && cfg.token) {
|
|
150
|
+
await submitScreenOut({
|
|
151
|
+
url: cfg.url,
|
|
152
|
+
token: cfg.token,
|
|
153
|
+
reason: screen.reason,
|
|
154
|
+
fetch: event.fetch
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return json({ ok: true });
|
|
158
|
+
}
|
|
119
159
|
let payload;
|
|
120
160
|
try {
|
|
121
161
|
payload = {
|
package/dist/forms/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/forms/types.ts","../../src/forms/client.ts","../../src/forms/action.ts","../../src/forms/endpoint.ts"],"sourcesContent":["/**\n * Form-type enum, kept in a leaf module (no Airtable/Resend imports) so it can\n * be shared with fleet sites via the `@reddoorla/maintenance/forms` subpath\n * without dragging server SDKs into a site bundle.\n */\nexport const SUBMISSION_FORM_TYPES = [\n \"contact\",\n \"inquiry\",\n \"newsletter\",\n \"rsvp\",\n \"reserve\",\n] as const;\nexport type FormType = (typeof SUBMISSION_FORM_TYPES)[number];\n","import { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\n\n/**\n * The JSON a fleet site forwards to the dashboard ingest endpoint. Typed fields\n * are optional; the index signature lets a site include its own extra fields\n * (e.g. `company`) which the dashboard normalizer captures into `extraFields`.\n *\n * Each typed field allows `string | undefined` (not just `string`) so a\n * `buildPayload` mapping can use the idiomatic `form.get(\"name\")?.toString()`\n * pattern under `exactOptionalPropertyTypes` without a cast — an absent field\n * and an explicit `undefined` both serialize away in the JSON body.\n */\nexport type SubmissionPayload = {\n formType?: FormType | string | undefined;\n name?: string | undefined;\n firstName?: string | undefined;\n lastName?: string | undefined;\n email?: string | undefined;\n phone?: string | undefined;\n message?: string | undefined;\n sourceUrl?: string | undefined;\n utm?: string | undefined;\n [key: string]: unknown;\n};\n\nexport type IngestClientResult =\n | { ok: true; id: string }\n | { ok: false; status: number; error: string };\n\nexport type SubmitToIngestOptions = {\n /** Full ingest endpoint incl. the site slug, e.g. https://…/api/forms/reddoor */\n url: string;\n /** The shared FORMS_INGEST_TOKEN. */\n token: string;\n payload: SubmissionPayload;\n /** Injectable fetch (pass SvelteKit's `event.fetch`); defaults to global fetch. */\n fetch?: typeof fetch;\n};\n\n/**\n * Forward a submission to the dashboard ingest endpoint. Never throws — a network\n * failure or a non-2xx response is returned as `{ ok: false }` so the caller can\n * show a friendly error rather than a 500.\n */\nexport async function submitToIngest(opts: SubmitToIngestOptions): Promise<IngestClientResult> {\n const doFetch = opts.fetch ?? fetch;\n let res: Response;\n try {\n res = await doFetch(opts.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"x-forms-token\": opts.token },\n body: JSON.stringify(opts.payload),\n });\n } catch (err) {\n return { ok: false, status: 0, error: `network error: ${String(err)}` };\n }\n let body: unknown = null;\n try {\n body = await res.json();\n } catch {\n // non-JSON response — fall through to the error path\n }\n const obj = body && typeof body === \"object\" ? (body as Record<string, unknown>) : null;\n if (res.ok && obj && obj.ok === true) {\n return { ok: true, id: String(obj.id ?? \"\") };\n }\n const error = obj && typeof obj.error === \"string\" ? obj.error : `ingest failed (${res.status})`;\n return { ok: false, status: res.status, error };\n}\n\nexport type ScreenInput = { botField?: string | null; elapsedMs?: number | null };\nexport type ScreenResult = { ok: true } | { ok: false; reason: \"honeypot\" | \"too-fast\" };\n\n/**\n * Minimum plausible fill time; faster than this reads as a bot. Kept low (800ms)\n * on purpose: a too-fast fill is dropped *silently* (the visitor sees success),\n * so a real human who happens to be quick — autofill, a short form, a returning\n * visitor — would lose their lead with no trace. Below this, a submit is\n * effectively instant (page render → fill → click → network all under ~0.8s),\n * which a human realistically never beats but a script does. The honeypot is the\n * primary bot signal; this is the secondary one, so it errs toward letting\n * borderline-fast humans through.\n */\nexport const MIN_FILL_MS = 800;\n\n/**\n * Cheap bot screen for the site action. A filled honeypot is a bot; a submission\n * faster than MIN_FILL_MS is a bot. Missing timing data (null) is NOT a rejection\n * — a prerendered/cached page can't plant a fresh timestamp, and the honeypot\n * remains the primary signal.\n */\nexport function screenSubmission(input: ScreenInput): ScreenResult {\n if (typeof input.botField === \"string\" && input.botField.trim().length > 0) {\n return { ok: false, reason: \"honeypot\" };\n }\n if (\n typeof input.elapsedMs === \"number\" &&\n input.elapsedMs >= 0 &&\n input.elapsedMs < MIN_FILL_MS\n ) {\n return { ok: false, reason: \"too-fast\" };\n }\n return { ok: true };\n}\n\nexport { SUBMISSION_FORM_TYPES, type FormType };\n","import { fail, redirect, type ActionFailure, type RequestEvent } from \"@sveltejs/kit\";\nimport { submitToIngest, screenSubmission, type SubmissionPayload } from \"./client.js\";\n\n/** Endpoint + token for the dashboard ingest, read per-request from site env. */\nexport type IngestActionConfig = { url?: string; token?: string };\n\nexport type CreateIngestActionOptions = {\n /** Stamped onto every payload as `formType` (a SUBMISSION_FORM_TYPES value). */\n formType: string;\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map this form's fields to a payload. The factory's `formType` is always\n * authoritative and cannot be overridden by `buildPayload`.\n */\n buildPayload: (form: FormData, event: RequestEvent) => SubmissionPayload;\n /** Honeypot input name. Default \"bot-field\". */\n botFieldName?: string;\n /** Hidden timestamp input name (planted in `load`). Default \"ts\". */\n tsFieldName?: string;\n /** fail(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** fail(502) copy when the ingest endpoint rejects/errors. */\n errorMessage?: string;\n /** Injectable clock for tests. Default Date.now. */\n now?: () => number;\n /** If set, a successful OR bot-screened submission throws redirect(303, redirectTo)\n * instead of returning {success:true} (e.g. a dedicated /thank-you page). */\n redirectTo?: string;\n};\n\nexport type IngestActionData = { success: true } | ActionFailure<{ error: string }>;\n\n/**\n * Build a SvelteKit `default` form action that screens for bots, forwards the\n * submission to the dashboard ingest endpoint, and returns SvelteKit-shaped\n * results. The per-form field mapping is the only thing a site must supply.\n */\nexport function createIngestAction(\n opts: CreateIngestActionOptions,\n): (event: RequestEvent) => Promise<IngestActionData> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const tsFieldName = opts.tsFieldName ?? \"ts\";\n const now = opts.now ?? Date.now;\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let form: FormData;\n try {\n form = await event.request.formData();\n } catch {\n console.error(`[forms-ingest] ${opts.formType}: could not parse form body`);\n return fail(400, { error: failed });\n }\n\n // Bot screen: a filled honeypot OR an implausibly fast fill is silently\n // accepted (return success, do NOT forward) so bots get no signal.\n const screen = screenSubmission({\n botField: form.get(botFieldName)?.toString() ?? null,\n elapsedMs: elapsedMs(form.get(tsFieldName), now),\n });\n if (!screen.ok) return succeed();\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${opts.formType}`);\n return fail(500, { error: unavailable });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload: { ...opts.buildPayload(form, event), formType: opts.formType },\n });\n if (!result.ok) {\n console.error(`[forms-ingest] ${opts.formType} → ${result.status}: ${result.error}`);\n return fail(502, { error: failed });\n }\n return succeed();\n };\n\n // Single success path: redirect when configured (e.g. a dedicated /thank-you\n // page), otherwise return the SvelteKit-shaped success. `redirect()` throws, so\n // the trailing `return` keeps the `{ success: true }` type.\n function succeed(): { success: true } {\n if (opts.redirectTo) redirect(303, opts.redirectTo);\n return { success: true };\n }\n}\n\n// `FormDataEntryValue` is a DOM-lib global; this package compiles with only the\n// ES2022 lib + @types/node, where it is not in scope. Derive the type from the\n// in-scope `FormData.get` return instead — same value, no DOM-lib dependency.\nfunction elapsedMs(tsRaw: ReturnType<FormData[\"get\"]>, now: () => number): number | null {\n const ts = Number(tsRaw);\n if (!Number.isFinite(ts) || ts <= 0) return null;\n return now() - ts;\n}\n","import { json, type RequestEvent } from \"@sveltejs/kit\";\nimport { submitToIngest, screenSubmission, type SubmissionPayload } from \"./client.js\";\nimport { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { IngestActionConfig } from \"./action.js\";\n\n/**\n * Options for {@link createIngestEndpoint} — the JSON sibling of\n * `createIngestAction` for client-driven forms (modals / lightboxes / fetch)\n * that POST JSON to a `+server.ts` route instead of using a form action.\n */\nexport type CreateIngestEndpointOptions = {\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map the parsed JSON body to a payload. Must set `formType` UNLESS the fixed\n * `formType` option is provided (then that is authoritative and overrides it).\n */\n buildPayload: (body: Record<string, unknown>, event: RequestEvent) => SubmissionPayload;\n /** Fixed formType for single-type endpoints; omit for multi-type endpoints\n * where `buildPayload` derives formType from the body. */\n formType?: string;\n /** Honeypot field name in the JSON body. Default \"bot-field\". */\n botFieldName?: string;\n /** json(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** json(400/502) copy for bad input / ingest failure. */\n errorMessage?: string;\n};\n\nfunction isFormType(v: unknown): v is FormType {\n return typeof v === \"string\" && (SUBMISSION_FORM_TYPES as readonly string[]).includes(v);\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\n/**\n * Build a JSON `POST` handler that screens for bots, forwards the submission to\n * the dashboard ingest endpoint, and returns `{ ok }`-shaped JSON. The per-form\n * field mapping (`buildPayload`) is the only thing a site must supply. The\n * returned function is structurally a SvelteKit `RequestHandler`.\n */\nexport function createIngestEndpoint(\n opts: CreateIngestEndpointOptions,\n): (event: RequestEvent) => Promise<Response> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await event.request.json();\n if (!parsed || typeof parsed !== \"object\") throw new Error(\"body is not an object\");\n body = parsed as Record<string, unknown>;\n } catch {\n console.error(\"[forms-ingest] could not parse JSON body\");\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n // Bot screen: honeypot only. A client POST carries no server-planted ts, and\n // screenSubmission treats a missing elapsedMs as OK. A filled honeypot is\n // silently accepted (return ok, do NOT forward) so bots get no signal.\n const screen = screenSubmission({ botField: str(body[botFieldName]) ?? null });\n if (!screen.ok) return json({ ok: true });\n\n // buildPayload runs on untrusted JSON; a careless field access (e.g.\n // `body.name.trim()` on a non-string) would otherwise escape as a 500. Treat\n // a throw as a malformed request (400), keeping the \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...opts.buildPayload(body, event),\n ...(opts.formType ? { formType: opts.formType } : {}),\n };\n } catch (err) {\n console.error(`[forms-ingest] buildPayload threw: ${String(err)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n if (!isFormType(payload.formType)) {\n console.error(`[forms-ingest] invalid formType: ${String(payload.formType)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${payload.formType}`);\n return json({ ok: false, error: unavailable }, { status: 500 });\n }\n\n const result = await submitToIngest({ url, token, fetch: event.fetch, payload });\n if (!result.ok) {\n console.error(`[forms-ingest] ${payload.formType} → ${result.status}: ${result.error}`);\n return json({ ok: false, error: failed }, { status: 502 });\n }\n return json({ ok: true });\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/forms/types.ts","../../src/forms/client.ts","../../src/forms/action.ts","../../src/forms/endpoint.ts"],"sourcesContent":["/**\n * Form-type enum, kept in a leaf module (no Airtable/Resend imports) so it can\n * be shared with fleet sites via the `@reddoorla/maintenance/forms` subpath\n * without dragging server SDKs into a site bundle.\n */\nexport const SUBMISSION_FORM_TYPES = [\n \"contact\",\n \"inquiry\",\n \"newsletter\",\n \"rsvp\",\n \"reserve\",\n] as const;\nexport type FormType = (typeof SUBMISSION_FORM_TYPES)[number];\n","import { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\n\n/**\n * The JSON a fleet site forwards to the dashboard ingest endpoint. Typed fields\n * are optional; the index signature lets a site include its own extra fields\n * (e.g. `company`) which the dashboard normalizer captures into `extraFields`.\n *\n * Each typed field allows `string | undefined` (not just `string`) so a\n * `buildPayload` mapping can use the idiomatic `form.get(\"name\")?.toString()`\n * pattern under `exactOptionalPropertyTypes` without a cast — an absent field\n * and an explicit `undefined` both serialize away in the JSON body.\n */\nexport type SubmissionPayload = {\n formType?: FormType | string | undefined;\n name?: string | undefined;\n firstName?: string | undefined;\n lastName?: string | undefined;\n email?: string | undefined;\n phone?: string | undefined;\n message?: string | undefined;\n sourceUrl?: string | undefined;\n utm?: string | undefined;\n [key: string]: unknown;\n};\n\nexport type IngestClientResult =\n | { ok: true; id: string }\n | { ok: false; status: number; error: string };\n\nexport type SubmitToIngestOptions = {\n /** Full ingest endpoint incl. the site slug, e.g. https://…/api/forms/reddoor */\n url: string;\n /** The shared FORMS_INGEST_TOKEN. */\n token: string;\n payload: SubmissionPayload;\n /** Injectable fetch (pass SvelteKit's `event.fetch`); defaults to global fetch. */\n fetch?: typeof fetch;\n};\n\n/**\n * Forward a submission to the dashboard ingest endpoint. Never throws — a network\n * failure or a non-2xx response is returned as `{ ok: false }` so the caller can\n * show a friendly error rather than a 500.\n */\nexport async function submitToIngest(opts: SubmitToIngestOptions): Promise<IngestClientResult> {\n const doFetch = opts.fetch ?? fetch;\n let res: Response;\n try {\n res = await doFetch(opts.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"x-forms-token\": opts.token },\n body: JSON.stringify(opts.payload),\n });\n } catch (err) {\n return { ok: false, status: 0, error: `network error: ${String(err)}` };\n }\n let body: unknown = null;\n try {\n body = await res.json();\n } catch {\n // non-JSON response — fall through to the error path\n }\n const obj = body && typeof body === \"object\" ? (body as Record<string, unknown>) : null;\n if (res.ok && obj && obj.ok === true) {\n return { ok: true, id: String(obj.id ?? \"\") };\n }\n const error = obj && typeof obj.error === \"string\" ? obj.error : `ingest failed (${res.status})`;\n return { ok: false, status: res.status, error };\n}\n\nexport type SubmitScreenOutOptions = {\n /** Same ingest endpoint the site already posts submissions to. */\n url: string;\n token: string;\n reason: \"honeypot\" | \"too-fast\";\n fetch?: typeof fetch;\n /** Abort budget so a slow/hung beacon can't delay the (already-successful) response. */\n timeoutMs?: number;\n};\n\n/**\n * Best-effort screen-out beacon: tells the central ingest \"a bot was screened here\"\n * (no PII) so caught-vs-delivered is observable. Never throws — a failure is returned\n * as { ok: false } and the caller ignores it (the visitor already saw success).\n */\nexport async function submitScreenOut(\n opts: SubmitScreenOutOptions,\n): Promise<{ ok: boolean; status: number }> {\n const doFetch = opts.fetch ?? fetch;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1500);\n try {\n const res = await doFetch(opts.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"x-forms-token\": opts.token },\n body: JSON.stringify({ screenOut: opts.reason }),\n signal: controller.signal,\n });\n return { ok: res.ok, status: res.status };\n } catch {\n return { ok: false, status: 0 };\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport type ScreenInput = { botField?: string | null; elapsedMs?: number | null };\nexport type ScreenResult = { ok: true } | { ok: false; reason: \"honeypot\" | \"too-fast\" };\n\n/**\n * Minimum plausible fill time; faster than this reads as a bot. Kept low (800ms)\n * on purpose: a too-fast fill is dropped *silently* (the visitor sees success),\n * so a real human who happens to be quick — autofill, a short form, a returning\n * visitor — would lose their lead with no trace. Below this, a submit is\n * effectively instant (page render → fill → click → network all under ~0.8s),\n * which a human realistically never beats but a script does. The honeypot is the\n * primary bot signal; this is the secondary one, so it errs toward letting\n * borderline-fast humans through.\n */\nexport const MIN_FILL_MS = 800;\n\n/**\n * Cheap bot screen for the site action. A filled honeypot is a bot; a submission\n * faster than MIN_FILL_MS is a bot. Missing timing data (null) is NOT a rejection\n * — a prerendered/cached page can't plant a fresh timestamp, and the honeypot\n * remains the primary signal.\n */\nexport function screenSubmission(input: ScreenInput): ScreenResult {\n if (typeof input.botField === \"string\" && input.botField.trim().length > 0) {\n return { ok: false, reason: \"honeypot\" };\n }\n if (\n typeof input.elapsedMs === \"number\" &&\n input.elapsedMs >= 0 &&\n input.elapsedMs < MIN_FILL_MS\n ) {\n return { ok: false, reason: \"too-fast\" };\n }\n return { ok: true };\n}\n\nexport { SUBMISSION_FORM_TYPES, type FormType };\n","import { fail, redirect, type ActionFailure, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\n\n/** Endpoint + token for the dashboard ingest, read per-request from site env. */\nexport type IngestActionConfig = { url?: string; token?: string };\n\nexport type CreateIngestActionOptions = {\n /** Stamped onto every payload as `formType` (a SUBMISSION_FORM_TYPES value). */\n formType: string;\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map this form's fields to a payload. The factory's `formType` is always\n * authoritative and cannot be overridden by `buildPayload`.\n */\n buildPayload: (form: FormData, event: RequestEvent) => SubmissionPayload;\n /** Honeypot input name. Default \"bot-field\". */\n botFieldName?: string;\n /** Hidden timestamp input name (planted in `load`). Default \"ts\". */\n tsFieldName?: string;\n /** fail(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** fail(502) copy when the ingest endpoint rejects/errors. */\n errorMessage?: string;\n /** Injectable clock for tests. Default Date.now. */\n now?: () => number;\n /** If set, a successful OR bot-screened submission throws redirect(303, redirectTo)\n * instead of returning {success:true} (e.g. a dedicated /thank-you page). */\n redirectTo?: string;\n};\n\nexport type IngestActionData = { success: true } | ActionFailure<{ error: string }>;\n\n/**\n * Build a SvelteKit `default` form action that screens for bots, forwards the\n * submission to the dashboard ingest endpoint, and returns SvelteKit-shaped\n * results. The per-form field mapping is the only thing a site must supply.\n */\nexport function createIngestAction(\n opts: CreateIngestActionOptions,\n): (event: RequestEvent) => Promise<IngestActionData> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const tsFieldName = opts.tsFieldName ?? \"ts\";\n const now = opts.now ?? Date.now;\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let form: FormData;\n try {\n form = await event.request.formData();\n } catch {\n console.error(`[forms-ingest] ${opts.formType}: could not parse form body`);\n return fail(400, { error: failed });\n }\n\n // Bot screen: a filled honeypot OR an implausibly fast fill is silently\n // accepted (return success, do NOT forward) so bots get no signal.\n const screen = screenSubmission({\n botField: form.get(botFieldName)?.toString() ?? null,\n elapsedMs: elapsedMs(form.get(tsFieldName), now),\n });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // succeed exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return succeed();\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${opts.formType}`);\n return fail(500, { error: unavailable });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload: { ...opts.buildPayload(form, event), formType: opts.formType },\n });\n if (!result.ok) {\n console.error(`[forms-ingest] ${opts.formType} → ${result.status}: ${result.error}`);\n return fail(502, { error: failed });\n }\n return succeed();\n };\n\n // Single success path: redirect when configured (e.g. a dedicated /thank-you\n // page), otherwise return the SvelteKit-shaped success. `redirect()` throws, so\n // the trailing `return` keeps the `{ success: true }` type.\n function succeed(): { success: true } {\n if (opts.redirectTo) redirect(303, opts.redirectTo);\n return { success: true };\n }\n}\n\n// `FormDataEntryValue` is a DOM-lib global; this package compiles with only the\n// ES2022 lib + @types/node, where it is not in scope. Derive the type from the\n// in-scope `FormData.get` return instead — same value, no DOM-lib dependency.\nfunction elapsedMs(tsRaw: ReturnType<FormData[\"get\"]>, now: () => number): number | null {\n const ts = Number(tsRaw);\n if (!Number.isFinite(ts) || ts <= 0) return null;\n return now() - ts;\n}\n","import { json, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { IngestActionConfig } from \"./action.js\";\n\n/**\n * Options for {@link createIngestEndpoint} — the JSON sibling of\n * `createIngestAction` for client-driven forms (modals / lightboxes / fetch)\n * that POST JSON to a `+server.ts` route instead of using a form action.\n */\nexport type CreateIngestEndpointOptions = {\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map the parsed JSON body to a payload. Must set `formType` UNLESS the fixed\n * `formType` option is provided (then that is authoritative and overrides it).\n */\n buildPayload: (body: Record<string, unknown>, event: RequestEvent) => SubmissionPayload;\n /** Fixed formType for single-type endpoints; omit for multi-type endpoints\n * where `buildPayload` derives formType from the body. */\n formType?: string;\n /** Honeypot field name in the JSON body. Default \"bot-field\". */\n botFieldName?: string;\n /** json(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** json(400/502) copy for bad input / ingest failure. */\n errorMessage?: string;\n};\n\nfunction isFormType(v: unknown): v is FormType {\n return typeof v === \"string\" && (SUBMISSION_FORM_TYPES as readonly string[]).includes(v);\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\n/**\n * Build a JSON `POST` handler that screens for bots, forwards the submission to\n * the dashboard ingest endpoint, and returns `{ ok }`-shaped JSON. The per-form\n * field mapping (`buildPayload`) is the only thing a site must supply. The\n * returned function is structurally a SvelteKit `RequestHandler`.\n */\nexport function createIngestEndpoint(\n opts: CreateIngestEndpointOptions,\n): (event: RequestEvent) => Promise<Response> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await event.request.json();\n if (!parsed || typeof parsed !== \"object\") throw new Error(\"body is not an object\");\n body = parsed as Record<string, unknown>;\n } catch {\n console.error(\"[forms-ingest] could not parse JSON body\");\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n // Bot screen: honeypot only. A client POST carries no server-planted ts, and\n // screenSubmission treats a missing elapsedMs as OK. A filled honeypot is\n // silently accepted (return ok, do NOT forward) so bots get no signal.\n const screen = screenSubmission({ botField: str(body[botFieldName]) ?? null });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // return success exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return json({ ok: true });\n }\n\n // buildPayload runs on untrusted JSON; a careless field access (e.g.\n // `body.name.trim()` on a non-string) would otherwise escape as a 500. Treat\n // a throw as a malformed request (400), keeping the \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...opts.buildPayload(body, event),\n ...(opts.formType ? { formType: opts.formType } : {}),\n };\n } catch (err) {\n console.error(`[forms-ingest] buildPayload threw: ${String(err)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n if (!isFormType(payload.formType)) {\n console.error(`[forms-ingest] invalid formType: ${String(payload.formType)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${payload.formType}`);\n return json({ ok: false, error: unavailable }, { status: 500 });\n }\n\n const result = await submitToIngest({ url, token, fetch: event.fetch, payload });\n if (!result.ok) {\n console.error(`[forms-ingest] ${payload.formType} → ${result.status}: ${result.error}`);\n return json({ ok: false, error: failed }, { status: 502 });\n }\n return json({ ok: true });\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -307,6 +307,10 @@ type WebsiteRow = {
|
|
|
307
307
|
/** ISO timestamp the security audit last ran — gates freshness of the Security Updates auto-tick
|
|
308
308
|
* (clean counts only auto-tick when recent). */
|
|
309
309
|
lastSecurityAuditAt: string | null;
|
|
310
|
+
/** The known advisories behind the counts (severity-sorted, capped), so the dashboard can show
|
|
311
|
+
* WHICH packages are vulnerable, not just the totals. null = never audited / unparseable;
|
|
312
|
+
* empty array = audited clean. Written alongside the counts by the security audit. */
|
|
313
|
+
securityAdvisories: SecurityAdvisory[] | null;
|
|
310
314
|
/** Domain/DNS/SSL probe (the `domain` audit). `certDaysRemaining` is days until the TLS cert
|
|
311
315
|
* expires (null = unresolved or no usable cert); `domainCheckedAt` is when it last ran. */
|
|
312
316
|
certDaysRemaining: number | null;
|
|
@@ -338,6 +342,15 @@ type WebsiteRow = {
|
|
|
338
342
|
githubSignalsAt: string | null;
|
|
339
343
|
notifyRouting: NotifyRouting | null;
|
|
340
344
|
};
|
|
345
|
+
type Severity = "low" | "moderate" | "high" | "critical";
|
|
346
|
+
/** One known vulnerability behind the security counts, as persisted/rendered. */
|
|
347
|
+
type SecurityAdvisory = {
|
|
348
|
+
module: string;
|
|
349
|
+
severity: Severity;
|
|
350
|
+
title: string;
|
|
351
|
+
cves: string[];
|
|
352
|
+
url: string | null;
|
|
353
|
+
};
|
|
341
354
|
|
|
342
355
|
type ResolvedCopy = {
|
|
343
356
|
maintenanceIntro: string;
|
|
@@ -653,13 +666,19 @@ type SubmissionRow = {
|
|
|
653
666
|
resendMessageId: string | null;
|
|
654
667
|
};
|
|
655
668
|
|
|
669
|
+
type ScreenOutTotals = {
|
|
670
|
+
honeypot: number;
|
|
671
|
+
tooFast: number;
|
|
672
|
+
markedSpam: number;
|
|
673
|
+
};
|
|
674
|
+
|
|
656
675
|
/**
|
|
657
676
|
* Render the per-site dashboard as a single HTML document. Pure function:
|
|
658
677
|
* no Airtable access, no env reads, no I/O. The Netlify function handler
|
|
659
678
|
* fetches data, then hands it here. Easier to unit-test, easier to render
|
|
660
679
|
* a static preview from CLI later.
|
|
661
680
|
*/
|
|
662
|
-
declare function renderSiteDashboardHtml(site: WebsiteRow, reports: ReportRow[], submissions?: SubmissionRow[]): string;
|
|
681
|
+
declare function renderSiteDashboardHtml(site: WebsiteRow, reports: ReportRow[], submissions?: SubmissionRow[], spamTotals?: ScreenOutTotals | null, now?: Date): string;
|
|
663
682
|
|
|
664
683
|
/** Severity of a "Needs attention" entry. `critical` sorts above `warning`. */
|
|
665
684
|
type AttentionSeverity = "critical" | "warning";
|
|
@@ -736,6 +755,11 @@ type CockpitModel = {
|
|
|
736
755
|
pending: PendingEntry[];
|
|
737
756
|
/** NEW submissions across the fleet, newest-first (optional for back-compat). */
|
|
738
757
|
submissions?: SubmissionEntry[];
|
|
758
|
+
/** Fleet spam totals over the window (optional; populated by buildCockpitModel). */
|
|
759
|
+
spam?: {
|
|
760
|
+
caught: number;
|
|
761
|
+
through: number;
|
|
762
|
+
} | null;
|
|
739
763
|
};
|
|
740
764
|
|
|
741
765
|
/**
|
package/dist/index.js
CHANGED
|
@@ -3030,6 +3030,7 @@ function mapRow(rec) {
|
|
|
3030
3030
|
securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
|
|
3031
3031
|
securityVulnsLow: f["Security Vulns Low"] ?? null,
|
|
3032
3032
|
lastSecurityAuditAt: f["Last security audit at"] ?? null,
|
|
3033
|
+
securityAdvisories: parseSecurityAdvisories(f["Security advisories"]),
|
|
3033
3034
|
certDaysRemaining: f["Cert days remaining"] ?? null,
|
|
3034
3035
|
domainCheckedAt: f["Domain checked at"] ?? null,
|
|
3035
3036
|
crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
|
|
@@ -3059,6 +3060,40 @@ async function listWebsites(base) {
|
|
|
3059
3060
|
});
|
|
3060
3061
|
return out;
|
|
3061
3062
|
}
|
|
3063
|
+
var SEVERITY_RANK = {
|
|
3064
|
+
critical: 0,
|
|
3065
|
+
high: 1,
|
|
3066
|
+
moderate: 2,
|
|
3067
|
+
low: 3
|
|
3068
|
+
};
|
|
3069
|
+
function normalizeSecurityAdvisory(raw) {
|
|
3070
|
+
if (!raw || typeof raw !== "object") return null;
|
|
3071
|
+
const e = raw;
|
|
3072
|
+
const module = typeof e["module"] === "string" ? e["module"] : null;
|
|
3073
|
+
const severity = e["severity"];
|
|
3074
|
+
if (module === null) return null;
|
|
3075
|
+
if (severity !== "low" && severity !== "moderate" && severity !== "high" && severity !== "critical")
|
|
3076
|
+
return null;
|
|
3077
|
+
const cves = Array.isArray(e["cves"]) ? e["cves"].filter((c) => typeof c === "string") : [];
|
|
3078
|
+
return {
|
|
3079
|
+
module,
|
|
3080
|
+
severity,
|
|
3081
|
+
title: typeof e["title"] === "string" ? e["title"] : "",
|
|
3082
|
+
cves,
|
|
3083
|
+
url: typeof e["url"] === "string" ? e["url"] : null
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
function parseSecurityAdvisories(raw) {
|
|
3087
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
3088
|
+
let parsed;
|
|
3089
|
+
try {
|
|
3090
|
+
parsed = JSON.parse(raw);
|
|
3091
|
+
} catch {
|
|
3092
|
+
return null;
|
|
3093
|
+
}
|
|
3094
|
+
if (!Array.isArray(parsed)) return null;
|
|
3095
|
+
return parsed.map(normalizeSecurityAdvisory).filter((a) => a !== null);
|
|
3096
|
+
}
|
|
3062
3097
|
async function updateLaunched(base, recordId, at) {
|
|
3063
3098
|
const fields = { Status: "maintenance", "Launched at": at };
|
|
3064
3099
|
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
@@ -4304,6 +4339,36 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
|
|
|
4304
4339
|
|
|
4305
4340
|
// src/reports/airtable/client.ts
|
|
4306
4341
|
import Airtable from "airtable";
|
|
4342
|
+
|
|
4343
|
+
// src/reports/airtable/throttle.ts
|
|
4344
|
+
function createMinIntervalThrottle(opts) {
|
|
4345
|
+
const { minIntervalMs, now, delay } = opts;
|
|
4346
|
+
return function wrap(fn) {
|
|
4347
|
+
let chain = Promise.resolve();
|
|
4348
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
4349
|
+
return (...args) => {
|
|
4350
|
+
chain = chain.then(async () => {
|
|
4351
|
+
const wait = minIntervalMs - (now() - last);
|
|
4352
|
+
if (wait > 0) await delay(wait);
|
|
4353
|
+
last = now();
|
|
4354
|
+
fn(...args);
|
|
4355
|
+
}).catch(() => {
|
|
4356
|
+
});
|
|
4357
|
+
};
|
|
4358
|
+
};
|
|
4359
|
+
}
|
|
4360
|
+
function applyThrottle(base, opts) {
|
|
4361
|
+
const real = base._base?.runAction;
|
|
4362
|
+
if (typeof real !== "function") return base;
|
|
4363
|
+
const wrap = createMinIntervalThrottle(opts);
|
|
4364
|
+
const throttled = wrap(real.bind(base._base));
|
|
4365
|
+
base._base.runAction = throttled;
|
|
4366
|
+
base.runAction = throttled;
|
|
4367
|
+
return base;
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
// src/reports/airtable/client.ts
|
|
4371
|
+
var MIN_REQUEST_INTERVAL_MS = 220;
|
|
4307
4372
|
function missing(name) {
|
|
4308
4373
|
return Object.assign(
|
|
4309
4374
|
new Error(
|
|
@@ -4320,7 +4385,12 @@ function readAirtableConfig() {
|
|
|
4320
4385
|
return { apiKey, baseId };
|
|
4321
4386
|
}
|
|
4322
4387
|
function openBase(cfg) {
|
|
4323
|
-
|
|
4388
|
+
const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
|
|
4389
|
+
return applyThrottle(base, {
|
|
4390
|
+
minIntervalMs: MIN_REQUEST_INTERVAL_MS,
|
|
4391
|
+
now: () => Date.now(),
|
|
4392
|
+
delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
4393
|
+
});
|
|
4324
4394
|
}
|
|
4325
4395
|
|
|
4326
4396
|
// src/reports/maintenance-email/header-image.ts
|
|
@@ -4739,6 +4809,28 @@ function securitySub(site) {
|
|
|
4739
4809
|
const l = site.securityVulnsLow ?? 0;
|
|
4740
4810
|
return `${c}C / ${h}H / ${m}M / ${l}L`;
|
|
4741
4811
|
}
|
|
4812
|
+
function advisoryRow(a) {
|
|
4813
|
+
const sev = escapeHtml(a.severity);
|
|
4814
|
+
const module = escapeHtml(a.module);
|
|
4815
|
+
const title = a.title ? ` \u2014 ${escapeHtml(a.title)}` : "";
|
|
4816
|
+
const cves = a.cves.length > 0 ? ` <span class="muted">(${escapeHtml(a.cves.join(", "))})</span>` : "";
|
|
4817
|
+
const link = a.url ? ` <a href="${escapeHtml(safeUrl(a.url))}" rel="noopener noreferrer">advisory \u25B8</a>` : "";
|
|
4818
|
+
return `<li class="vuln-item">
|
|
4819
|
+
<span class="pill sev-${sev}">${sev}</span>
|
|
4820
|
+
<strong>${module}</strong>${title}${cves}${link}
|
|
4821
|
+
</li>`;
|
|
4822
|
+
}
|
|
4823
|
+
function securitySection(site) {
|
|
4824
|
+
const advisories = site.securityAdvisories;
|
|
4825
|
+
if (!advisories || advisories.length === 0) return "";
|
|
4826
|
+
const sorted = [...advisories].sort(
|
|
4827
|
+
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
4828
|
+
);
|
|
4829
|
+
return `<div class="section vulns">
|
|
4830
|
+
<h2>Vulnerabilities (${sorted.length})</h2>
|
|
4831
|
+
<ul class="vuln-list">${sorted.map(advisoryRow).join("")}</ul>
|
|
4832
|
+
</div>`;
|
|
4833
|
+
}
|
|
4742
4834
|
function checklistBlock(r) {
|
|
4743
4835
|
const items = checklistFor(r.reportType);
|
|
4744
4836
|
if (items.length === 0) return "";
|
|
@@ -4793,30 +4885,79 @@ function reportRow(r) {
|
|
|
4793
4885
|
const action = isPendingApproval(r) ? approveButton(r) : "";
|
|
4794
4886
|
return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>`;
|
|
4795
4887
|
}
|
|
4888
|
+
function extraFieldsList(raw) {
|
|
4889
|
+
if (!raw || raw.trim() === "") return "";
|
|
4890
|
+
let parsed;
|
|
4891
|
+
try {
|
|
4892
|
+
parsed = JSON.parse(raw);
|
|
4893
|
+
} catch {
|
|
4894
|
+
return `<div class="subm-kv"><span class="k">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;
|
|
4895
|
+
}
|
|
4896
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4897
|
+
return `<div class="subm-kv"><span class="k">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;
|
|
4898
|
+
}
|
|
4899
|
+
const rows = Object.entries(parsed).map(
|
|
4900
|
+
([k, v]) => `<div class="subm-kv"><span class="k">${escapeHtml(k)}</span> ${escapeHtml(String(v))}</div>`
|
|
4901
|
+
).join("");
|
|
4902
|
+
return rows;
|
|
4903
|
+
}
|
|
4796
4904
|
function submissionRow(s) {
|
|
4797
4905
|
const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
|
|
4798
4906
|
const type = escapeHtml(s.formType);
|
|
4799
4907
|
const who = escapeHtml(s.name || "(no name)");
|
|
4800
4908
|
const email = escapeHtml(s.email || "");
|
|
4801
|
-
const message = escapeHtml(s.message ?? "");
|
|
4802
4909
|
const status = escapeHtml(s.status);
|
|
4803
4910
|
const id = escapeHtml(s.id);
|
|
4804
4911
|
const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;
|
|
4805
4912
|
const btn = (label, action) => `<button class="subm-status" data-id="${id}" data-status="${action}" data-url="${url}">${label}</button>`;
|
|
4913
|
+
const kv = (label, value) => value === null || value === "" ? "" : `<div class="subm-kv"><span class="k">${label}</span> ${escapeHtml(String(value))}</div>`;
|
|
4914
|
+
const sourceLink = s.sourceUrl ? `<div class="subm-kv"><span class="k">Source</span> <a href="${escapeHtml(safeUrl(s.sourceUrl))}" rel="noopener noreferrer">${escapeHtml(s.sourceUrl)}</a></div>` : "";
|
|
4915
|
+
const messageBlock = s.message ? `<div class="subm-kv"><span class="k">Message</span></div><div class="subm-msg">${escapeHtml(s.message)}</div>` : "";
|
|
4916
|
+
const details = [
|
|
4917
|
+
kv("Phone", s.phone),
|
|
4918
|
+
messageBlock,
|
|
4919
|
+
sourceLink,
|
|
4920
|
+
kv("UTM", s.utm),
|
|
4921
|
+
extraFieldsList(s.extraFields),
|
|
4922
|
+
kv("Notify", s.notifyStatus),
|
|
4923
|
+
kv("Resend ID", s.resendMessageId),
|
|
4924
|
+
kv("Submission #", s.submissionId)
|
|
4925
|
+
].join("");
|
|
4806
4926
|
return `<li class="subm-item">
|
|
4807
|
-
<
|
|
4808
|
-
|
|
4927
|
+
<details>
|
|
4928
|
+
<summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span> <span class="muted">${when}</span></summary>
|
|
4929
|
+
<div class="subm-detail">${details}</div>
|
|
4930
|
+
</details>
|
|
4809
4931
|
<div class="subm-actions">${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>
|
|
4810
4932
|
</li>`;
|
|
4811
4933
|
}
|
|
4934
|
+
var SUBMISSIONS_PER_SITE_CAP = 25;
|
|
4812
4935
|
function submissionsSection(submissions) {
|
|
4813
4936
|
if (submissions.length === 0) return "";
|
|
4814
|
-
const recent = [...submissions].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0,
|
|
4937
|
+
const recent = [...submissions].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_PER_SITE_CAP);
|
|
4938
|
+
const note = submissions.length > recent.length ? `<span class="muted"> \u2014 showing ${recent.length} of ${submissions.length}</span>` : "";
|
|
4815
4939
|
return `<div class="section submissions">
|
|
4816
|
-
<h2>Form submissions (${submissions.length})</h2>
|
|
4940
|
+
<h2>Form submissions (${submissions.length})${note}</h2>
|
|
4817
4941
|
<ul class="subm-list">${recent.map(submissionRow).join("")}</ul>
|
|
4818
4942
|
</div>`;
|
|
4819
4943
|
}
|
|
4944
|
+
var SPAM_WINDOW_DAYS = 30;
|
|
4945
|
+
function spamScreenSection(totals, submissions, now) {
|
|
4946
|
+
const sinceMs = now.getTime() - SPAM_WINDOW_DAYS * 24 * 60 * 60 * 1e3;
|
|
4947
|
+
const delivered = submissions.filter(
|
|
4948
|
+
(s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs
|
|
4949
|
+
).length;
|
|
4950
|
+
const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };
|
|
4951
|
+
if (delivered === 0 && t.honeypot === 0 && t.tooFast === 0 && t.markedSpam === 0) return "";
|
|
4952
|
+
const row = (label, n) => `<div class="spam-kv"><span class="k">${label}</span> ${escapeHtml(String(n))}</div>`;
|
|
4953
|
+
return `<div class="section spam-screen">
|
|
4954
|
+
<h2>Spam screen (30d)</h2>
|
|
4955
|
+
${row("Caught \u2014 honeypot", t.honeypot)}
|
|
4956
|
+
${row("Caught \u2014 too-fast", t.tooFast)}
|
|
4957
|
+
${row("Delivered", delivered)}
|
|
4958
|
+
${row("Marked spam", t.markedSpam)}
|
|
4959
|
+
</div>`;
|
|
4960
|
+
}
|
|
4820
4961
|
function setupSection(site) {
|
|
4821
4962
|
const { score, total } = onboardingStatus(site);
|
|
4822
4963
|
const missing2 = missingOnboarding(site);
|
|
@@ -4884,13 +5025,26 @@ button.approve:disabled { opacity: 0.6; cursor: default; }
|
|
|
4884
5025
|
@media (prefers-color-scheme: dark) { .subm-item { border-color: #2a2a2a; } }
|
|
4885
5026
|
.subm-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
|
4886
5027
|
.subm-msg { margin: 0.35rem 0; white-space: pre-wrap; }
|
|
5028
|
+
.subm-detail { padding: 0.35rem 0 0.2rem; }
|
|
5029
|
+
.subm-kv { font-size: 0.9rem; margin: 0.15rem 0; }
|
|
5030
|
+
.subm-kv .k { color: #888; margin-right: 0.4rem; }
|
|
5031
|
+
summary.subm-head { cursor: pointer; }
|
|
4887
5032
|
.subm-actions { display: flex; gap: 0.4rem; }
|
|
4888
5033
|
button.subm-status { font: inherit; padding: 0.25rem 0.7rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
|
|
4889
5034
|
button.subm-status:disabled { opacity: 0.6; cursor: default; }
|
|
5035
|
+
.spam-screen .spam-kv { font-size: 0.95rem; margin: 0.2rem 0; }
|
|
5036
|
+
.spam-screen .spam-kv .k { color: #888; display: inline-block; min-width: 11rem; }
|
|
4890
5037
|
.pill.subm-new { background: #e8f0fe; color: #1a56db; }
|
|
4891
5038
|
.pill.subm-read { background: #f0f0f0; color: #555; }
|
|
4892
5039
|
.pill.subm-archived { background: #eee; color: #888; }
|
|
4893
5040
|
.pill.subm-spam { background: #fdecea; color: #b00; }
|
|
5041
|
+
.vuln-list { list-style: none; padding: 0; margin: 0; }
|
|
5042
|
+
.vuln-item { padding: 0.45rem 0; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; }
|
|
5043
|
+
@media (prefers-color-scheme: dark) { .vuln-item { border-color: #2a2a2a; } }
|
|
5044
|
+
.pill.sev-critical { background: #fdecea; color: #b00; }
|
|
5045
|
+
.pill.sev-high { background: #fff0e6; color: #c4500a; }
|
|
5046
|
+
.pill.sev-moderate { background: #fff8e1; color: #8a6d00; }
|
|
5047
|
+
.pill.sev-low { background: #f0f0f0; color: #555; }
|
|
4894
5048
|
.home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }
|
|
4895
5049
|
.setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }
|
|
4896
5050
|
.setup-ok { color: #1b7a2f; font-weight: 600; }
|
|
@@ -4900,7 +5054,7 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
|
|
|
4900
5054
|
.detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }
|
|
4901
5055
|
.detail dd { margin: 0; }
|
|
4902
5056
|
`;
|
|
4903
|
-
function renderSiteDashboardHtml(site, reports, submissions = []) {
|
|
5057
|
+
function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = null, now = /* @__PURE__ */ new Date()) {
|
|
4904
5058
|
const name = escapeHtml(site.name);
|
|
4905
5059
|
const urlSafe = safeUrl(site.url);
|
|
4906
5060
|
const allScoresNull = site.pScore === null && site.rScore === null && site.bpScore === null && site.seoScore === null;
|
|
@@ -4951,6 +5105,10 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
|
|
|
4951
5105
|
${healthSection}
|
|
4952
5106
|
</div>
|
|
4953
5107
|
|
|
5108
|
+
${securitySection(site)}
|
|
5109
|
+
|
|
5110
|
+
${spamScreenSection(spamTotals, submissions, now)}
|
|
5111
|
+
|
|
4954
5112
|
<div class="section">
|
|
4955
5113
|
<h2>Reports</h2>
|
|
4956
5114
|
${reportsSection}
|
|
@@ -5110,6 +5268,8 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
|
|
|
5110
5268
|
.summary { display:flex; flex-wrap:wrap; gap:0.5rem 1.25rem; align-items:baseline; margin-bottom:0.5rem; }
|
|
5111
5269
|
.summary .tier { font-weight:700; }
|
|
5112
5270
|
.summary .heads { color:#666; font-size:0.9rem; }
|
|
5271
|
+
.spam-rollup { font-size:0.9rem; margin-bottom:1rem; }
|
|
5272
|
+
.muted { color:#999; }
|
|
5113
5273
|
.filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }
|
|
5114
5274
|
.filters button { font:inherit; font-size:0.85rem; padding:0.25rem 0.7rem; border:1px solid #ccc; border-radius:999px; background:transparent; color:inherit; cursor:pointer; }
|
|
5115
5275
|
.filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
|
|
@@ -5171,6 +5331,11 @@ function summaryBar(model) {
|
|
|
5171
5331
|
<div class="summary heads">${escapeHtml(heads)}</div>
|
|
5172
5332
|
<div class="filters">${chips2}</div>`;
|
|
5173
5333
|
}
|
|
5334
|
+
function spamRollup(model) {
|
|
5335
|
+
const s = model.spam;
|
|
5336
|
+
if (!s || s.caught === 0 && s.through === 0) return "";
|
|
5337
|
+
return `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${s.caught} \xB7 through ${s.through}</div>`;
|
|
5338
|
+
}
|
|
5174
5339
|
function allClearBanner(model) {
|
|
5175
5340
|
if (model.summary.attention > 0) return "";
|
|
5176
5341
|
const msg = model.cards.length === 0 ? "No sites on the fleet view yet." : "All clear \u2014 nothing needs your attention.";
|
|
@@ -5193,10 +5358,12 @@ function approveStrip(model) {
|
|
|
5193
5358
|
${rows}
|
|
5194
5359
|
</section>`;
|
|
5195
5360
|
}
|
|
5361
|
+
var SUBMISSIONS_STRIP_CAP = 10;
|
|
5196
5362
|
function submissionsStrip(model) {
|
|
5197
5363
|
const subs = model.submissions ?? [];
|
|
5198
5364
|
if (subs.length === 0) return "";
|
|
5199
|
-
const
|
|
5365
|
+
const shown = [...subs].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_STRIP_CAP);
|
|
5366
|
+
const rows = shown.map((sub) => {
|
|
5200
5367
|
const href = `/s/${escapeHtml(sub.slug)}`;
|
|
5201
5368
|
const when = sub.submittedAt ? escapeHtml(relativeTimeFromNow(sub.submittedAt)) : "";
|
|
5202
5369
|
const who = escapeHtml(sub.name || sub.email);
|
|
@@ -5207,9 +5374,11 @@ function submissionsStrip(model) {
|
|
|
5207
5374
|
<a href="${href}">open \u25B8</a>
|
|
5208
5375
|
</div>`;
|
|
5209
5376
|
}).join("");
|
|
5377
|
+
const overflow = subs.length - shown.length;
|
|
5378
|
+
const more = overflow > 0 ? `<div class="approve-row subm-more muted">+${overflow} more \u2014 triage on each site page</div>` : "";
|
|
5210
5379
|
return `<section class="approve-strip subm-strip" data-tier="submissions">
|
|
5211
5380
|
<h2>\u{1F4E5} New submissions (${subs.length})</h2>
|
|
5212
|
-
${rows}
|
|
5381
|
+
${rows}${more}
|
|
5213
5382
|
</section>`;
|
|
5214
5383
|
}
|
|
5215
5384
|
function submBadge(c) {
|
|
@@ -5302,6 +5471,7 @@ function renderCockpitHtml(model) {
|
|
|
5302
5471
|
<h1>Reddoor fleet cockpit</h1>
|
|
5303
5472
|
<div class="meta">${total} site${total === 1 ? "" : "s"} on the Reddoor stack.</div>
|
|
5304
5473
|
${summaryBar(model)}
|
|
5474
|
+
${spamRollup(model)}
|
|
5305
5475
|
${allClearBanner(model)}
|
|
5306
5476
|
${approveStrip(model)}
|
|
5307
5477
|
${submissionsStrip(model)}
|