@fedify/webfinger 2.2.19 → 2.2.20

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.
@@ -0,0 +1,234 @@
1
+ import { getUserAgent, UrlError } from "@fedify/vocab-runtime";
2
+ import {
3
+ openDirectFetch,
4
+ openPublicFetch,
5
+ type PublicFetchSession,
6
+ } from "@fedify/vocab-runtime/internal";
7
+ import { getLogger } from "@logtape/logtape";
8
+ import { BodyTooLargeError, MAX_BODY_SIZE, readBoundedText } from "./body.ts";
9
+ import type { ResourceDescriptor } from "./jrd.ts";
10
+ import type { LookupWebFingerOptions } from "./lookup.ts";
11
+
12
+ const logger = getLogger(["fedify", "webfinger", "lookup"]);
13
+
14
+ const DEFAULT_MAX_REDIRECTION = 5;
15
+
16
+ /**
17
+ * The dependencies of {@link lookupWebFingerInternal}.
18
+ * @internal
19
+ */
20
+ export interface LookupWebFingerDependencies {
21
+ /**
22
+ * Opens a fetch session for a single hop. Defaults to `openDirectFetch()`
23
+ * if `allowPrivateAddress` is turned on, or `openPublicFetch()` otherwise.
24
+ */
25
+ readonly openHop?: (url: string) => Promise<PublicFetchSession>;
26
+ }
27
+
28
+ /**
29
+ * The outcome of a single hop: either a redirect to follow, or the final
30
+ * result of the lookup.
31
+ */
32
+ type HopResult =
33
+ | { readonly redirect: string }
34
+ | { readonly result: ResourceDescriptor | null };
35
+
36
+ /**
37
+ * Looks up a WebFinger resource, opening a fetch session per hop. This is
38
+ * the implementation of {@link lookupWebFinger}, which is not exported from
39
+ * the package entry point.
40
+ * @param resource The resource URL to look up.
41
+ * @param options Extra options for looking up the resource.
42
+ * @param deps The dependencies.
43
+ * @returns The resource descriptor, or `null` if not found.
44
+ * @internal
45
+ */
46
+ export async function lookupWebFingerInternal(
47
+ resource: URL | string,
48
+ options: LookupWebFingerOptions = {},
49
+ deps: LookupWebFingerDependencies = {},
50
+ ): Promise<ResourceDescriptor | null> {
51
+ if (typeof resource === "string") resource = new URL(resource);
52
+ let protocol = "https:";
53
+ let server: string;
54
+ if (resource.protocol === "acct:") {
55
+ const atPos = resource.pathname.lastIndexOf("@");
56
+ if (atPos < 0) return null;
57
+ server = resource.pathname.substring(atPos + 1);
58
+ if (server === "") return null;
59
+ } else {
60
+ protocol = resource.protocol;
61
+ server = resource.host;
62
+ }
63
+ let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
64
+ url.searchParams.set("resource", resource.href);
65
+ const openHop = deps.openHop ??
66
+ ((hopUrl: string) =>
67
+ options.allowPrivateAddress === true
68
+ ? openDirectFetch(hopUrl)
69
+ : openPublicFetch(hopUrl, { signal: options.signal }));
70
+ let redirected = 0;
71
+ while (true) {
72
+ logger.debug(
73
+ "Fetching WebFinger resource descriptor from {url}...",
74
+ { url: url.href },
75
+ );
76
+ const session = await openSession(url, openHop, options.signal);
77
+ if (session == null) return null;
78
+ let hop: HopResult;
79
+ try {
80
+ hop = await fetchHop(session, url, options);
81
+ } finally {
82
+ await session.close();
83
+ }
84
+ if ("result" in hop) return hop.result;
85
+ redirected++;
86
+ const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
87
+ if (redirected >= maxRedirection) {
88
+ logger.error(
89
+ "Too many redirections ({redirections}) while fetching WebFinger " +
90
+ "resource descriptor.",
91
+ { redirections: redirected },
92
+ );
93
+ return null;
94
+ }
95
+ // The request URL is the base, since a pinned response's url can be an
96
+ // IP address URL:
97
+ const redirectedUrl = new URL(hop.redirect, url);
98
+ if (redirectedUrl.protocol !== url.protocol) {
99
+ logger.error(
100
+ "Redirected to a different protocol ({protocol} to " +
101
+ "{redirectedProtocol}) while fetching WebFinger resource " +
102
+ "descriptor.",
103
+ {
104
+ protocol: url.protocol,
105
+ redirectedProtocol: redirectedUrl.protocol,
106
+ },
107
+ );
108
+ return null;
109
+ }
110
+ url = redirectedUrl;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Opens a fetch session for a hop. A disallowed URL, a failed DNS lookup,
116
+ * or an aborted lookup yields `null` instead of an error.
117
+ */
118
+ async function openSession(
119
+ url: URL,
120
+ openHop: (url: string) => Promise<PublicFetchSession>,
121
+ signal: AbortSignal | undefined,
122
+ ): Promise<PublicFetchSession | null> {
123
+ try {
124
+ return await openHop(url.href);
125
+ } catch (e) {
126
+ if (e instanceof UrlError) {
127
+ if (e.reason === "dns") {
128
+ logger.debug("DNS lookup failed for {url}", {
129
+ url: url.href,
130
+ error: e,
131
+ });
132
+ } else {
133
+ logger.error(
134
+ "Invalid URL for WebFinger resource descriptor: {error}",
135
+ { error: e },
136
+ );
137
+ }
138
+ // TODO: When merging forward into 2.3-maintenance and later, where
139
+ // this returns { resource: null, result: "network_error", remoteHost }
140
+ // instead of null, keep that return value for both reasons.
141
+ return null;
142
+ }
143
+ if (signal?.aborted) {
144
+ logger.debug(
145
+ "Failed to fetch WebFinger resource descriptor: {error}",
146
+ { url: url.href, error: e },
147
+ );
148
+ return null;
149
+ }
150
+ throw e;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Fetches a hop with the given session, and consumes the response body or
156
+ * starts cancelling it before returning. The caller closes the session.
157
+ */
158
+ async function fetchHop(
159
+ session: PublicFetchSession,
160
+ url: URL,
161
+ options: LookupWebFingerOptions,
162
+ ): Promise<HopResult> {
163
+ let response: Response;
164
+ try {
165
+ response = await session.fetch(url, {
166
+ headers: {
167
+ Accept: "application/jrd+json",
168
+ "User-Agent": typeof options.userAgent === "string"
169
+ ? options.userAgent
170
+ : getUserAgent(options.userAgent),
171
+ },
172
+ redirect: "manual",
173
+ signal: options.signal,
174
+ });
175
+ } catch (error) {
176
+ logger.debug(
177
+ "Failed to fetch WebFinger resource descriptor: {error}",
178
+ { url: url.href, error },
179
+ );
180
+ return { result: null };
181
+ }
182
+ if (
183
+ response.status >= 300 && response.status < 400 &&
184
+ response.headers.has("Location")
185
+ ) {
186
+ cancelBody(response, url);
187
+ return { redirect: response.headers.get("Location")! };
188
+ }
189
+ if (!response.ok) {
190
+ logger.debug(
191
+ "Failed to fetch WebFinger resource descriptor: {status} {statusText}.",
192
+ {
193
+ url: url.href,
194
+ status: response.status,
195
+ statusText: response.statusText,
196
+ },
197
+ );
198
+ cancelBody(response, url);
199
+ return { result: null };
200
+ }
201
+ try {
202
+ return {
203
+ result: JSON.parse(
204
+ await readBoundedText(response, MAX_BODY_SIZE, url),
205
+ ) as ResourceDescriptor,
206
+ };
207
+ } catch (e) {
208
+ if (e instanceof BodyTooLargeError) return { result: null };
209
+ if (e instanceof SyntaxError) {
210
+ logger.debug(
211
+ "Failed to parse WebFinger resource descriptor as JSON: {error}",
212
+ { error: e },
213
+ );
214
+ return { result: null };
215
+ }
216
+ throw e;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Starts cancelling a response body that is not read, without waiting for it
222
+ * to settle. The cancellation of a branch of a tee (e.g., a body cloned by
223
+ * a fetch mock) settles only after the other branch is cancelled too, per
224
+ * the Streams standard, so awaiting it could block forever. A failure is
225
+ * only logged.
226
+ */
227
+ function cancelBody(response: Response, url: URL): void {
228
+ response.body?.cancel().catch((error) => {
229
+ logger.debug(
230
+ "Failed to cancel the response body from {url}: {error}",
231
+ { url: url.href, error },
232
+ );
233
+ });
234
+ }