@fedify/webfinger 2.5.0-dev.2448 → 2.5.0-dev.2492

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/mod.js CHANGED
@@ -1,9 +1,10 @@
1
- import { FetchError, UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
2
- import { getLogger } from "@logtape/logtape";
3
1
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
+ import { FetchError, UrlError, getUserAgent } from "@fedify/vocab-runtime";
3
+ import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
4
+ import { getLogger } from "@logtape/logtape";
4
5
  //#region deno.json
5
6
  var name = "@fedify/webfinger";
6
- var version = "2.5.0-dev.2448+756933d0";
7
+ var version = "2.5.0-dev.2492+4648c04e";
7
8
  //#endregion
8
9
  //#region src/body.ts
9
10
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -79,13 +80,230 @@ async function readBoundedText(message, maxBytes, url) {
79
80
  }
80
81
  }
81
82
  //#endregion
82
- //#region src/lookup.ts
83
+ //#region src/lookup-impl.ts
83
84
  const logger = getLogger([
84
85
  "fedify",
85
86
  "webfinger",
86
87
  "lookup"
87
88
  ]);
88
89
  const DEFAULT_MAX_REDIRECTION = 5;
90
+ /**
91
+ * Looks up a WebFinger resource, opening a fetch session per hop. This is
92
+ * the implementation of {@link lookupWebFinger}, which is not exported from
93
+ * the package entry point.
94
+ * @param resource The resource URL to look up.
95
+ * @param options Extra options for looking up the resource.
96
+ * @param deps The dependencies.
97
+ * @returns The resource descriptor and terminal lookup classification.
98
+ * @internal
99
+ */
100
+ async function lookupWebFingerInternal(resource, options = {}, deps = {}) {
101
+ if (typeof resource === "string") resource = new URL(resource);
102
+ let protocol = "https:";
103
+ let server;
104
+ if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
105
+ const atPos = resource.pathname.lastIndexOf("@");
106
+ if (atPos < 0) return {
107
+ resource: null,
108
+ result: "invalid"
109
+ };
110
+ server = resource.pathname.substring(atPos + 1);
111
+ if (server === "" || /[/?#]/.test(server)) return {
112
+ resource: null,
113
+ result: "invalid"
114
+ };
115
+ if (resource.protocol === "acct:" && (resource.search !== "" || resource.hash !== "")) return {
116
+ resource: null,
117
+ result: "invalid"
118
+ };
119
+ } else {
120
+ protocol = resource.protocol;
121
+ server = resource.host;
122
+ }
123
+ let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
124
+ url.searchParams.set("resource", resource.href);
125
+ const openHop = deps.openHop ?? ((hopUrl) => options.allowPrivateAddress === true ? openDirectFetch(hopUrl) : openPublicFetch(hopUrl, { signal: options.signal }));
126
+ let redirected = 0;
127
+ while (true) {
128
+ logger.debug("Fetching WebFinger resource descriptor from {url}...", { url: url.href });
129
+ const remoteHost = url.host;
130
+ const session = await openSession(url, openHop, options.signal);
131
+ if (session == null) return {
132
+ resource: null,
133
+ result: "network_error",
134
+ remoteHost
135
+ };
136
+ let hop;
137
+ try {
138
+ hop = await fetchHop(session, url, options);
139
+ } finally {
140
+ await session.close();
141
+ }
142
+ if ("outcome" in hop) return hop.outcome;
143
+ redirected++;
144
+ const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
145
+ if (redirected > maxRedirection) {
146
+ logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
147
+ return {
148
+ resource: null,
149
+ result: "invalid",
150
+ statusCode: hop.statusCode,
151
+ remoteHost
152
+ };
153
+ }
154
+ let redirectedUrl;
155
+ try {
156
+ redirectedUrl = new URL(hop.redirect, url);
157
+ } catch (error) {
158
+ logger.error("Invalid Location header while following WebFinger redirect: {error}", {
159
+ url: url.href,
160
+ error
161
+ });
162
+ return {
163
+ resource: null,
164
+ result: "invalid",
165
+ statusCode: hop.statusCode,
166
+ remoteHost
167
+ };
168
+ }
169
+ if (redirectedUrl.protocol !== url.protocol) {
170
+ logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
171
+ protocol: url.protocol,
172
+ redirectedProtocol: redirectedUrl.protocol
173
+ });
174
+ return {
175
+ resource: null,
176
+ result: "invalid",
177
+ statusCode: hop.statusCode,
178
+ remoteHost
179
+ };
180
+ }
181
+ url = redirectedUrl;
182
+ }
183
+ }
184
+ /**
185
+ * Opens a fetch session for a hop. A disallowed URL, a failed DNS lookup,
186
+ * or an aborted lookup yields `null` instead of an error.
187
+ */
188
+ async function openSession(url, openHop, signal) {
189
+ try {
190
+ return await openHop(url.href);
191
+ } catch (e) {
192
+ if (e instanceof UrlError) {
193
+ if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
194
+ url: url.href,
195
+ error: e
196
+ });
197
+ else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
198
+ return null;
199
+ }
200
+ if (signal?.aborted) {
201
+ logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
202
+ url: url.href,
203
+ error: e
204
+ });
205
+ return null;
206
+ }
207
+ throw e;
208
+ }
209
+ }
210
+ /**
211
+ * Fetches a hop with the given session, and consumes the response body or
212
+ * starts cancelling it before returning. The caller closes the session.
213
+ */
214
+ async function fetchHop(session, url, options) {
215
+ let response;
216
+ try {
217
+ response = await session.fetch(url, {
218
+ headers: {
219
+ Accept: "application/jrd+json",
220
+ "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent)
221
+ },
222
+ redirect: "manual",
223
+ signal: options.signal
224
+ });
225
+ } catch (error) {
226
+ logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
227
+ url: url.href,
228
+ error
229
+ });
230
+ return { outcome: {
231
+ resource: null,
232
+ result: "network_error",
233
+ remoteHost: url.host
234
+ } };
235
+ }
236
+ if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
237
+ cancelBody(response, url);
238
+ return {
239
+ redirect: response.headers.get("Location"),
240
+ statusCode: response.status
241
+ };
242
+ }
243
+ if (!response.ok) {
244
+ logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
245
+ url: url.href,
246
+ status: response.status,
247
+ statusText: response.statusText
248
+ });
249
+ cancelBody(response, url);
250
+ return { outcome: {
251
+ resource: null,
252
+ result: response.status === 404 || response.status === 410 ? "not_found" : "error",
253
+ statusCode: response.status,
254
+ remoteHost: url.host
255
+ } };
256
+ }
257
+ try {
258
+ const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
259
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { outcome: {
260
+ resource: null,
261
+ result: "invalid",
262
+ statusCode: response.status,
263
+ remoteHost: url.host
264
+ } };
265
+ return { outcome: {
266
+ resource: parsed,
267
+ result: "found",
268
+ statusCode: response.status,
269
+ remoteHost: url.host
270
+ } };
271
+ } catch (e) {
272
+ if (e instanceof BodyTooLargeError) return { outcome: {
273
+ resource: null,
274
+ result: "invalid",
275
+ statusCode: response.status,
276
+ remoteHost: url.host
277
+ } };
278
+ if (e instanceof SyntaxError) {
279
+ logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
280
+ return { outcome: {
281
+ resource: null,
282
+ result: "invalid",
283
+ statusCode: response.status,
284
+ remoteHost: url.host
285
+ } };
286
+ }
287
+ throw e;
288
+ }
289
+ }
290
+ /**
291
+ * Starts cancelling a response body that is not read, without waiting for it
292
+ * to settle. The cancellation of a branch of a tee (e.g., a body cloned by
293
+ * a fetch mock) settles only after the other branch is cancelled too, per
294
+ * the Streams standard, so awaiting it could block forever. A failure is
295
+ * only logged.
296
+ */
297
+ function cancelBody(response, url) {
298
+ response.body?.cancel().catch((error) => {
299
+ logger.debug("Failed to cancel the response body from {url}: {error}", {
300
+ url: url.href,
301
+ error
302
+ });
303
+ });
304
+ }
305
+ //#endregion
306
+ //#region src/lookup.ts
89
307
  const WEBFINGER_HISTOGRAM_BUCKETS = [
90
308
  5,
91
309
  10,
@@ -139,6 +357,9 @@ function getMetricResourceScheme(scheme) {
139
357
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
140
358
  }
141
359
  async function lookupWebFinger(resource, options = {}) {
360
+ return await lookupWebFingerWithDependencies(resource, options);
361
+ }
362
+ async function lookupWebFingerWithDependencies(resource, options = {}, deps = {}) {
142
363
  const tracer = (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version);
143
364
  const scheme = getResourceScheme(resource);
144
365
  return await tracer.startActiveSpan("webfinger.lookup", {
@@ -155,7 +376,7 @@ async function lookupWebFinger(resource, options = {}) {
155
376
  result: "error"
156
377
  };
157
378
  try {
158
- outcome = await lookupWebFingerInternal(resource, options);
379
+ outcome = await lookupWebFingerInternal(resource, options, deps);
159
380
  span.setStatus({ code: outcome.resource === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
160
381
  return options.withDiagnostics ? outcome : outcome.resource;
161
382
  } catch (error) {
@@ -181,161 +402,5 @@ function recordWebFingerLookup(meterProvider, durationMs, scheme, outcome) {
181
402
  instruments.lookup.add(1, attributes);
182
403
  instruments.lookupDuration.record(durationMs, attributes);
183
404
  }
184
- async function lookupWebFingerInternal(resource, options = {}) {
185
- if (typeof resource === "string") resource = new URL(resource);
186
- let protocol = "https:";
187
- let server;
188
- if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
189
- const atPos = resource.pathname.lastIndexOf("@");
190
- if (atPos < 0) return {
191
- resource: null,
192
- result: "invalid"
193
- };
194
- server = resource.pathname.substring(atPos + 1);
195
- if (server === "" || /[/?#]/.test(server)) return {
196
- resource: null,
197
- result: "invalid"
198
- };
199
- if (resource.protocol === "acct:" && (resource.search !== "" || resource.hash !== "")) return {
200
- resource: null,
201
- result: "invalid"
202
- };
203
- } else {
204
- protocol = resource.protocol;
205
- server = resource.host;
206
- }
207
- let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
208
- url.searchParams.set("resource", resource.href);
209
- let redirected = 0;
210
- while (true) {
211
- const remoteHost = url.host;
212
- logger.debug("Fetching WebFinger resource descriptor from {url}...", { url: url.href });
213
- let response;
214
- if (options.allowPrivateAddress !== true) try {
215
- await validatePublicUrl(url.href);
216
- } catch (e) {
217
- if (e instanceof UrlError) {
218
- if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
219
- url: url.href,
220
- error: e
221
- });
222
- else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
223
- return {
224
- resource: null,
225
- result: "network_error",
226
- remoteHost
227
- };
228
- }
229
- throw e;
230
- }
231
- try {
232
- response = await fetch(url, {
233
- headers: {
234
- Accept: "application/jrd+json",
235
- "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent)
236
- },
237
- redirect: "manual",
238
- signal: options.signal
239
- });
240
- } catch (error) {
241
- logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
242
- url: url.href,
243
- error
244
- });
245
- return {
246
- resource: null,
247
- result: "network_error",
248
- remoteHost
249
- };
250
- }
251
- if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
252
- redirected++;
253
- const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
254
- if (redirected > maxRedirection) {
255
- logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
256
- return {
257
- resource: null,
258
- result: "invalid",
259
- statusCode: response.status,
260
- remoteHost
261
- };
262
- }
263
- let redirectedUrl;
264
- try {
265
- redirectedUrl = new URL(response.headers.get("Location"), response.url == null || response.url === "" ? url : response.url);
266
- } catch (e) {
267
- logger.error("Invalid Location header while following WebFinger redirect: {error}", {
268
- url: url.href,
269
- error: e
270
- });
271
- return {
272
- resource: null,
273
- result: "invalid",
274
- statusCode: response.status,
275
- remoteHost
276
- };
277
- }
278
- if (redirectedUrl.protocol !== url.protocol) {
279
- logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
280
- protocol: url.protocol,
281
- redirectedProtocol: redirectedUrl.protocol
282
- });
283
- return {
284
- resource: null,
285
- result: "invalid",
286
- statusCode: response.status,
287
- remoteHost
288
- };
289
- }
290
- url = redirectedUrl;
291
- continue;
292
- }
293
- if (!response.ok) {
294
- logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
295
- url: url.href,
296
- status: response.status,
297
- statusText: response.statusText
298
- });
299
- return {
300
- resource: null,
301
- result: response.status === 404 || response.status === 410 ? "not_found" : "error",
302
- statusCode: response.status,
303
- remoteHost
304
- };
305
- }
306
- try {
307
- const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
308
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
309
- resource: null,
310
- result: "invalid",
311
- statusCode: response.status,
312
- remoteHost
313
- };
314
- return {
315
- resource: parsed,
316
- result: "found",
317
- statusCode: response.status,
318
- remoteHost
319
- };
320
- } catch (e) {
321
- if (e instanceof BodyTooLargeError) return {
322
- resource: null,
323
- result: "invalid",
324
- statusCode: response.status,
325
- remoteHost
326
- };
327
- if (e instanceof SyntaxError) {
328
- logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
329
- return {
330
- resource: null,
331
- result: "invalid",
332
- statusCode: response.status,
333
- remoteHost
334
- };
335
- }
336
- throw e;
337
- }
338
- }
339
- }
340
405
  //#endregion
341
- export { lookupWebFinger };
406
+ export { lookupWebFinger, lookupWebFingerWithDependencies };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/webfinger",
3
- "version": "2.5.0-dev.2448+756933d0",
3
+ "version": "2.5.0-dev.2492+4648c04e",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,9 +55,9 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@logtape/logtape": "^2.2.0",
58
- "@opentelemetry/api": "^1.9.1",
58
+ "@opentelemetry/api": "~1.9.1",
59
59
  "es-toolkit": "1.46.1",
60
- "@fedify/vocab-runtime": "2.5.0-dev.2448+756933d0"
60
+ "@fedify/vocab-runtime": "2.5.0-dev.2492+4648c04e"
61
61
  },
62
62
  "scripts": {
63
63
  "build:self": "tsdown",