spiderlint 0.0.0 → 0.143.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.
Files changed (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +233 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +358 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +56 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +16 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +353 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
@@ -0,0 +1,197 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { existsSync, readFileSync, unwatchFile, watchFile } from "node:fs";
5
+ import { BlockList, isIP } from "node:net";
6
+ import { Ajv2020 } from "ajv/dist/2020.js";
7
+ import { parse as parseYaml } from "yaml";
8
+ import { parseDuration } from "../cache/index.js";
9
+ import { ConfigError } from "../config/index.js";
10
+ import { describe, validateSubtree } from "../config/schema.js";
11
+ import { log } from "../logger.js";
12
+ import { directoryOf, Fragments } from "./page.js";
13
+ import { PROVIDERS } from "./providers.js";
14
+ export const DEFAULT_PATH = "/etc/spiderlint/server.yaml";
15
+ const WATCH_MS = 5000;
16
+ // Caps a policy lays over a scan; 0 in a request is unlimited, so it takes the cap.
17
+ export const CAPPED = ["max-pages", "max-depth", "concurrency", "rate", "timeout", "max-body-size"];
18
+ // Origins run by different operators, so one bad clock cannot pass for ours.
19
+ const CLOCK_REFERENCES = ["https://www.cloudflare.com/", "https://www.google.com/", "https://www.wikipedia.org/"];
20
+ const duration = {
21
+ oneOf: [
22
+ { type: "string", pattern: String.raw `^\d+[smhd]?$` },
23
+ { type: "integer", minimum: 1 },
24
+ ],
25
+ };
26
+ const positive = { type: "integer", minimum: 1 };
27
+ const rate = { type: "object", additionalProperties: false, required: ["jobs", "per"], properties: { jobs: positive, per: duration } };
28
+ const schema = {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ properties: {
32
+ redis: { type: "string", pattern: "^rediss?://" },
33
+ "redis-password-file": { type: "string", minLength: 1 },
34
+ listen: { type: "object", additionalProperties: false, properties: { host: { type: "string" }, port: { type: "integer", minimum: 1, maximum: 65_535 } } },
35
+ retention: duration,
36
+ workers: positive,
37
+ "max-queued": positive,
38
+ "allow-private": { type: "boolean" },
39
+ "clock-references": { type: "array", uniqueItems: true, items: { type: "string", pattern: "^https://" } },
40
+ defaults: { type: "object" },
41
+ page: { type: "object", additionalProperties: false, properties: { directory: { type: "string", minLength: 1 }, assets: { type: "string", minLength: 1 } } },
42
+ analytics: {
43
+ type: "object",
44
+ additionalProperties: false,
45
+ properties: {
46
+ matomo: {
47
+ type: "object",
48
+ additionalProperties: false,
49
+ required: ["url", "site-id", "site", "privacy"],
50
+ properties: { url: { type: "string", pattern: "^https?://" }, "site-id": positive, site: { type: "string", pattern: "^https?://" }, privacy: { type: "string", pattern: "^https?://" }, "include-hosts": { type: "boolean" } },
51
+ },
52
+ },
53
+ },
54
+ clients: { type: "object", additionalProperties: false, properties: { rate: { oneOf: [rate, { const: false }] }, "trusted-proxies": { type: "array", items: { type: "string", minLength: 1 } }, "trust-providers": { type: "array", uniqueItems: true, items: { enum: Object.keys(PROVIDERS) } } } },
55
+ policies: {
56
+ type: "array",
57
+ items: {
58
+ type: "object",
59
+ additionalProperties: false,
60
+ required: ["name", "hosts"],
61
+ properties: {
62
+ name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]*$" },
63
+ hosts: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } },
64
+ ban: { oneOf: [{ type: "boolean" }, { type: "string", minLength: 1 }] },
65
+ rate,
66
+ repeat: duration,
67
+ caps: { type: "object", additionalProperties: false, properties: { ...Object.fromEntries(CAPPED.map((key) => [key, { type: "integer", minimum: 1 }])), "scan-timeout": duration } },
68
+ fetch: { type: "array", minItems: 1, uniqueItems: true, items: { enum: ["auto", "http", "browser", "adaptive"] } },
69
+ rules: { type: "object", additionalProperties: false, properties: { allow: { type: "array", items: { type: "string" } }, deny: { type: "array", items: { type: "string" } } } },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ };
75
+ const validate = new Ajv2020({ allErrors: true }).compile(schema);
76
+ // `ua`, `.ua`, `*.ua` and `україна` as the punycode suffix a hostname ends with; `*` stays.
77
+ export function hostSuffix(raw) {
78
+ const bare = raw
79
+ .trim()
80
+ .toLowerCase()
81
+ .replace(/^\*?\./, "")
82
+ .replace(/\.$/, "");
83
+ if (bare === "*")
84
+ return bare;
85
+ if (!URL.canParse(`http://${bare}/`))
86
+ throw new ConfigError(`policies: invalid host ${raw}`);
87
+ return new URL(`http://${bare}/`).hostname;
88
+ }
89
+ function seconds(raw) {
90
+ return parseDuration(raw) ?? 0;
91
+ }
92
+ // One policy with durations in seconds, hosts in punycode and the secure defaults filled in.
93
+ function policyOf(raw, isPrivateAllowed) {
94
+ const { "scan-timeout": scanTimeout = "10m", ...caps } = raw.caps ?? {};
95
+ const fetch = raw.fetch ?? ["http"];
96
+ if (!isPrivateAllowed && fetch.some((mode) => mode !== "http"))
97
+ throw new ConfigError(`policies/${raw.name}/fetch: only http while allow-private is false, since the browser has no address guard`);
98
+ return {
99
+ name: raw.name,
100
+ hosts: raw.hosts.map((host) => hostSuffix(host)),
101
+ ...(raw.ban !== undefined && raw.ban !== false && { ban: raw.ban === true ? `scans of this host are disabled by policy ${raw.name}` : raw.ban }),
102
+ ...(raw.rate && { rate: { jobs: raw.rate.jobs, seconds: seconds(raw.rate.per) } }),
103
+ ...(raw.repeat !== undefined && { repeat: seconds(raw.repeat) }),
104
+ caps: { "max-pages": 100, ...caps },
105
+ scanTimeout: seconds(scanTimeout),
106
+ fetch,
107
+ rules: { ...(raw.rules?.allow && { allow: raw.rules.allow }), deny: raw.rules?.deny ?? [] },
108
+ };
109
+ }
110
+ // `10.0.0.0/8`, `fd00::/8` or one address as a block list entry; anything else throws ConfigError.
111
+ function trustedOf(entries) {
112
+ const trusted = new BlockList();
113
+ for (const entry of entries) {
114
+ const [address = "", prefix] = entry.trim().split("/", 2);
115
+ const family = isIP(address);
116
+ if (family === 0 || (prefix !== undefined && !/^\d+$/.test(prefix)))
117
+ throw new ConfigError(`server/clients/trusted-proxies: invalid address or network ${entry}`);
118
+ const type = family === 6 ? "ipv6" : "ipv4";
119
+ if (prefix === undefined)
120
+ trusted.addAddress(address, type);
121
+ else
122
+ trusted.addSubnet(address, Number(prefix), type);
123
+ }
124
+ return trusted;
125
+ }
126
+ // A parsed document as settings; any violation throws ConfigError naming its path.
127
+ export function settingsOf(raw) {
128
+ const document = raw ?? {};
129
+ if (!validate(document))
130
+ throw new ConfigError((validate.errors ?? []).map((error) => describe(error, "server")).join("; "));
131
+ const value = document;
132
+ const allowPrivate = value["allow-private"] ?? false;
133
+ const defaults = validateSubtree(value.defaults ?? {}, "server/defaults");
134
+ const policies = (value.policies ?? [{ name: "default", hosts: ["*"] }]).map((policy) => policyOf(policy, allowPrivate));
135
+ const names = policies.map((policy) => policy.name).filter((name, index, all) => all.indexOf(name) !== index);
136
+ if (names.length > 0)
137
+ throw new ConfigError(`server/policies: duplicate name ${names.join(", ")}`);
138
+ return {
139
+ redis: value.redis ?? "redis://127.0.0.1:6379",
140
+ ...(value["redis-password-file"] !== undefined && { redisPasswordFile: value["redis-password-file"] }),
141
+ listen: { host: value.listen?.host ?? "0.0.0.0", port: value.listen?.port ?? 8080 },
142
+ retention: seconds(value.retention ?? "7d"),
143
+ workers: value.workers ?? 2,
144
+ maxQueued: value["max-queued"] ?? 100,
145
+ allowPrivate,
146
+ defaults,
147
+ policies,
148
+ clients: { ...clientRate(value.clients?.rate), trusted: trustedOf(value.clients?.["trusted-proxies"] ?? []), providers: value.clients?.["trust-providers"] ?? [] },
149
+ clockReferences: value["clock-references"] ?? CLOCK_REFERENCES,
150
+ page: pageOf(value.page),
151
+ ...(value.analytics?.matomo && { matomo: matomoOf(value.analytics.matomo) }),
152
+ };
153
+ }
154
+ // The fragments and assets directory the settings name, read now so a bad one is refused.
155
+ function pageOf(raw) {
156
+ const assets = raw?.assets === undefined ? undefined : directoryOf("page/assets", raw.assets);
157
+ if (assets)
158
+ log.info({ assets }, "page assets served under /assets/");
159
+ return { ...(raw?.directory !== undefined && { fragments: new Fragments(directoryOf("page/directory", raw.directory)) }), ...(assets && { assets }) };
160
+ }
161
+ // The Matomo block as the tracker takes it.
162
+ function matomoOf(raw) {
163
+ log.info({ url: raw.url, siteId: raw["site-id"], isHostNamed: raw["include-hosts"] ?? false }, "page views reported to Matomo");
164
+ return { url: raw.url, siteId: raw["site-id"], site: raw.site, privacy: raw.privacy, isHostNamed: raw["include-hosts"] ?? false };
165
+ }
166
+ // The per-client bucket: 10 jobs an hour unless set, none when `false`.
167
+ function clientRate(raw) {
168
+ if (raw === false)
169
+ return {};
170
+ const { jobs, per } = raw ?? { jobs: 10, per: "1h" };
171
+ return { rate: { jobs, seconds: seconds(per) } };
172
+ }
173
+ // The file’s settings; an absent file is the built-in defaults, one policy admitting every host.
174
+ export function readSettings(path) {
175
+ const isPresent = existsSync(path);
176
+ log.info({ path, isPresent }, "server settings read");
177
+ return settingsOf(isPresent ? parseYaml(readFileSync(path, "utf8")) : {});
178
+ }
179
+ // Settings that follow the file: a valid edit replaces them, an invalid one is logged and ignored.
180
+ export function watchSettings(path) {
181
+ let settings = readSettings(path);
182
+ watchFile(path, { interval: WATCH_MS }, (now, before) => {
183
+ log.debug({ path, modified: now.mtimeMs, previous: before.mtimeMs }, "server settings changed on disk");
184
+ try {
185
+ const next = readSettings(path);
186
+ const restart = ["redis", "redisPasswordFile", "listen", "workers"].filter((key) => JSON.stringify(next[key]) !== JSON.stringify(settings[key]));
187
+ if (restart.length > 0)
188
+ log.warn({ keys: restart }, "server settings need a restart to take effect");
189
+ settings = next;
190
+ log.info({ path, policies: next.policies.map((policy) => policy.name) }, "server settings reloaded");
191
+ }
192
+ catch (error) {
193
+ log.error({ path, error: error instanceof Error ? error.message : String(error) }, "server settings rejected; previous kept");
194
+ }
195
+ });
196
+ return { current: () => settings, close: () => unwatchFile(path) };
197
+ }
@@ -0,0 +1,353 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { createHash } from "node:crypto";
5
+ import { readFileSync } from "node:fs";
6
+ import { domainToUnicode } from "node:url";
7
+ import { Hono } from "hono";
8
+ import { bodyLimit } from "hono/body-limit";
9
+ import { routePath } from "hono/route";
10
+ import { LICENSE } from "../agent.js";
11
+ import { negotiate, readerLocale, translator } from "../i18n.js";
12
+ import { log } from "../logger.js";
13
+ import { formatNames } from "../plugins/index.js";
14
+ import { escape, page, reportBody, STYLE } from "../report/html.js";
15
+ import { track } from "./analytics.js";
16
+ import { compress } from "./compress.js";
17
+ import { jobOf, submit } from "./jobs.js";
18
+ import { asset } from "./page.js";
19
+ import { presetSettings, presetsOffered, Refusal } from "./policy.js";
20
+ import { latestKey } from "./queue.js";
21
+ const BODY_MAX = 64 * 1024;
22
+ const ASSET_MAX_AGE_S = 3600;
23
+ const REFRESH_S = 5;
24
+ const BADGE_MAX_AGE_S = 300;
25
+ const LINKS = [
26
+ ["dbuho.me", "https://dbuho.me/project/spiderlint/"],
27
+ ["Kiota", "https://kiota.ch/damian-buho/spiderlint"],
28
+ ["GitHub", "https://github.com/damian-buho/spiderlint"],
29
+ ["Codeberg", "https://codeberg.org/damian-buho/spiderlint"],
30
+ ];
31
+ // Resolves from src/ and dist/ alike, since the package ships both.
32
+ const LOGO = readFileSync(new URL("../../src/server/logo.png", import.meta.url));
33
+ const TOUCH = readFileSync(new URL("../../src/server/apple-touch-icon.png", import.meta.url));
34
+ const LOGO_36 = readFileSync(new URL("../../src/server/logo-36.png", import.meta.url));
35
+ const LOGO_72 = readFileSync(new URL("../../src/server/logo-72.png", import.meta.url));
36
+ const ICONS = '<link rel="icon" type="image/png" sizes="192x192" href="/logo.png"><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">';
37
+ const GRADE_COLOR = { S: "#1e7a34", A: "#1e7a34", B: "#8a6d00", C: "#b45d00", D: "#b45d00", E: "#b3261e", F: "#b3261e", none: "#6b6b75" };
38
+ // Progress over server-sent events, for a browser that runs scripts; without them the page refreshes itself.
39
+ const SCRIPT = String.raw `
40
+ const status = document.querySelector("[data-events]");
41
+ if (status && "EventSource" in window) {
42
+ const lang = status.dataset.locale;
43
+ const numbers = new Intl.NumberFormat(lang);
44
+ const phases = JSON.parse(status.dataset.phases);
45
+ const fill = (template, values) => template.replace(/\{(\w+)\}/g, (match, key) => values[key] ?? match);
46
+ const left = (eta) => {
47
+ const units = new Intl.NumberFormat(lang, { style: "unit", unit: eta.unit });
48
+ if (eta.low === 0) return fill(status.dataset.under, { range: units.format(eta.high) });
49
+ return fill(status.dataset.eta, { range: eta.low === eta.high ? units.format(eta.high) : units.formatRange(eta.low, eta.high) });
50
+ };
51
+ const events = new EventSource(status.dataset.events);
52
+ events.addEventListener("progress", (event) => {
53
+ const job = JSON.parse(event.data);
54
+ if (job.status === "running") status.querySelector("[data-state]").textContent = status.dataset.running;
55
+ if (!job.progress) return;
56
+ const { done, total, eta, phase, step } = job.progress;
57
+ Object.assign(status.querySelector("progress"), { max: total, value: done });
58
+ status.querySelector("[data-count]").textContent = fill(status.dataset.count, { done: numbers.format(done), total: numbers.format(total) });
59
+ status.querySelector("[data-eta]").textContent = eta ? left(eta) : "";
60
+ status.querySelector("[data-phase]").textContent = phase === "crawl" || !phases[phase] ? "" : step ? fill(status.dataset.step, { phase: phases[phase], done: numbers.format(step.done), total: numbers.format(step.total) }) : phases[phase];
61
+ });
62
+ for (const name of ["done", "failed", "expired"]) events.addEventListener(name, () => { events.close(); location.reload(); });
63
+ }
64
+ `;
65
+ const hash = (source) => `'sha256-${createHash("sha256").update(source).digest("base64")}'`;
66
+ // The policy of a page: its own stylesheet and script by hash, what the owner’s fragments ask for, same-origin images, events and forms, nothing else; `self` joins scripts and styles only while the owner serves assets.
67
+ export function pageCsp(sources, hasAssets = false) {
68
+ const list = (own, asked) => [...new Set([own, ...(hasAssets ? ["'self'"] : []), ...asked])].join(" ");
69
+ return `default-src 'none'; style-src ${list(hash(STYLE), sources?.style ?? [])}; script-src ${list(hash(SCRIPT), sources?.script ?? [])}; img-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests`;
70
+ }
71
+ // The policy of every page without fragments.
72
+ export const PAGE_CSP = pageCsp();
73
+ // A refusal in the reader’s words; `code` picks the sentence, the English message stays for logs.
74
+ function refusalText(t, refusal) {
75
+ const when = refusal.retryAfter === undefined ? "" : relative(t, refusal.retryAfter);
76
+ const texts = {
77
+ "invalid-url": t._("Enter a web address, such as example.com."),
78
+ "invalid-body": t._("The form could not be read. Try again."),
79
+ "cross-site": t._("The form was sent from another site. Open this page and try again."),
80
+ banned: t._("Scans of this site are disabled on this instance."),
81
+ "no-policy": t._("This instance does not scan this site."),
82
+ "forbidden-rule": t._("A requested rule is not available on this instance."),
83
+ "unknown-rule": t._("A requested rule is not available on this instance."),
84
+ "forbidden-fetch": t._("The requested fetch mode is not available on this instance."),
85
+ "rate-limited": t._("This site was scanned recently. Try again {when}.", { when }),
86
+ "client-rate-limited": t._("You have started many scans. Try again {when}.", { when }),
87
+ "queue-full": t._("Too many scans are waiting. Try again {when}.", { when }),
88
+ "not-found": t._("This scan does not exist or has expired."),
89
+ };
90
+ return texts[refusal.code] ?? t._("Something went wrong. Try again later.");
91
+ }
92
+ // `seconds` from now as the reader says it: “in 5 minutes”.
93
+ function relative(t, seconds) {
94
+ const format = new Intl.RelativeTimeFormat(t.lang, { numeric: "auto" });
95
+ if (seconds < 90)
96
+ return format.format(seconds, "second");
97
+ return seconds < 90 * 60 ? format.format(Math.round(seconds / 60), "minute") : format.format(Math.round(seconds / 3600), "hour");
98
+ }
99
+ // The seed as a reader writes it: an international domain in its own script.
100
+ function shown(url) {
101
+ const parsed = new URL(url);
102
+ return `${parsed.protocol}//${domainToUnicode(parsed.hostname) || parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}${decodeURI(parsed.pathname)}${parsed.search}`;
103
+ }
104
+ // A domain or a URL as typed, as the http(s) URL to scan; a bare domain is https.
105
+ function seedOf(raw) {
106
+ const typed = typeof raw === "string" ? raw.trim() : "";
107
+ return typed === "" || /^[a-z][\d+.a-z-]*:\/\//i.test(typed) ? typed : `https://${typed}`;
108
+ }
109
+ // The page in the reader’s language with the owner’s fragments, never cached, under the page policy they extend; the view is reported to Matomo when one is set.
110
+ function respond(c, t, title, body, status = 200, head = "") {
111
+ const { page: owner, matomo } = c.get("settings");
112
+ const slots = ["head", "header", "footer"].map((slot) => owner.fragments?.slot(slot, t.lang) ?? { html: "", sources: { script: [], style: [] } });
113
+ const [fromHead, fromHeader, fromFooter] = slots;
114
+ const sources = { script: slots.flatMap((slot) => slot.sources.script), style: slots.flatMap((slot) => slot.sources.style) };
115
+ const privacy = matomo ? `<a href="${escape(matomo.privacy)}" rel="noopener noreferrer">${escape(t._("Privacy"))}</a>` : "";
116
+ const origin = new URL(c.req.url).origin;
117
+ const self = new URL(c.req.path, origin).href;
118
+ const description = t._("Check every page of a site: SEO tags, security headers, TLS, links and more.");
119
+ const seo = `<meta name="description" content="${escape(description)}"><link rel="canonical" href="${escape(self)}"><meta property="og:title" content="${escape(title)}"><meta property="og:description" content="${escape(description)}"><meta property="og:image" content="${escape(`${origin}/logo.png`)}"><meta property="og:url" content="${escape(self)}"><meta property="og:type" content="website">`;
120
+ const skip = `<a class="skip" href="#main">${escape(t._("Skip to main content"))}</a>`;
121
+ const nav = `<header class="site"><a class="brand" href="/"><img src="/logo.png" srcset="/logo-36.png 36w, /logo-72.png 72w" sizes="36px" alt="" width="36" height="36">spiderlint</a><nav><a href="/">${escape(t._("New scan"))}</a><a href="${LINKS[0]?.[1]}" rel="noopener noreferrer">${escape(t._("Self-host"))}</a></nav></header>`;
122
+ const links = LINKS.map(([name, href]) => `<a href="${escape(href)}" rel="noopener noreferrer">${escape(name)}</a>`).join("");
123
+ const foot = `<footer class="foot"><p>${escape(t._("Self-host spiderlint: free software under the {license} licence.", { license: LICENSE }))}</p>${links}${privacy}</footer>`;
124
+ c.header("content-security-policy", pageCsp(sources, owner.assets !== undefined));
125
+ c.header("cache-control", "no-cache");
126
+ c.header("vary", "accept-language");
127
+ track(matomo, c, routePath(c), URL.canParse(title) ? new URL(title).hostname : undefined);
128
+ return c.html(page(t, title, `${skip}${fromHeader.html}${nav}${body}${foot}${fromFooter.html}`, ICONS + seo + head + fromHead.html), status);
129
+ }
130
+ // What each web preset is called and says, in the reader’s language.
131
+ function presetText(t) {
132
+ return {
133
+ recommended: [t._("Standard"), t._("The recommended checks")],
134
+ "web-quick": [t._("Quick"), t._("The basics over HTTP, on at most 25 pages")],
135
+ "web-comprehensive": [t._("Comprehensive"), t._("The recommended checks plus link graph, structured data, manifest, trackers and more")],
136
+ };
137
+ }
138
+ // The presets as a radio group, `chosen` or else the first checked; nothing when no policy offers one.
139
+ function presetChoice(t, offered, chosen) {
140
+ if (offered.length === 0)
141
+ return "";
142
+ const checked = chosen !== undefined && offered.includes(chosen) ? chosen : offered[0];
143
+ const text = presetText(t);
144
+ const options = offered.map((name) => `<label for="preset-${escape(name)}"><input id="preset-${escape(name)}" type="radio" name="preset" value="${escape(name)}"${name === checked ? " checked" : ""}> ${escape(text[name]?.[0] ?? name)} <small class="muted">${escape(text[name]?.[1] ?? "")}</small></label>`);
145
+ return `<fieldset><legend class="muted">${escape(t._("Checks"))}</legend>${options.join("")}</fieldset>`;
146
+ }
147
+ // The form, with the address typed, the preset chosen and the refusal it met, if any.
148
+ function formPage(c, t, offered, typed = "", refusal, chosen) {
149
+ if (refusal?.retryAfter !== undefined)
150
+ c.header("retry-after", String(refusal.retryAfter));
151
+ const alert = refusal ? `<p class="alert" role="alert">${escape(refusalText(t, refusal))}${refusal.code === "banned" ? `<br><small>${escape(refusal.message)}</small>` : ""}</p>` : "";
152
+ const body = `<main id="main"><h1>spiderlint</h1><p>${escape(t._("Checks every page of a site: search engine tags, security headers, TLS, links and more."))}</p>${alert}<form method="post" action="/"><label for="url" class="muted">${escape(t._("Site address"))}</label><input id="url" name="url" type="text" inputmode="url" autocomplete="url" required spellcheck="false" placeholder="example.com" value="${escape(typed)}"${refusal ? ' aria-invalid="true"' : ""}><button type="submit">${escape(t._("Scan"))}</button>${presetChoice(t, offered, chosen)}</form><p class="muted"><small>${escape(t._("Scans obey robots.txt and identify themselves as spiderlint. A finished report is public: its link and the site’s badge lead to it."))}</small></p></main>`;
153
+ return respond(c, t, t._("spiderlint — scan every page of a website"), body, refusal?.status ?? 200);
154
+ }
155
+ // The time left as the reader writes it: “about 2–4 min”, “less than 3 min” while the low end is zero.
156
+ function etaLine(t, eta) {
157
+ if (!eta)
158
+ return "";
159
+ const units = new Intl.NumberFormat(t.locale, { style: "unit", unit: eta.unit });
160
+ if (eta.low === 0)
161
+ return t._("Less than {range} left", { range: units.format(eta.high) });
162
+ return t._("About {range} left", { range: eta.low === eta.high ? units.format(eta.high) : units.formatRange(eta.low, eta.high) });
163
+ }
164
+ // What each phase after the crawl is called, in the reader’s language.
165
+ function phaseNames(t) {
166
+ return { resources: t._("Checking linked resources"), probes: t._("Checking external links"), site: t._("Checking domain, mail and certificates"), lint: t._("Evaluating the rules") };
167
+ }
168
+ // Where a queued or running scan stands, updated by events or, without scripts, by a refresh.
169
+ function progressBody(t, job, status) {
170
+ const { done, total, eta, phase, step } = (typeof job.progress === "object" ? job.progress : {});
171
+ const count = t._("Pages: {done} of {total}", { done: "{done}", total: "{total}" });
172
+ const stepText = t._("{phase}: {done} of {total}", { phase: "{phase}", done: "{done}", total: "{total}" });
173
+ const names = phaseNames(t);
174
+ const named = phase && names[phase];
175
+ const phaseText = named ? (step ? t._("{phase}: {done} of {total}", { phase: named, done: t.number(step.done), total: t.number(step.total) }) : named) : "";
176
+ const state = status === "running" ? t._("Scanning…") : t._("Waiting in the queue…");
177
+ const values = total === undefined ? "" : ` max="${total}" value="${done ?? 0}"`;
178
+ const attributes = [
179
+ ["events", `/v1/jobs/${job.id}/events`],
180
+ ["locale", t.locale],
181
+ ["running", t._("Scanning…")],
182
+ ["count", count],
183
+ ["eta", t._("About {range} left", { range: "{range}" })],
184
+ ["under", t._("Less than {range} left", { range: "{range}" })],
185
+ ["step", stepText],
186
+ ["phases", JSON.stringify(names)],
187
+ ]
188
+ .map(([name, value]) => `data-${name}="${escape(value)}"`)
189
+ .join(" ");
190
+ const rulesets = job.data.settings?.rules?.join(", ");
191
+ return `<div ${attributes}><p data-state>${escape(state)}</p>${rulesets ? `<p class="muted">${escape(t._("Rulesets: {names}", { names: rulesets }))}</p>` : ""}<progress${values}></progress><p><span data-count>${total === undefined ? "" : escape(t._("Pages: {done} of {total}", { done: t.number(done ?? 0), total: t.number(total) }))}</span> <span data-eta class="muted">${escape(etaLine(t, eta))}</span></p><p data-phase class="muted">${escape(phaseText)}</p></div>`;
192
+ }
193
+ // When a fresh scan of the site may start, and the way to it; `isRepeat` adds why the reader sees an earlier scan.
194
+ function scanNote(t, job, wait, isRepeat) {
195
+ const lead = isRepeat ? `${t._("This site was scanned recently, so this is the existing report.")} ` : "";
196
+ const next = wait > 0 ? t._("A new scan of this site can start {when}.", { when: relative(t, wait) }) : t._("You can scan this site again now.");
197
+ const action = wait > 0 ? "" : `<a class="button" href="/?url=${encodeURIComponent(job.data.host)}">${escape(t._("Scan again"))}</a>`;
198
+ return `<div class="notice" role="status"><p>${escape(lead + next)}</p>${action}</div>`;
199
+ }
200
+ // The report with its scan note, downloads and badge.
201
+ function doneBody(t, job, wait, isRepeat) {
202
+ const downloads = formatNames()
203
+ .map((name) => `<a href="/v1/jobs/${escape(job.id)}/report/${escape(name)}">${escape(name)}</a>`)
204
+ .join(" · ");
205
+ const badge = `/badge/${escape(job.data.host)}.svg`;
206
+ return `${scanNote(t, job, wait, isRepeat)}${reportBody(job.returnvalue, t, shown(job.data.url), new URL(job.data.url).origin)}<h2>${escape(t._("Downloads"))}</h2><p>${downloads}</p><h2>${escape(t._("Badge"))}</h2><p><a href="${badge}"><img src="${badge}" alt="${escape(t._("Rating badge"))}"></a></p>`;
207
+ }
208
+ // Pixels a badge half needs for `text` in 11px Verdana, roughly.
209
+ function width(text) {
210
+ return 12 + [...text].length * 7;
211
+ }
212
+ // The reader’s strings from the request’s Accept-Language, and its numbers in the first locale it names.
213
+ function translate(c) {
214
+ const accept = c.req.header("accept-language");
215
+ return translator(negotiate(accept), readerLocale(accept));
216
+ }
217
+ // The form fields, or none when the body cannot be read.
218
+ async function formOf(c) {
219
+ try {
220
+ return await c.req.parseBody();
221
+ }
222
+ catch (error) {
223
+ log.debug({ error: String(error) }, "form not parsed");
224
+ return {};
225
+ }
226
+ }
227
+ // An SVG badge of `value` beside the tool name, linking to `href` when opened on its own.
228
+ function badge(value, color, href) {
229
+ const label = "spiderlint";
230
+ const [left, right] = [width(label), width(value)];
231
+ const text = `<rect width="${left}" height="20" fill="#3c3c44"/><rect x="${left}" width="${right}" height="20" fill="${color}"/><g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"><text x="${left / 2}" y="14">${label}</text><text x="${left + right / 2}" y="14">${escape(value)}</text></g>`;
232
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${left + right}" height="20" role="img" aria-label="${escape(`${label}: ${value}`)}"><title>${escape(`${label}: ${value}`)}</title>${href ? `<a href="${escape(href)}" target="_top">${text}</a>` : text}</svg>`;
233
+ }
234
+ // `bytes` as an image answer with a shared cache lifetime.
235
+ function image(bytes, type = "image/png") {
236
+ return (c) => {
237
+ c.header("content-type", type);
238
+ c.header("cache-control", `public, max-age=${ASSET_MAX_AGE_S}`);
239
+ return c.body(new Uint8Array(bytes));
240
+ };
241
+ }
242
+ // The form, the job pages and the badge, rendered on the server and complete without scripts.
243
+ export function web(jobs) {
244
+ const app = new Hono();
245
+ // The settings as they are for this request, for the pages’ fragments and Matomo.
246
+ app.use(async (c, next) => {
247
+ c.set("settings", jobs.settings());
248
+ await next();
249
+ });
250
+ // The owner’s files, served as they are from one directory: never a listing, a dotfile or a path out of it.
251
+ app.get("/assets/*", async (c) => {
252
+ const { assets } = jobs.settings().page;
253
+ let relative = "";
254
+ try {
255
+ relative = decodeURIComponent(c.req.path.slice("/assets/".length));
256
+ }
257
+ catch {
258
+ log.debug({ path: c.req.path }, "asset path not decodable");
259
+ }
260
+ const file = assets && relative ? await asset(assets, relative) : undefined;
261
+ log.debug({ path: c.req.path, isServed: file !== undefined }, "asset requested");
262
+ if (!file)
263
+ throw new Refusal(404, "not-found", `${c.req.path}: no such asset`);
264
+ c.header("content-type", file.type);
265
+ c.header("content-security-policy", "default-src 'none'; sandbox");
266
+ c.header("cache-control", `public, max-age=${ASSET_MAX_AGE_S}`);
267
+ return c.body(new Uint8Array(file.body));
268
+ });
269
+ const offered = () => presetsOffered(jobs.settings());
270
+ // Text answers leave compressed when the client asks for gzip.
271
+ app.use(compress());
272
+ app.get("/logo.png", (c) => {
273
+ log.debug({ bytes: LOGO.length }, "logo served");
274
+ return image(LOGO)(c);
275
+ });
276
+ app.get("/favicon.ico", (c) => {
277
+ log.debug({ bytes: LOGO.length }, "favicon served");
278
+ return image(LOGO)(c);
279
+ });
280
+ app.get("/apple-touch-icon.png", (c) => {
281
+ log.debug({ bytes: TOUCH.length }, "apple touch icon served");
282
+ return image(TOUCH)(c);
283
+ });
284
+ app.get("/logo-36.png", (c) => image(LOGO_36)(c));
285
+ app.get("/logo-72.png", (c) => image(LOGO_72)(c));
286
+ // RFC 9116 contact point with a rolling half-year expiry, so the date never goes stale.
287
+ app.get("/.well-known/security.txt", (c) => {
288
+ const expires = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
289
+ log.debug({ expires }, "security.txt served");
290
+ c.header("content-type", "text/plain; charset=utf-8");
291
+ c.header("cache-control", "public, max-age=86400");
292
+ return c.body(`Contact: mailto:damian.buho@proton.me\nExpires: ${expires}\nPreferred-Languages: en\n`);
293
+ });
294
+ app.get("/", (c) => formPage(c, translate(c), offered(), c.req.query("url") ?? ""));
295
+ app.post("/", bodyLimit({ maxSize: BODY_MAX, onError: (c) => formPage(c, translate(c), offered(), "", new Refusal(400, "invalid-body", "form too large")) }), async (c) => {
296
+ const t = translate(c);
297
+ const form = await formOf(c);
298
+ const typed = typeof form.url === "string" ? form.url : "";
299
+ const preset = typeof form.preset === "string" ? form.preset : undefined;
300
+ try {
301
+ if (c.req.header("sec-fetch-site") === "cross-site")
302
+ throw new Refusal(403, "cross-site", "form sent from another site");
303
+ const { job, isRepeat } = await submit(jobs, { url: seedOf(typed), ...(preset !== undefined && { settings: presetSettings(preset) }) }, c);
304
+ log.debug({ job: job.id, isRepeat, preset }, "form submitted");
305
+ return c.redirect(isRepeat ? `/jobs/${job.id}?repeat` : `/jobs/${job.id}`, 303);
306
+ }
307
+ catch (error) {
308
+ if (!(error instanceof Refusal))
309
+ throw error;
310
+ log.info({ code: error.code, status: error.status, preset }, "form refused");
311
+ return formPage(c, t, offered(), typed, error, preset);
312
+ }
313
+ });
314
+ app.get("/jobs/:id", async (c) => {
315
+ const t = translate(c);
316
+ let job;
317
+ try {
318
+ job = await jobOf(jobs.queue, c.req.param("id"));
319
+ }
320
+ catch (error) {
321
+ if (!(error instanceof Refusal))
322
+ throw error;
323
+ return respond(c, t, t._("Scan not found"), `<main id="main"><h1>${escape(t._("Scan not found"))}</h1><p>${escape(refusalText(t, error))}</p><p><a href="/">${escape(t._("Start a new scan"))}</a></p></main>`, 404);
324
+ }
325
+ const state = await job.getState();
326
+ const title = shown(job.data.url);
327
+ const isRepeat = c.req.query("repeat") !== undefined;
328
+ const wait = job.data.repeatKey ? await jobs.redis.ttl(job.data.repeatKey) : 0;
329
+ log.debug({ job: job.id, state, isRepeat, wait }, "job page rendered");
330
+ if (state === "completed")
331
+ return respond(c, t, title, `<main id="main">${doneBody(t, job, wait, isRepeat)}</main>`);
332
+ if (state === "failed")
333
+ return respond(c, t, title, `<main id="main"><h1>${escape(title)}</h1><p class="alert" role="alert">${escape(t._("The scan failed."))}</p><p><code>${escape(job.failedReason)}</code></p><p><a href="/">${escape(t._("Start a new scan"))}</a></p></main>`);
334
+ const body = `<main id="main"><h1>${escape(title)}</h1>${isRepeat ? `<p class="notice">${escape(t._("This site was scanned recently, so this is the existing scan."))}</p>` : ""}${progressBody(t, job, state === "active" ? "running" : "queued")}</main><script>${SCRIPT}</script>`;
335
+ return respond(c, t, title, body, 200, `<noscript><meta http-equiv="refresh" content="${REFRESH_S}"></noscript>`);
336
+ });
337
+ app.get("/badge/:file", async (c) => {
338
+ const file = c.req.param("file");
339
+ const raw = file.endsWith(".svg") ? file.slice(0, -".svg".length).toLowerCase().replace(/\.$/, "") : "";
340
+ const host = raw && URL.canParse(`http://${raw}/`) ? new URL(`http://${raw}/`).hostname : "";
341
+ const id = host ? await jobs.redis.get(latestKey(host)) : undefined;
342
+ const job = id ? (await jobs.queue.getJob(id)) : undefined;
343
+ const grade = job?.returnvalue?.summary.rating?.grade;
344
+ log.debug({ host, job: id, grade }, "badge served");
345
+ c.header("content-type", "image/svg+xml; charset=utf-8");
346
+ c.header("content-security-policy", "default-src 'none'");
347
+ c.header("cache-control", `max-age=${BADGE_MAX_AGE_S}`);
348
+ c.header("vary", "accept-language");
349
+ const value = job ? (grade ?? "–") : translate(c)._("not scanned");
350
+ return c.body(badge(value, GRADE_COLOR[grade ?? "none"], job ? `/jobs/${job.id}` : undefined));
351
+ });
352
+ return app;
353
+ }