@coywolf/emdash 0.0.0-stage → 0.35.13
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/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link check results, ignore rules and the recheck schedule. Pure functions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type LinkStatus = "unchecked" | "ok" | "redirect" | "broken" | "blocked" | "error";
|
|
6
|
+
|
|
7
|
+
export const STATUS_LABELS: Record<LinkStatus, string> = {
|
|
8
|
+
unchecked: "Not checked",
|
|
9
|
+
ok: "OK",
|
|
10
|
+
redirect: "Redirect",
|
|
11
|
+
broken: "Broken",
|
|
12
|
+
blocked: "Blocked",
|
|
13
|
+
error: "Error",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface Hop {
|
|
17
|
+
url: string;
|
|
18
|
+
code: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CheckOutcome {
|
|
22
|
+
status: LinkStatus;
|
|
23
|
+
/** Final HTTP status (0 when there was no response). */
|
|
24
|
+
code: number;
|
|
25
|
+
/** Where the redirect chain ended, when it changed the URL. */
|
|
26
|
+
finalUrl: string | null;
|
|
27
|
+
chain: Hop[];
|
|
28
|
+
/** Short reason shown in the admin. */
|
|
29
|
+
note: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Status codes that mean "a bot wall answered", not "the page is gone". */
|
|
33
|
+
const BLOCKED_CODES = new Set([403, 429, 999]);
|
|
34
|
+
|
|
35
|
+
/** Response headers that identify a bot challenge (Cloudflare, AWS WAF, DataDome, PerimeterX, Akamai). */
|
|
36
|
+
export function isBotWall(code: number, headers: Headers | Record<string, string | undefined>): boolean {
|
|
37
|
+
if (BLOCKED_CODES.has(code)) return true;
|
|
38
|
+
const get = (name: string) => (headers instanceof Headers ? headers.get(name) : headers[name]) ?? "";
|
|
39
|
+
if (/challenge/i.test(get("cf-mitigated"))) return true;
|
|
40
|
+
if (get("x-amzn-waf-action") || get("x-datadome") || get("x-px-block")) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Same URL apart from a trailing slash. */
|
|
45
|
+
function sameUrl(a: string, b: string): boolean {
|
|
46
|
+
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Classify a finished request. `chain` lists every response, the last one
|
|
51
|
+
* being final. `blocked` is whether the final response looked like a bot wall.
|
|
52
|
+
*/
|
|
53
|
+
export function classify(chain: Hop[], blocked: boolean, error?: string): CheckOutcome {
|
|
54
|
+
const last = chain[chain.length - 1];
|
|
55
|
+
if (!last || error) {
|
|
56
|
+
return { status: "error", code: last?.code ?? 0, finalUrl: null, chain, note: error ?? "No response" };
|
|
57
|
+
}
|
|
58
|
+
const first = chain[0];
|
|
59
|
+
const redirected = chain.length > 1 && !sameUrl(first.url, last.url);
|
|
60
|
+
const finalUrl = redirected ? last.url : null;
|
|
61
|
+
if (blocked) {
|
|
62
|
+
return {
|
|
63
|
+
status: "blocked",
|
|
64
|
+
code: last.code,
|
|
65
|
+
finalUrl,
|
|
66
|
+
chain,
|
|
67
|
+
note: `Blocked by the destination (HTTP ${last.code}). The link is likely fine but can't be verified from a server.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (last.code >= 400) {
|
|
71
|
+
return {
|
|
72
|
+
status: "broken",
|
|
73
|
+
code: last.code,
|
|
74
|
+
finalUrl,
|
|
75
|
+
chain,
|
|
76
|
+
note: redirected ? `Redirects (${first.code}) to a page that returns ${last.code}` : `HTTP ${last.code}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (last.code >= 300) {
|
|
80
|
+
return { status: "error", code: last.code, finalUrl, chain, note: "Redirect without a usable Location" };
|
|
81
|
+
}
|
|
82
|
+
if (redirected) {
|
|
83
|
+
return { status: "redirect", code: last.code, finalUrl, chain, note: `${first.code} → ${last.url}` };
|
|
84
|
+
}
|
|
85
|
+
return { status: "ok", code: last.code, finalUrl: null, chain, note: `HTTP ${last.code}` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DAY = 86_400_000;
|
|
89
|
+
|
|
90
|
+
/** When to check again: problems daily, everything else weekly. */
|
|
91
|
+
export function nextCheckAt(status: LinkStatus, now: number): string {
|
|
92
|
+
const delay = status === "broken" || status === "error" ? DAY : 7 * DAY;
|
|
93
|
+
return new Date(now + delay).toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Ignore rules ─────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export type IgnoreType = "domain" | "url" | "wildcard" | "regex";
|
|
99
|
+
|
|
100
|
+
export interface IgnoreRule {
|
|
101
|
+
id: string;
|
|
102
|
+
type: IgnoreType;
|
|
103
|
+
value: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class IgnoreRuleError extends Error {}
|
|
107
|
+
|
|
108
|
+
/** Normalize and validate a rule's value. Throws IgnoreRuleError. */
|
|
109
|
+
export function normalizeIgnore(type: IgnoreType, raw: string): string {
|
|
110
|
+
const value = raw.trim();
|
|
111
|
+
if (!value) throw new IgnoreRuleError("Enter a value.");
|
|
112
|
+
if (value.length > 1024) throw new IgnoreRuleError("That rule is too long.");
|
|
113
|
+
switch (type) {
|
|
114
|
+
case "domain": {
|
|
115
|
+
const host = value.includes("://") ? safeHost(value) : value.replace(/\/.*$/, "");
|
|
116
|
+
const clean = host.toLowerCase().replace(/^\*\./, "").replace(/^www\./, "");
|
|
117
|
+
if (!/^[a-z0-9.-]+\.[a-z0-9-]+$|^localhost$/.test(clean)) throw new IgnoreRuleError(`"${value}" isn't a domain.`);
|
|
118
|
+
return clean;
|
|
119
|
+
}
|
|
120
|
+
case "url":
|
|
121
|
+
return normalizeUrlForMatch(value) || value;
|
|
122
|
+
case "wildcard":
|
|
123
|
+
return value.replace(/\*+/g, "*");
|
|
124
|
+
case "regex":
|
|
125
|
+
if (value.length > 200) throw new IgnoreRuleError("Keep regular expressions under 200 characters, or use a wildcard rule.");
|
|
126
|
+
// Repeated groups holding a quantifier or alternation, like (a+)+ or (a|ab)*, and backreferences can take exponential time.
|
|
127
|
+
if (/\((?:[^()\\]|\\.)*[+*}|](?:[^()\\]|\\.)*\)\s*[+*{]|\\[1-9]/.test(value)) {
|
|
128
|
+
throw new IgnoreRuleError("That regular expression could be very slow (nested repetition or a backreference). Simplify it or use a wildcard rule.");
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
new RegExp(value, "i");
|
|
132
|
+
} catch {
|
|
133
|
+
throw new IgnoreRuleError("That regular expression isn't valid.");
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function safeHost(value: string): string {
|
|
140
|
+
try {
|
|
141
|
+
return new URL(value).hostname;
|
|
142
|
+
} catch {
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** scheme://host/path?query, lowercased scheme and host, no trailing slash, no fragment. */
|
|
148
|
+
export function normalizeUrlForMatch(url: string): string {
|
|
149
|
+
try {
|
|
150
|
+
const u = new URL(url);
|
|
151
|
+
return `${u.protocol}//${u.hostname.toLowerCase()}${u.port ? `:${u.port}` : ""}${u.pathname.replace(/\/+$/, "")}${u.search}`;
|
|
152
|
+
} catch {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const regexCache = new Map<string, RegExp | null>();
|
|
158
|
+
function compiled(pattern: string, wildcard: boolean): RegExp | null {
|
|
159
|
+
const key = `${wildcard ? "w" : "r"}:${pattern}`;
|
|
160
|
+
if (!regexCache.has(key)) {
|
|
161
|
+
let re: RegExp | null = null;
|
|
162
|
+
try {
|
|
163
|
+
re = wildcard
|
|
164
|
+
? new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i")
|
|
165
|
+
: new RegExp(pattern, "i");
|
|
166
|
+
} catch {
|
|
167
|
+
re = null;
|
|
168
|
+
}
|
|
169
|
+
if (regexCache.size > 500) regexCache.clear();
|
|
170
|
+
regexCache.set(key, re);
|
|
171
|
+
}
|
|
172
|
+
return regexCache.get(key) ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Whether a rule matches an absolute URL. */
|
|
176
|
+
export function ruleMatches(rule: IgnoreRule, absoluteUrl: string): boolean {
|
|
177
|
+
switch (rule.type) {
|
|
178
|
+
case "domain": {
|
|
179
|
+
const host = safeHost(absoluteUrl).toLowerCase().replace(/^www\./, "");
|
|
180
|
+
return host === rule.value || host.endsWith(`.${rule.value}`);
|
|
181
|
+
}
|
|
182
|
+
case "url":
|
|
183
|
+
return normalizeUrlForMatch(absoluteUrl).toLowerCase() === rule.value.toLowerCase();
|
|
184
|
+
case "wildcard":
|
|
185
|
+
return absoluteUrl.length <= 2048 && (compiled(rule.value, true)?.test(absoluteUrl) ?? false);
|
|
186
|
+
case "regex":
|
|
187
|
+
// Stored rules were vetted by normalizeIgnore; still refuse anything that no longer passes.
|
|
188
|
+
return absoluteUrl.length <= 2048 && rule.value.length <= 200 && (compiled(rule.value, false)?.test(absoluteUrl) ?? false);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isIgnored(rules: IgnoreRule[], absoluteUrl: string): boolean {
|
|
193
|
+
return rules.some((rule) => ruleMatches(rule, absoluteUrl));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Safety ───────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/** Host names that only resolve on a local network (or to this machine). */
|
|
199
|
+
const LOCAL_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".lan", ".intranet", ".corp", ".private"];
|
|
200
|
+
const LOCAL_NAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback", "broadcasthost"]);
|
|
201
|
+
|
|
202
|
+
/** Whether a dotted IPv4 address (as the URL parser writes it) is public. */
|
|
203
|
+
function isPublicV4(a: number, b: number, c: number): boolean {
|
|
204
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
|
|
205
|
+
if (a === 169 && b === 254) return false;
|
|
206
|
+
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
207
|
+
if (a === 192 && b === 168) return false;
|
|
208
|
+
if (a === 192 && b === 0 && c === 0) return false; // IETF protocol assignments
|
|
209
|
+
if (a === 100 && b >= 64 && b <= 127) return false; // carrier-grade NAT
|
|
210
|
+
if (a === 198 && (b === 18 || b === 19)) return false; // benchmarking
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The eight 16-bit groups of an IPv6 address, or null when it isn't one. */
|
|
215
|
+
function ipv6Groups(host: string): number[] | null {
|
|
216
|
+
let text = host;
|
|
217
|
+
// A trailing dotted IPv4 part ("::ffff:1.2.3.4") becomes two groups.
|
|
218
|
+
const v4 = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
|
|
219
|
+
if (v4) {
|
|
220
|
+
const n = v4.slice(1).map(Number);
|
|
221
|
+
text = `${text.slice(0, v4.index)}${((n[0] << 8) | n[1]).toString(16)}:${((n[2] << 8) | n[3]).toString(16)}`;
|
|
222
|
+
}
|
|
223
|
+
const halves = text.split("::");
|
|
224
|
+
if (halves.length > 2) return null;
|
|
225
|
+
const part = (s: string) => (s ? s.split(":") : []);
|
|
226
|
+
const head = part(halves[0]);
|
|
227
|
+
const tail = halves.length === 2 ? part(halves[1]) : [];
|
|
228
|
+
const fill = 8 - head.length - tail.length;
|
|
229
|
+
if (halves.length === 2 ? fill < 1 : fill !== 0) return null;
|
|
230
|
+
const groups = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...tail];
|
|
231
|
+
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
|
|
232
|
+
return groups.map((g) => Number.parseInt(g, 16));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Whether an IPv6 address is public: not loopback, unspecified, private, link- or site-local, multicast, or a wrapper around an IPv4 address. */
|
|
236
|
+
function isPublicV6(host: string): boolean {
|
|
237
|
+
const g = ipv6Groups(host);
|
|
238
|
+
if (!g) return false;
|
|
239
|
+
// ::/16 holds ::, ::1, IPv4-compatible (::a.b.c.d), IPv4-mapped (::ffff:a.b.c.d) and IPv4-translated (::ffff:0:a.b.c.d) addresses.
|
|
240
|
+
if (g[0] === 0) return false;
|
|
241
|
+
if ((g[0] & 0xfe00) === 0xfc00) return false; // unique local fc00::/7
|
|
242
|
+
if ((g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0) return false; // link-local, site-local
|
|
243
|
+
if ((g[0] & 0xff00) === 0xff00) return false; // multicast
|
|
244
|
+
if (g[0] === 0x64 && g[1] === 0xff9b) return false; // NAT64 (64:ff9b::/96 and 64:ff9b:1::/48) embeds an IPv4 address
|
|
245
|
+
if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return false; // discard-only 100::/64
|
|
246
|
+
if (g[0] === 0x2002) return false; // 6to4 embeds an IPv4 address
|
|
247
|
+
if (g[0] === 0x2001 && g[1] === 0) return false; // Teredo embeds an IPv4 address
|
|
248
|
+
if (g[0] === 0x2001 && g[1] === 0xdb8) return false; // documentation
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Refuse to request loopback, private, link-local or metadata addresses.
|
|
254
|
+
* Hosts come from a parsed URL, which already turns decimal, octal and hex
|
|
255
|
+
* IPv4 forms ("http://2130706433/", "http://0x7f.1/") into dotted ones and
|
|
256
|
+
* writes IPv6 in its short form. Names that resolve to private addresses
|
|
257
|
+
* aren't caught here (that needs DNS); Workers can't reach private networks
|
|
258
|
+
* anyway, so this is defense in depth.
|
|
259
|
+
*/
|
|
260
|
+
export function isPublicTarget(url: URL): boolean {
|
|
261
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
262
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
|
|
263
|
+
if (!host || LOCAL_NAMES.has(host) || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false;
|
|
264
|
+
if (host.includes(":")) return isPublicV6(host);
|
|
265
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
266
|
+
if (v4) return isPublicV4(Number(v4[1]), Number(v4[2]), Number(v4[3]));
|
|
267
|
+
// Anything numeric the URL parser didn't turn into an address, and single-label names ("intranet", "metadata").
|
|
268
|
+
if (/^[\d.]+$/.test(host) || !host.includes(".")) return false;
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link Manager CSV export: one row per link with its check result and the
|
|
3
|
+
* entries that use it, for a spreadsheet or a hand-off to whoever fixes
|
|
4
|
+
* links. Pure, so the admin page and the tests share it.
|
|
5
|
+
*/
|
|
6
|
+
import { type CsvCell, toCsv } from "../core/csv.js";
|
|
7
|
+
|
|
8
|
+
export interface ExportLink {
|
|
9
|
+
url: string;
|
|
10
|
+
resolved: string | null;
|
|
11
|
+
host: string;
|
|
12
|
+
internal: boolean;
|
|
13
|
+
ignored: boolean;
|
|
14
|
+
status: string;
|
|
15
|
+
code: number | null;
|
|
16
|
+
finalUrl: string | null;
|
|
17
|
+
note: string;
|
|
18
|
+
checkedAt: string | null;
|
|
19
|
+
usedIn: Array<{ collection: string; entryId: string; title: string; anchors?: string[]; count?: number }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const LINK_CSV_COLUMNS = [
|
|
23
|
+
"url",
|
|
24
|
+
"checked_url",
|
|
25
|
+
"status",
|
|
26
|
+
"http_code",
|
|
27
|
+
"final_url",
|
|
28
|
+
"note",
|
|
29
|
+
"host",
|
|
30
|
+
"internal",
|
|
31
|
+
"ignored",
|
|
32
|
+
"checked_at",
|
|
33
|
+
"entries",
|
|
34
|
+
"used_in",
|
|
35
|
+
"anchor_text",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/** Spreadsheet-safe CSV (cells that look like formulas are guarded). Entries are joined with " | ". */
|
|
39
|
+
export function linksToCsv(links: ExportLink[]): string {
|
|
40
|
+
const rows: CsvCell[][] = [[...LINK_CSV_COLUMNS]];
|
|
41
|
+
for (const l of links) {
|
|
42
|
+
rows.push([
|
|
43
|
+
l.url,
|
|
44
|
+
l.resolved ?? "",
|
|
45
|
+
l.status,
|
|
46
|
+
l.code ?? "",
|
|
47
|
+
l.finalUrl ?? "",
|
|
48
|
+
l.note,
|
|
49
|
+
l.host,
|
|
50
|
+
l.internal,
|
|
51
|
+
l.ignored,
|
|
52
|
+
l.checkedAt ?? "",
|
|
53
|
+
l.usedIn.length,
|
|
54
|
+
l.usedIn.map((u) => `${u.title || u.entryId} (${u.collection}/${u.entryId})`).join(" | "),
|
|
55
|
+
[...new Set(l.usedIn.flatMap((u) => u.anchors ?? []).filter(Boolean))].join(" | "),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
return toCsv(rows, { guard: true });
|
|
59
|
+
}
|