@reddoorla/maintenance 0.49.0 → 0.51.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 +722 -50
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +3 -5
- package/dist/cli/commands/audit.js +532 -16
- 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 +109 -3
- package/dist/index.js +711 -41
- package/dist/index.js.map +1 -1
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/{types-DeKpgkG-.d.ts → types-QG-QhCYh.d.ts} +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -939,13 +939,348 @@ async function a11yAudit(ctx) {
|
|
|
939
939
|
}
|
|
940
940
|
}
|
|
941
941
|
|
|
942
|
+
// src/audits/domain.ts
|
|
943
|
+
import { promises as dnsPromises } from "dns";
|
|
944
|
+
import tls from "tls";
|
|
945
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
946
|
+
async function checkDomain(url, deps) {
|
|
947
|
+
let host;
|
|
948
|
+
try {
|
|
949
|
+
host = new URL(url).hostname;
|
|
950
|
+
} catch {
|
|
951
|
+
return { resolved: false, certDaysRemaining: null };
|
|
952
|
+
}
|
|
953
|
+
try {
|
|
954
|
+
await deps.lookup(host);
|
|
955
|
+
} catch {
|
|
956
|
+
return { resolved: false, certDaysRemaining: null };
|
|
957
|
+
}
|
|
958
|
+
let validTo;
|
|
959
|
+
try {
|
|
960
|
+
validTo = await deps.certValidTo(host);
|
|
961
|
+
} catch {
|
|
962
|
+
validTo = null;
|
|
963
|
+
}
|
|
964
|
+
if (!validTo || Number.isNaN(validTo.getTime()))
|
|
965
|
+
return { resolved: true, certDaysRemaining: null };
|
|
966
|
+
return {
|
|
967
|
+
resolved: true,
|
|
968
|
+
certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
function defaultDomainDeps(now) {
|
|
972
|
+
return {
|
|
973
|
+
lookup: async (host) => {
|
|
974
|
+
await dnsPromises.lookup(host);
|
|
975
|
+
},
|
|
976
|
+
certValidTo: (host) => new Promise((resolvePromise) => {
|
|
977
|
+
const socket = tls.connect(
|
|
978
|
+
{ host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
|
|
979
|
+
() => {
|
|
980
|
+
const cert = socket.authorized ? socket.getPeerCertificate() : null;
|
|
981
|
+
socket.end();
|
|
982
|
+
const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
|
|
983
|
+
resolvePromise(validTo);
|
|
984
|
+
}
|
|
985
|
+
);
|
|
986
|
+
socket.on("error", () => resolvePromise(null));
|
|
987
|
+
socket.on("timeout", () => {
|
|
988
|
+
socket.destroy();
|
|
989
|
+
resolvePromise(null);
|
|
990
|
+
});
|
|
991
|
+
}),
|
|
992
|
+
now
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
async function domainAudit(ctx) {
|
|
996
|
+
const { site } = ctx;
|
|
997
|
+
const label = siteLabel(site);
|
|
998
|
+
if (!site.deployedUrl) {
|
|
999
|
+
return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
|
|
1000
|
+
}
|
|
1001
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1002
|
+
const deps = ctx.domainDeps ?? defaultDomainDeps(now);
|
|
1003
|
+
const check = await checkDomain(site.deployedUrl, deps);
|
|
1004
|
+
const checkedAt = now.toISOString();
|
|
1005
|
+
const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
|
|
1006
|
+
const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
|
|
1007
|
+
return {
|
|
1008
|
+
audit: "domain",
|
|
1009
|
+
site: label,
|
|
1010
|
+
status,
|
|
1011
|
+
summary,
|
|
1012
|
+
details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/audits/route-discovery.ts
|
|
1017
|
+
var DEFAULT_CAP = 15;
|
|
1018
|
+
function parseSitemapUrls(xml) {
|
|
1019
|
+
const out = [];
|
|
1020
|
+
const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
|
|
1021
|
+
let m;
|
|
1022
|
+
while ((m = re.exec(xml)) !== null) {
|
|
1023
|
+
const url = m[1];
|
|
1024
|
+
if (url) out.push(url.trim());
|
|
1025
|
+
}
|
|
1026
|
+
return out;
|
|
1027
|
+
}
|
|
1028
|
+
function parseHtmlLinks(html, baseUrl) {
|
|
1029
|
+
const out = /* @__PURE__ */ new Set();
|
|
1030
|
+
const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
|
|
1031
|
+
let m;
|
|
1032
|
+
while ((m = re.exec(html)) !== null) {
|
|
1033
|
+
const href = m[1];
|
|
1034
|
+
if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
|
|
1035
|
+
try {
|
|
1036
|
+
const u = new URL(href, baseUrl);
|
|
1037
|
+
if (u.origin !== new URL(baseUrl).origin) continue;
|
|
1038
|
+
out.add(u.pathname);
|
|
1039
|
+
} catch {
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return [...out];
|
|
1043
|
+
}
|
|
1044
|
+
function family(pathname) {
|
|
1045
|
+
return pathname.split("/").filter(Boolean)[0] ?? "";
|
|
1046
|
+
}
|
|
1047
|
+
function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
|
|
1048
|
+
const seen = /* @__PURE__ */ new Set(["/"]);
|
|
1049
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1050
|
+
for (const raw of urlsOrPaths) {
|
|
1051
|
+
let pathname;
|
|
1052
|
+
try {
|
|
1053
|
+
pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
|
|
1054
|
+
} catch {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
if (pathname === "/") continue;
|
|
1058
|
+
if (seen.has(pathname)) continue;
|
|
1059
|
+
seen.add(pathname);
|
|
1060
|
+
const fam = family(pathname);
|
|
1061
|
+
const arr = buckets.get(fam) ?? [];
|
|
1062
|
+
arr.push(pathname);
|
|
1063
|
+
buckets.set(fam, arr);
|
|
1064
|
+
}
|
|
1065
|
+
const result = ["/"];
|
|
1066
|
+
const families = [...buckets.values()];
|
|
1067
|
+
let guard = 0;
|
|
1068
|
+
while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
|
|
1069
|
+
for (const fam of families) {
|
|
1070
|
+
if (result.length >= cap) break;
|
|
1071
|
+
const next = fam.shift();
|
|
1072
|
+
if (next) result.push(next);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return result;
|
|
1076
|
+
}
|
|
1077
|
+
function familyCountsOf(paths) {
|
|
1078
|
+
const counts = {};
|
|
1079
|
+
for (const p of paths) {
|
|
1080
|
+
const key = p === "/" ? "/" : `/${family(p)}`;
|
|
1081
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
1082
|
+
}
|
|
1083
|
+
return counts;
|
|
1084
|
+
}
|
|
1085
|
+
async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
|
|
1086
|
+
const origin = new URL(deployedUrl).origin;
|
|
1087
|
+
const abs = (paths) => paths.map((p) => new URL(p, origin).href);
|
|
1088
|
+
const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
|
|
1089
|
+
if (sitemapXml) {
|
|
1090
|
+
const urls = parseSitemapUrls(sitemapXml);
|
|
1091
|
+
if (urls.length > 0) {
|
|
1092
|
+
const paths = sampleRoutePaths(urls, cap);
|
|
1093
|
+
return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const homeHtml = await deps.fetchText(origin);
|
|
1097
|
+
if (homeHtml) {
|
|
1098
|
+
const links = parseHtmlLinks(homeHtml, origin);
|
|
1099
|
+
if (links.length > 0) {
|
|
1100
|
+
const paths = sampleRoutePaths(links, cap);
|
|
1101
|
+
return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// src/audits/browser.ts
|
|
1108
|
+
function isBroken(status) {
|
|
1109
|
+
return status === null || status >= 400;
|
|
1110
|
+
}
|
|
1111
|
+
function summarizeBrowser(routes, links, familyCounts) {
|
|
1112
|
+
const desktopChecks = routes.flatMap((r) => r.desktop);
|
|
1113
|
+
const mobileChecks = routes.flatMap((r) => r.mobile);
|
|
1114
|
+
const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
|
|
1115
|
+
const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
|
|
1116
|
+
const brokenLinks = links.filter((l) => isBroken(l.status)).length;
|
|
1117
|
+
const linksOk = links.length > 0 && brokenLinks === 0;
|
|
1118
|
+
const engines = [...new Set(desktopChecks.map((d) => d.engine))];
|
|
1119
|
+
const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
|
|
1120
|
+
const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
|
|
1121
|
+
const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
|
|
1122
|
+
return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
|
|
1123
|
+
}
|
|
1124
|
+
async function browserAudit(ctx) {
|
|
1125
|
+
const { site } = ctx;
|
|
1126
|
+
const label = siteLabel(site);
|
|
1127
|
+
if (!site.deployedUrl) {
|
|
1128
|
+
return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
|
|
1129
|
+
}
|
|
1130
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1131
|
+
const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
|
|
1132
|
+
const runner = ctx.browserRunner ?? await defaultBrowserRunner();
|
|
1133
|
+
try {
|
|
1134
|
+
const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
|
|
1135
|
+
const routeResults = await runner.probe(discovered.routes);
|
|
1136
|
+
const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
|
|
1137
|
+
const linkResults = await runner.checkLinks(internalLinks);
|
|
1138
|
+
const summary = summarizeBrowser(
|
|
1139
|
+
routeResults,
|
|
1140
|
+
linkResults,
|
|
1141
|
+
discovered.familyCounts ?? familyCountsOf(discovered.routes)
|
|
1142
|
+
);
|
|
1143
|
+
const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
|
|
1144
|
+
return {
|
|
1145
|
+
audit: "browser",
|
|
1146
|
+
site: label,
|
|
1147
|
+
status,
|
|
1148
|
+
summary: summary.note,
|
|
1149
|
+
details: { ...summary, checkedAt: now.toISOString() }
|
|
1150
|
+
};
|
|
1151
|
+
} finally {
|
|
1152
|
+
await runner.close?.();
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function defaultDiscoverDeps() {
|
|
1156
|
+
return {
|
|
1157
|
+
fetchText: async (url) => {
|
|
1158
|
+
try {
|
|
1159
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
1160
|
+
if (!res.ok) return null;
|
|
1161
|
+
return await res.text();
|
|
1162
|
+
} catch {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
|
|
1169
|
+
var PAGE_TIMEOUT_MS = 3e4;
|
|
1170
|
+
async function defaultBrowserRunner() {
|
|
1171
|
+
const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
|
|
1172
|
+
const desktopEngines = [
|
|
1173
|
+
{ engine: "chromium", type: chromium },
|
|
1174
|
+
{ engine: "firefox", type: firefox },
|
|
1175
|
+
{ engine: "webkit", type: webkit }
|
|
1176
|
+
];
|
|
1177
|
+
const mobileTargets = [
|
|
1178
|
+
{ device: "Pixel 7", descriptor: devices2["Pixel 7"] },
|
|
1179
|
+
{ device: "iPhone 14", descriptor: devices2["iPhone 14"] }
|
|
1180
|
+
];
|
|
1181
|
+
return {
|
|
1182
|
+
async probe(urls) {
|
|
1183
|
+
const results = [];
|
|
1184
|
+
const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
|
|
1185
|
+
const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
|
|
1186
|
+
try {
|
|
1187
|
+
for (const url of urls) {
|
|
1188
|
+
const desktop = [];
|
|
1189
|
+
const linkSet = /* @__PURE__ */ new Set();
|
|
1190
|
+
for (let i = 0; i < desktopEngines.length; i++) {
|
|
1191
|
+
const engine = desktopEngines[i].engine;
|
|
1192
|
+
const browser = browsers[i];
|
|
1193
|
+
const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
|
|
1194
|
+
const page = await ctx.newPage();
|
|
1195
|
+
const errors = [];
|
|
1196
|
+
page.on("pageerror", (e) => errors.push(String(e)));
|
|
1197
|
+
let ok = false;
|
|
1198
|
+
try {
|
|
1199
|
+
const resp = await page.goto(url, {
|
|
1200
|
+
waitUntil: "domcontentloaded",
|
|
1201
|
+
timeout: PAGE_TIMEOUT_MS
|
|
1202
|
+
});
|
|
1203
|
+
const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
|
|
1204
|
+
ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
|
|
1205
|
+
if (engine === "chromium") {
|
|
1206
|
+
const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
|
|
1207
|
+
const origin = new URL(url).origin;
|
|
1208
|
+
for (const h of hrefs) {
|
|
1209
|
+
try {
|
|
1210
|
+
if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
|
|
1211
|
+
} catch {
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
} catch {
|
|
1216
|
+
ok = false;
|
|
1217
|
+
} finally {
|
|
1218
|
+
await ctx.close().catch(() => {
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
desktop.push({ engine, ok });
|
|
1222
|
+
}
|
|
1223
|
+
const mobile = [];
|
|
1224
|
+
for (let i = 0; i < mobileTargets.length; i++) {
|
|
1225
|
+
const { device, descriptor } = mobileTargets[i];
|
|
1226
|
+
const browser = mobileBrowsers[i];
|
|
1227
|
+
const ctx = await browser.newContext({ ...descriptor });
|
|
1228
|
+
const page = await ctx.newPage();
|
|
1229
|
+
const errors = [];
|
|
1230
|
+
page.on("pageerror", (e) => errors.push(String(e)));
|
|
1231
|
+
let ok = false;
|
|
1232
|
+
try {
|
|
1233
|
+
const resp = await page.goto(url, {
|
|
1234
|
+
waitUntil: "domcontentloaded",
|
|
1235
|
+
timeout: PAGE_TIMEOUT_MS
|
|
1236
|
+
});
|
|
1237
|
+
const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
|
|
1238
|
+
ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
|
|
1239
|
+
} catch {
|
|
1240
|
+
ok = false;
|
|
1241
|
+
} finally {
|
|
1242
|
+
await ctx.close().catch(() => {
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
mobile.push({ device, ok });
|
|
1246
|
+
}
|
|
1247
|
+
results.push({ url, desktop, mobile, links: [...linkSet] });
|
|
1248
|
+
}
|
|
1249
|
+
} finally {
|
|
1250
|
+
await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
|
|
1251
|
+
})));
|
|
1252
|
+
}
|
|
1253
|
+
return results;
|
|
1254
|
+
},
|
|
1255
|
+
async checkLinks(urls) {
|
|
1256
|
+
const out = [];
|
|
1257
|
+
for (const url of urls) {
|
|
1258
|
+
let status;
|
|
1259
|
+
try {
|
|
1260
|
+
let res = await fetch(url, { method: "HEAD", redirect: "follow" });
|
|
1261
|
+
if (res.status === 405 || res.status === 501) {
|
|
1262
|
+
res = await fetch(url, { method: "GET", redirect: "follow" });
|
|
1263
|
+
}
|
|
1264
|
+
status = res.status;
|
|
1265
|
+
} catch {
|
|
1266
|
+
status = null;
|
|
1267
|
+
}
|
|
1268
|
+
out.push({ url, status });
|
|
1269
|
+
}
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
942
1275
|
// src/audits/index.ts
|
|
943
1276
|
var REGISTRY = {
|
|
944
1277
|
deps: depsAudit,
|
|
945
1278
|
lint: lintAudit,
|
|
946
1279
|
security: securityAudit,
|
|
947
1280
|
lighthouse: lighthouseAudit,
|
|
948
|
-
a11y: a11yAudit
|
|
1281
|
+
a11y: a11yAudit,
|
|
1282
|
+
domain: domainAudit,
|
|
1283
|
+
browser: browserAudit
|
|
949
1284
|
};
|
|
950
1285
|
var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
|
|
951
1286
|
var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
|
|
@@ -2557,6 +2892,16 @@ function isHttpUrl(s) {
|
|
|
2557
2892
|
}
|
|
2558
2893
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
2559
2894
|
}
|
|
2895
|
+
function isNetlifyAppUrl(s) {
|
|
2896
|
+
let parsed;
|
|
2897
|
+
try {
|
|
2898
|
+
parsed = new URL(s);
|
|
2899
|
+
} catch {
|
|
2900
|
+
return false;
|
|
2901
|
+
}
|
|
2902
|
+
const host = parsed.hostname.toLowerCase();
|
|
2903
|
+
return host === "netlify.app" || host.endsWith(".netlify.app");
|
|
2904
|
+
}
|
|
2560
2905
|
|
|
2561
2906
|
// src/inventory/json.ts
|
|
2562
2907
|
function validate(raw) {
|
|
@@ -2684,6 +3029,15 @@ function mapRow(rec) {
|
|
|
2684
3029
|
securityVulnsHigh: f["Security Vulns High"] ?? null,
|
|
2685
3030
|
securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
|
|
2686
3031
|
securityVulnsLow: f["Security Vulns Low"] ?? null,
|
|
3032
|
+
lastSecurityAuditAt: f["Last security audit at"] ?? null,
|
|
3033
|
+
securityAdvisories: parseSecurityAdvisories(f["Security advisories"]),
|
|
3034
|
+
certDaysRemaining: f["Cert days remaining"] ?? null,
|
|
3035
|
+
domainCheckedAt: f["Domain checked at"] ?? null,
|
|
3036
|
+
crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
|
|
3037
|
+
mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
|
|
3038
|
+
linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
|
|
3039
|
+
brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
|
|
3040
|
+
browserCheckedAt: f["Browser checked at"] ?? null,
|
|
2687
3041
|
copyIntro: trimToNull(f["Copy \u2014 Intro"]),
|
|
2688
3042
|
copyContact: trimToNull(f["Copy \u2014 Contact"]),
|
|
2689
3043
|
copyFooter: trimToNull(f["Copy \u2014 Footer"]),
|
|
@@ -2706,6 +3060,40 @@ async function listWebsites(base) {
|
|
|
2706
3060
|
});
|
|
2707
3061
|
return out;
|
|
2708
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
|
+
}
|
|
2709
3097
|
async function updateLaunched(base, recordId, at) {
|
|
2710
3098
|
const fields = { Status: "maintenance", "Launched at": at };
|
|
2711
3099
|
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
@@ -3363,9 +3751,32 @@ function mapRow2(rec) {
|
|
|
3363
3751
|
deliveryStatus: f["Delivery status"] ?? "pending",
|
|
3364
3752
|
renderedHtmlAttachment: html,
|
|
3365
3753
|
resendMessageId: f["Resend message ID"] ?? null,
|
|
3366
|
-
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
|
|
3754
|
+
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
|
|
3755
|
+
autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
|
|
3367
3756
|
};
|
|
3368
3757
|
}
|
|
3758
|
+
function parseAutoEvidence(raw) {
|
|
3759
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
3760
|
+
let parsed;
|
|
3761
|
+
try {
|
|
3762
|
+
parsed = JSON.parse(raw);
|
|
3763
|
+
} catch {
|
|
3764
|
+
return null;
|
|
3765
|
+
}
|
|
3766
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
3767
|
+
const out = {};
|
|
3768
|
+
for (const [field, v] of Object.entries(parsed)) {
|
|
3769
|
+
if (!v || typeof v !== "object") continue;
|
|
3770
|
+
const o = v;
|
|
3771
|
+
if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
|
|
3772
|
+
out[field] = {
|
|
3773
|
+
result: o.result,
|
|
3774
|
+
checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
|
|
3775
|
+
note: typeof o.note === "string" ? o.note : ""
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
3779
|
+
}
|
|
3369
3780
|
function lighthouseFromFields(f) {
|
|
3370
3781
|
const p = f["Lighthouse \u2014 Performance"];
|
|
3371
3782
|
const a = f["Lighthouse \u2014 Accessibility"];
|
|
@@ -3399,6 +3810,10 @@ async function createDraft(base, input) {
|
|
|
3399
3810
|
if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
|
|
3400
3811
|
if (input.period !== void 0) fields["Period"] = input.period;
|
|
3401
3812
|
if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
|
|
3813
|
+
for (const field of input.checklistTicks ?? []) fields[field] = true;
|
|
3814
|
+
if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
|
|
3815
|
+
fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
|
|
3816
|
+
}
|
|
3402
3817
|
const created = await base(REPORTS_TABLE).create([{ fields }]);
|
|
3403
3818
|
const rec = created[0];
|
|
3404
3819
|
if (!rec) throw new Error("Airtable create returned no records");
|
|
@@ -3467,6 +3882,118 @@ async function queueDraft(base, report) {
|
|
|
3467
3882
|
return { queued: true, supersededIds };
|
|
3468
3883
|
}
|
|
3469
3884
|
|
|
3885
|
+
// src/reports/auto-tick.ts
|
|
3886
|
+
var STALE_DAYS = 3;
|
|
3887
|
+
var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
|
|
3888
|
+
function isFresh(checkedAt, now) {
|
|
3889
|
+
if (!checkedAt) return false;
|
|
3890
|
+
const t = new Date(checkedAt).getTime();
|
|
3891
|
+
if (Number.isNaN(t)) return false;
|
|
3892
|
+
return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
|
|
3893
|
+
}
|
|
3894
|
+
var CERT_MIN_DAYS = 14;
|
|
3895
|
+
function autoTickChecklist(site, reportType, now, signals) {
|
|
3896
|
+
const out = /* @__PURE__ */ new Map();
|
|
3897
|
+
const fields = new Set(checklistFor(reportType).map((i) => i.field));
|
|
3898
|
+
if (fields.has("Maint: Google Indexed")) {
|
|
3899
|
+
const g = googleEvidence(now, signals.search);
|
|
3900
|
+
if (g) out.set("Maint: Google Indexed", g);
|
|
3901
|
+
}
|
|
3902
|
+
if (fields.has("Maint: Security Updates")) {
|
|
3903
|
+
const s = securityEvidence(site, now);
|
|
3904
|
+
if (s) out.set("Maint: Security Updates", s);
|
|
3905
|
+
}
|
|
3906
|
+
if (fields.has("Maint: Domain, DNS & SSL")) {
|
|
3907
|
+
const d = domainEvidence(site, now);
|
|
3908
|
+
if (d) out.set("Maint: Domain, DNS & SSL", d);
|
|
3909
|
+
}
|
|
3910
|
+
if (fields.has("Test: Desktop Browsers")) {
|
|
3911
|
+
const e = browserEvidence(
|
|
3912
|
+
site.crossbrowserOk,
|
|
3913
|
+
site,
|
|
3914
|
+
now,
|
|
3915
|
+
"Desktop renders cleanly",
|
|
3916
|
+
"render errors"
|
|
3917
|
+
);
|
|
3918
|
+
if (e) out.set("Test: Desktop Browsers", e);
|
|
3919
|
+
}
|
|
3920
|
+
if (fields.has("Test: Mobile Browsers")) {
|
|
3921
|
+
const e = browserEvidence(
|
|
3922
|
+
site.mobileOk,
|
|
3923
|
+
site,
|
|
3924
|
+
now,
|
|
3925
|
+
"Mobile renders cleanly",
|
|
3926
|
+
"overflow/errors"
|
|
3927
|
+
);
|
|
3928
|
+
if (e) out.set("Test: Mobile Browsers", e);
|
|
3929
|
+
}
|
|
3930
|
+
if (fields.has("Test: Links & Navigation")) {
|
|
3931
|
+
const broken = site.brokenLinks;
|
|
3932
|
+
const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
|
|
3933
|
+
const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
|
|
3934
|
+
if (e) out.set("Test: Links & Navigation", e);
|
|
3935
|
+
}
|
|
3936
|
+
return out;
|
|
3937
|
+
}
|
|
3938
|
+
function browserEvidence(ok, site, now, passNote, failNote) {
|
|
3939
|
+
if (ok === null || !site.browserCheckedAt) return null;
|
|
3940
|
+
const at = site.browserCheckedAt;
|
|
3941
|
+
if (!isFresh(at, now)) {
|
|
3942
|
+
return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
|
|
3943
|
+
}
|
|
3944
|
+
return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
|
|
3945
|
+
}
|
|
3946
|
+
function securityEvidence(site, now) {
|
|
3947
|
+
const crit = site.securityVulnsCritical;
|
|
3948
|
+
const high = site.securityVulnsHigh;
|
|
3949
|
+
if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
|
|
3950
|
+
const at = site.lastSecurityAuditAt;
|
|
3951
|
+
if (!isFresh(at, now)) {
|
|
3952
|
+
return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
|
|
3953
|
+
}
|
|
3954
|
+
if (crit === 0 && high === 0) {
|
|
3955
|
+
return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
|
|
3956
|
+
}
|
|
3957
|
+
return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
|
|
3958
|
+
}
|
|
3959
|
+
function googleEvidence(now, search) {
|
|
3960
|
+
const at = now.toISOString();
|
|
3961
|
+
if (search.softFailed) {
|
|
3962
|
+
return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
|
|
3963
|
+
}
|
|
3964
|
+
if (search.value === null) return null;
|
|
3965
|
+
if (search.value.foundOnPage1) {
|
|
3966
|
+
const pos2 = search.value.position;
|
|
3967
|
+
return {
|
|
3968
|
+
result: "pass",
|
|
3969
|
+
checkedAt: at,
|
|
3970
|
+
note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
|
|
3971
|
+
};
|
|
3972
|
+
}
|
|
3973
|
+
const pos = search.value.position;
|
|
3974
|
+
return {
|
|
3975
|
+
result: "fail",
|
|
3976
|
+
checkedAt: at,
|
|
3977
|
+
note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
function domainEvidence(site, now) {
|
|
3981
|
+
if (!site.url || isNetlifyAppUrl(site.url)) return null;
|
|
3982
|
+
if (!site.domainCheckedAt) return null;
|
|
3983
|
+
const at = site.domainCheckedAt;
|
|
3984
|
+
if (!isFresh(site.domainCheckedAt, now)) {
|
|
3985
|
+
return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
|
|
3986
|
+
}
|
|
3987
|
+
const days = site.certDaysRemaining;
|
|
3988
|
+
if (days === null) {
|
|
3989
|
+
return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
|
|
3990
|
+
}
|
|
3991
|
+
if (days <= CERT_MIN_DAYS) {
|
|
3992
|
+
return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
|
|
3993
|
+
}
|
|
3994
|
+
return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3470
3997
|
// src/reports/airtable/attachments.ts
|
|
3471
3998
|
function looksLikeHtml(bytes) {
|
|
3472
3999
|
const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
|
|
@@ -3538,7 +4065,7 @@ import { readFileSync as readFileSync3 } from "fs";
|
|
|
3538
4065
|
import { JWT } from "google-auth-library";
|
|
3539
4066
|
import { BetaAnalyticsDataClient } from "@google-analytics/data";
|
|
3540
4067
|
var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
|
|
3541
|
-
var
|
|
4068
|
+
var MS_PER_DAY3 = 864e5;
|
|
3542
4069
|
function ymd2(d) {
|
|
3543
4070
|
return d.toISOString().slice(0, 10);
|
|
3544
4071
|
}
|
|
@@ -3551,9 +4078,9 @@ async function fetchPeriodUsers(query, periodStart, periodEnd) {
|
|
|
3551
4078
|
subject: query.subject
|
|
3552
4079
|
});
|
|
3553
4080
|
const client = new BetaAnalyticsDataClient({ authClient });
|
|
3554
|
-
const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) /
|
|
3555
|
-
const prevEnd = new Date(periodStart.getTime() -
|
|
3556
|
-
const prevStart = new Date(prevEnd.getTime() - lengthDays *
|
|
4081
|
+
const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
|
|
4082
|
+
const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
|
|
4083
|
+
const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
|
|
3557
4084
|
const property = `properties/${query.propertyId}`;
|
|
3558
4085
|
const run = async (start, end) => {
|
|
3559
4086
|
const [resp] = await client.runReport({
|
|
@@ -3677,7 +4204,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
3677
4204
|
const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
|
|
3678
4205
|
const periodEnd = today;
|
|
3679
4206
|
const completedOn = today;
|
|
3680
|
-
const lastTestedDate = reportType === "Maintenance" && siteRow.
|
|
4207
|
+
const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
|
|
3681
4208
|
const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
3682
4209
|
const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
3683
4210
|
const gaUsers = gaResult.value;
|
|
@@ -3724,6 +4251,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
3724
4251
|
supersededIds: outcome2.supersededIds
|
|
3725
4252
|
};
|
|
3726
4253
|
}
|
|
4254
|
+
const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
|
|
4255
|
+
const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
|
|
4256
|
+
const autoEvidence = Object.fromEntries(evidence);
|
|
3727
4257
|
const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
|
|
3728
4258
|
const created = await createDraft(base, {
|
|
3729
4259
|
reportId,
|
|
@@ -3737,7 +4267,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
3737
4267
|
lastTestedDate,
|
|
3738
4268
|
...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
|
|
3739
4269
|
...search ? { searchFoundPage1: search.foundOnPage1 } : {},
|
|
3740
|
-
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
|
|
4270
|
+
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
|
|
4271
|
+
checklistTicks,
|
|
4272
|
+
autoEvidence
|
|
3741
4273
|
});
|
|
3742
4274
|
await uploadDraftHtml(created.id, slug, periodEnd, html);
|
|
3743
4275
|
const outcome = await queueDraft(base, {
|
|
@@ -3807,6 +4339,36 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
|
|
|
3807
4339
|
|
|
3808
4340
|
// src/reports/airtable/client.ts
|
|
3809
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;
|
|
3810
4372
|
function missing(name) {
|
|
3811
4373
|
return Object.assign(
|
|
3812
4374
|
new Error(
|
|
@@ -3823,7 +4385,12 @@ function readAirtableConfig() {
|
|
|
3823
4385
|
return { apiKey, baseId };
|
|
3824
4386
|
}
|
|
3825
4387
|
function openBase(cfg) {
|
|
3826
|
-
|
|
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
|
+
});
|
|
3827
4394
|
}
|
|
3828
4395
|
|
|
3829
4396
|
// src/reports/maintenance-email/header-image.ts
|
|
@@ -4026,35 +4593,33 @@ async function sendOne(client, base, site, report) {
|
|
|
4026
4593
|
});
|
|
4027
4594
|
const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
|
|
4028
4595
|
const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
|
|
4596
|
+
const attachments = [
|
|
4597
|
+
toInlineAttachment({
|
|
4598
|
+
bytes: header.bytes,
|
|
4599
|
+
filename: `${cidName}.jpg`,
|
|
4600
|
+
contentType: header.contentType,
|
|
4601
|
+
cid: cidName
|
|
4602
|
+
})
|
|
4603
|
+
];
|
|
4604
|
+
for (const img of [bundled.check, bundled.blurred]) {
|
|
4605
|
+
if (html.includes(`cid:${img.cid}`)) {
|
|
4606
|
+
attachments.push(
|
|
4607
|
+
toInlineAttachment({
|
|
4608
|
+
bytes: img.bytes,
|
|
4609
|
+
filename: img.filename,
|
|
4610
|
+
contentType: img.contentType,
|
|
4611
|
+
cid: img.cid
|
|
4612
|
+
})
|
|
4613
|
+
);
|
|
4614
|
+
}
|
|
4615
|
+
}
|
|
4029
4616
|
const payload = {
|
|
4030
4617
|
from: FROM_ADDRESS,
|
|
4031
4618
|
to,
|
|
4032
4619
|
replyTo: REPLY_TO,
|
|
4033
4620
|
subject,
|
|
4034
4621
|
html,
|
|
4035
|
-
attachments
|
|
4036
|
-
toInlineAttachment({
|
|
4037
|
-
bytes: header.bytes,
|
|
4038
|
-
filename: `${cidName}.jpg`,
|
|
4039
|
-
contentType: header.contentType,
|
|
4040
|
-
cid: cidName
|
|
4041
|
-
}),
|
|
4042
|
-
// Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
|
|
4043
|
-
// in the template. Attached inline so the email is self-contained — no
|
|
4044
|
-
// external CDN dependency, no image-blocked broken icons in webmail.
|
|
4045
|
-
toInlineAttachment({
|
|
4046
|
-
bytes: bundled.check.bytes,
|
|
4047
|
-
filename: bundled.check.filename,
|
|
4048
|
-
contentType: bundled.check.contentType,
|
|
4049
|
-
cid: bundled.check.cid
|
|
4050
|
-
}),
|
|
4051
|
-
toInlineAttachment({
|
|
4052
|
-
bytes: bundled.blurred.bytes,
|
|
4053
|
-
filename: bundled.blurred.filename,
|
|
4054
|
-
contentType: bundled.blurred.contentType,
|
|
4055
|
-
cid: bundled.blurred.cid
|
|
4056
|
-
})
|
|
4057
|
-
],
|
|
4622
|
+
attachments,
|
|
4058
4623
|
// Stable across retries of the same row — if Airtable stamping fails after a
|
|
4059
4624
|
// successful Resend, the next --send-ready replays with the same key and
|
|
4060
4625
|
// Resend returns the original message id rather than sending a duplicate.
|
|
@@ -4244,6 +4809,28 @@ function securitySub(site) {
|
|
|
4244
4809
|
const l = site.securityVulnsLow ?? 0;
|
|
4245
4810
|
return `${c}C / ${h}H / ${m}M / ${l}L`;
|
|
4246
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
|
+
}
|
|
4247
4834
|
function checklistBlock(r) {
|
|
4248
4835
|
const items = checklistFor(r.reportType);
|
|
4249
4836
|
if (items.length === 0) return "";
|
|
@@ -4251,7 +4838,9 @@ function checklistBlock(r) {
|
|
|
4251
4838
|
const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;
|
|
4252
4839
|
const boxes = items.map((item) => {
|
|
4253
4840
|
const checked = r.checklist[item.field] === true ? " checked" : "";
|
|
4254
|
-
|
|
4841
|
+
const ev = r.autoEvidence?.[item.field];
|
|
4842
|
+
const badge = ev ? ev.result === "pass" ? ` <span class="auto-badge auto-pass" title="${escapeHtml(ev.note)}">auto \u2713</span>` : ` <span class="auto-badge auto-amber" title="${escapeHtml(ev.note)}">auto: ${escapeHtml(ev.note)}</span>` : "";
|
|
4843
|
+
return `<label class="check-item"><input type="checkbox" class="checklist-checkbox" data-checklist-report-id="${rid}" data-field="${escapeHtml(item.field)}" data-checklist-url="${escapeHtml(url)}"${checked} /> ${escapeHtml(item.label)}${badge}</label>`;
|
|
4255
4844
|
}).join("");
|
|
4256
4845
|
return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
|
|
4257
4846
|
}
|
|
@@ -4296,30 +4885,79 @@ function reportRow(r) {
|
|
|
4296
4885
|
const action = isPendingApproval(r) ? approveButton(r) : "";
|
|
4297
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>`;
|
|
4298
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
|
+
}
|
|
4299
4904
|
function submissionRow(s) {
|
|
4300
4905
|
const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
|
|
4301
4906
|
const type = escapeHtml(s.formType);
|
|
4302
4907
|
const who = escapeHtml(s.name || "(no name)");
|
|
4303
4908
|
const email = escapeHtml(s.email || "");
|
|
4304
|
-
const message = escapeHtml(s.message ?? "");
|
|
4305
4909
|
const status = escapeHtml(s.status);
|
|
4306
4910
|
const id = escapeHtml(s.id);
|
|
4307
4911
|
const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;
|
|
4308
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("");
|
|
4309
4926
|
return `<li class="subm-item">
|
|
4310
|
-
<
|
|
4311
|
-
|
|
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>
|
|
4312
4931
|
<div class="subm-actions">${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>
|
|
4313
4932
|
</li>`;
|
|
4314
4933
|
}
|
|
4934
|
+
var SUBMISSIONS_PER_SITE_CAP = 25;
|
|
4315
4935
|
function submissionsSection(submissions) {
|
|
4316
4936
|
if (submissions.length === 0) return "";
|
|
4317
|
-
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>` : "";
|
|
4318
4939
|
return `<div class="section submissions">
|
|
4319
|
-
<h2>Form submissions (${submissions.length})</h2>
|
|
4940
|
+
<h2>Form submissions (${submissions.length})${note}</h2>
|
|
4320
4941
|
<ul class="subm-list">${recent.map(submissionRow).join("")}</ul>
|
|
4321
4942
|
</div>`;
|
|
4322
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
|
+
}
|
|
4323
4961
|
function setupSection(site) {
|
|
4324
4962
|
const { score, total } = onboardingStatus(site);
|
|
4325
4963
|
const missing2 = missingOnboarding(site);
|
|
@@ -4378,19 +5016,35 @@ button.approve:disabled { opacity: 0.6; cursor: default; }
|
|
|
4378
5016
|
.checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
|
|
4379
5017
|
.check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
|
|
4380
5018
|
.check-item input { margin: 0; }
|
|
5019
|
+
.auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }
|
|
5020
|
+
.auto-pass { background: #e6f4ea; color: #137333; }
|
|
5021
|
+
.auto-amber { background: #fef7e0; color: #b06000; }
|
|
4381
5022
|
.pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
|
|
4382
5023
|
.subm-list { list-style: none; padding: 0; margin: 0; }
|
|
4383
5024
|
.subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }
|
|
4384
5025
|
@media (prefers-color-scheme: dark) { .subm-item { border-color: #2a2a2a; } }
|
|
4385
5026
|
.subm-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
|
4386
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; }
|
|
4387
5032
|
.subm-actions { display: flex; gap: 0.4rem; }
|
|
4388
5033
|
button.subm-status { font: inherit; padding: 0.25rem 0.7rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
|
|
4389
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; }
|
|
4390
5037
|
.pill.subm-new { background: #e8f0fe; color: #1a56db; }
|
|
4391
5038
|
.pill.subm-read { background: #f0f0f0; color: #555; }
|
|
4392
5039
|
.pill.subm-archived { background: #eee; color: #888; }
|
|
4393
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; }
|
|
4394
5048
|
.home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }
|
|
4395
5049
|
.setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }
|
|
4396
5050
|
.setup-ok { color: #1b7a2f; font-weight: 600; }
|
|
@@ -4400,7 +5054,7 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
|
|
|
4400
5054
|
.detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }
|
|
4401
5055
|
.detail dd { margin: 0; }
|
|
4402
5056
|
`;
|
|
4403
|
-
function renderSiteDashboardHtml(site, reports, submissions = []) {
|
|
5057
|
+
function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = null, now = /* @__PURE__ */ new Date()) {
|
|
4404
5058
|
const name = escapeHtml(site.name);
|
|
4405
5059
|
const urlSafe = safeUrl(site.url);
|
|
4406
5060
|
const allScoresNull = site.pScore === null && site.rScore === null && site.bpScore === null && site.seoScore === null;
|
|
@@ -4451,6 +5105,10 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
|
|
|
4451
5105
|
${healthSection}
|
|
4452
5106
|
</div>
|
|
4453
5107
|
|
|
5108
|
+
${securitySection(site)}
|
|
5109
|
+
|
|
5110
|
+
${spamScreenSection(spamTotals, submissions, now)}
|
|
5111
|
+
|
|
4454
5112
|
<div class="section">
|
|
4455
5113
|
<h2>Reports</h2>
|
|
4456
5114
|
${reportsSection}
|
|
@@ -4610,6 +5268,8 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
|
|
|
4610
5268
|
.summary { display:flex; flex-wrap:wrap; gap:0.5rem 1.25rem; align-items:baseline; margin-bottom:0.5rem; }
|
|
4611
5269
|
.summary .tier { font-weight:700; }
|
|
4612
5270
|
.summary .heads { color:#666; font-size:0.9rem; }
|
|
5271
|
+
.spam-rollup { font-size:0.9rem; margin-bottom:1rem; }
|
|
5272
|
+
.muted { color:#999; }
|
|
4613
5273
|
.filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }
|
|
4614
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; }
|
|
4615
5275
|
.filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
|
|
@@ -4671,6 +5331,11 @@ function summaryBar(model) {
|
|
|
4671
5331
|
<div class="summary heads">${escapeHtml(heads)}</div>
|
|
4672
5332
|
<div class="filters">${chips2}</div>`;
|
|
4673
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
|
+
}
|
|
4674
5339
|
function allClearBanner(model) {
|
|
4675
5340
|
if (model.summary.attention > 0) return "";
|
|
4676
5341
|
const msg = model.cards.length === 0 ? "No sites on the fleet view yet." : "All clear \u2014 nothing needs your attention.";
|
|
@@ -4693,10 +5358,12 @@ function approveStrip(model) {
|
|
|
4693
5358
|
${rows}
|
|
4694
5359
|
</section>`;
|
|
4695
5360
|
}
|
|
5361
|
+
var SUBMISSIONS_STRIP_CAP = 10;
|
|
4696
5362
|
function submissionsStrip(model) {
|
|
4697
5363
|
const subs = model.submissions ?? [];
|
|
4698
5364
|
if (subs.length === 0) return "";
|
|
4699
|
-
const
|
|
5365
|
+
const shown = [...subs].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_STRIP_CAP);
|
|
5366
|
+
const rows = shown.map((sub) => {
|
|
4700
5367
|
const href = `/s/${escapeHtml(sub.slug)}`;
|
|
4701
5368
|
const when = sub.submittedAt ? escapeHtml(relativeTimeFromNow(sub.submittedAt)) : "";
|
|
4702
5369
|
const who = escapeHtml(sub.name || sub.email);
|
|
@@ -4707,9 +5374,11 @@ function submissionsStrip(model) {
|
|
|
4707
5374
|
<a href="${href}">open \u25B8</a>
|
|
4708
5375
|
</div>`;
|
|
4709
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>` : "";
|
|
4710
5379
|
return `<section class="approve-strip subm-strip" data-tier="submissions">
|
|
4711
5380
|
<h2>\u{1F4E5} New submissions (${subs.length})</h2>
|
|
4712
|
-
${rows}
|
|
5381
|
+
${rows}${more}
|
|
4713
5382
|
</section>`;
|
|
4714
5383
|
}
|
|
4715
5384
|
function submBadge(c) {
|
|
@@ -4802,6 +5471,7 @@ function renderCockpitHtml(model) {
|
|
|
4802
5471
|
<h1>Reddoor fleet cockpit</h1>
|
|
4803
5472
|
<div class="meta">${total} site${total === 1 ? "" : "s"} on the Reddoor stack.</div>
|
|
4804
5473
|
${summaryBar(model)}
|
|
5474
|
+
${spamRollup(model)}
|
|
4805
5475
|
${allClearBanner(model)}
|
|
4806
5476
|
${approveStrip(model)}
|
|
4807
5477
|
${submissionsStrip(model)}
|