@fedify/webfinger 2.3.15-dev.2482 → 2.3.15

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/src/lookup.ts CHANGED
@@ -1,10 +1,4 @@
1
- import {
2
- getUserAgent,
3
- type GetUserAgentOptions,
4
- UrlError,
5
- validatePublicUrl,
6
- } from "@fedify/vocab-runtime";
7
- import { getLogger } from "@logtape/logtape";
1
+ import type { GetUserAgentOptions } from "@fedify/vocab-runtime";
8
2
  import {
9
3
  type Attributes,
10
4
  type Counter,
@@ -16,12 +10,12 @@ import {
16
10
  type TracerProvider,
17
11
  } from "@opentelemetry/api";
18
12
  import metadata from "../deno.json" with { type: "json" };
19
- import { BodyTooLargeError, MAX_BODY_SIZE, readBoundedText } from "./body.ts";
20
13
  import type { ResourceDescriptor } from "./jrd.ts";
21
14
 
22
- const logger = getLogger(["fedify", "webfinger", "lookup"]);
23
-
24
- const DEFAULT_MAX_REDIRECTION = 5;
15
+ import {
16
+ type LookupWebFingerDependencies,
17
+ lookupWebFingerInternal,
18
+ } from "./lookup-impl.ts";
25
19
 
26
20
  /**
27
21
  * The terminal classification of an outgoing {@link lookupWebFinger} call,
@@ -124,7 +118,7 @@ function getMetricResourceScheme(scheme: string): string {
124
118
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
125
119
  }
126
120
 
127
- interface WebFingerLookupOutcome {
121
+ export interface WebFingerLookupOutcome {
128
122
  resource: ResourceDescriptor | null;
129
123
  result: WebFingerLookupResult;
130
124
  statusCode?: number;
@@ -194,6 +188,19 @@ export interface LookupWebFingerOptions {
194
188
  export async function lookupWebFinger(
195
189
  resource: URL | string,
196
190
  options: LookupWebFingerOptions = {},
191
+ ): Promise<ResourceDescriptor | null> {
192
+ return await lookupWebFingerWithDependencies(resource, options);
193
+ }
194
+
195
+ /**
196
+ * The lookup implementation with injectable hop sessions for tests.
197
+ * Not exported from the package entry point.
198
+ * @internal
199
+ */
200
+ export async function lookupWebFingerWithDependencies(
201
+ resource: URL | string,
202
+ options: LookupWebFingerOptions = {},
203
+ deps: LookupWebFingerDependencies = {},
197
204
  ): Promise<ResourceDescriptor | null> {
198
205
  const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
199
206
  const tracer = tracerProvider.getTracer(
@@ -222,7 +229,7 @@ export async function lookupWebFinger(
222
229
  result: "error",
223
230
  };
224
231
  try {
225
- outcome = await lookupWebFingerInternal(resource, options);
232
+ outcome = await lookupWebFingerInternal(resource, options, deps);
226
233
  span.setStatus({
227
234
  code: outcome.resource === null
228
235
  ? SpanStatusCode.ERROR
@@ -266,209 +273,3 @@ function recordWebFingerLookup(
266
273
  instruments.lookup.add(1, attributes);
267
274
  instruments.lookupDuration.record(durationMs, attributes);
268
275
  }
269
-
270
- async function lookupWebFingerInternal(
271
- resource: URL | string,
272
- options: LookupWebFingerOptions = {},
273
- ): Promise<WebFingerLookupOutcome> {
274
- if (typeof resource === "string") resource = new URL(resource);
275
- let protocol = "https:";
276
- let server: string;
277
- if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
278
- // `acct:` (RFC 7565) and `mailto:` (RFC 6068, used as a WebFinger
279
- // resource per RFC 7033 §4.5) are opaque-path schemes: their
280
- // `user@host` authority lives in `pathname`, not in `host`. The
281
- // WebFinger host is extracted from the substring after the last
282
- // `@`, and the lookup always goes to https on that host.
283
- const atPos = resource.pathname.lastIndexOf("@");
284
- if (atPos < 0) return { resource: null, result: "invalid" };
285
- server = resource.pathname.substring(atPos + 1);
286
- // The authority part of both schemes must be a bare host: no
287
- // path, query, or fragment characters embedded in it. The
288
- // WHATWG URL parser routes anything after the first `?` or `#`
289
- // into `search` / `hash`, so by the time we read `pathname` the
290
- // only stray characters that can land in `server` are slashes.
291
- // Reject those (along with an empty authority) for both schemes.
292
- if (server === "" || /[/?#]/.test(server)) {
293
- return { resource: null, result: "invalid" };
294
- }
295
- // `acct:` (RFC 7565 §3) is bare authority only: no `search` or
296
- // `hash` allowed. `mailto:` (RFC 6068 §2) explicitly permits
297
- // `?hfields=…` header fields and fragment identifiers, so we
298
- // forward those to the remote WebFinger lookup unchanged and
299
- // only enforce the stricter shape for `acct:`.
300
- if (
301
- resource.protocol === "acct:" &&
302
- (resource.search !== "" || resource.hash !== "")
303
- ) {
304
- return { resource: null, result: "invalid" };
305
- }
306
- } else {
307
- protocol = resource.protocol;
308
- server = resource.host;
309
- }
310
- let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
311
- url.searchParams.set("resource", resource.href);
312
- let redirected = 0;
313
- while (true) {
314
- const remoteHost = url.host;
315
- logger.debug(
316
- "Fetching WebFinger resource descriptor from {url}...",
317
- { url: url.href },
318
- );
319
- let response: Response;
320
- if (options.allowPrivateAddress !== true) {
321
- try {
322
- await validatePublicUrl(url.href);
323
- } catch (e) {
324
- if (e instanceof UrlError) {
325
- if (e.reason === "dns") {
326
- logger.debug("DNS lookup failed for {url}", {
327
- url: url.href,
328
- error: e,
329
- });
330
- } else {
331
- logger.error(
332
- "Invalid URL for WebFinger resource descriptor: {error}",
333
- { error: e },
334
- );
335
- }
336
- return { resource: null, result: "network_error", remoteHost };
337
- }
338
- throw e;
339
- }
340
- }
341
- try {
342
- response = await fetch(url, {
343
- headers: {
344
- Accept: "application/jrd+json",
345
- "User-Agent": typeof options.userAgent === "string"
346
- ? options.userAgent
347
- : getUserAgent(options.userAgent),
348
- },
349
- redirect: "manual",
350
- signal: options.signal,
351
- });
352
- } catch (error) {
353
- logger.debug(
354
- "Failed to fetch WebFinger resource descriptor: {error}",
355
- { url: url.href, error },
356
- );
357
- return { resource: null, result: "network_error", remoteHost };
358
- }
359
- if (
360
- response.status >= 300 && response.status < 400 &&
361
- response.headers.has("Location")
362
- ) {
363
- redirected++;
364
- const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
365
- // `maxRedirection: N` is documented as "the maximum number of
366
- // redirections to follow", so the Nth redirect must still be
367
- // followed and the (N+1)th rejected. An earlier version used
368
- // `>=` here, which drifted by one from the documented semantics
369
- // and from the sibling code in @fedify/vocab-runtime's document
370
- // loader.
371
- if (redirected > maxRedirection) {
372
- logger.error(
373
- "Too many redirections ({redirections}) while fetching WebFinger " +
374
- "resource descriptor.",
375
- { redirections: redirected },
376
- );
377
- return {
378
- resource: null,
379
- result: "invalid",
380
- statusCode: response.status,
381
- remoteHost,
382
- };
383
- }
384
- let redirectedUrl: URL;
385
- try {
386
- redirectedUrl = new URL(
387
- response.headers.get("Location")!,
388
- response.url == null || response.url === "" ? url : response.url,
389
- );
390
- } catch (e) {
391
- logger.error(
392
- "Invalid Location header while following WebFinger redirect: " +
393
- "{error}",
394
- { url: url.href, error: e },
395
- );
396
- return {
397
- resource: null,
398
- result: "invalid",
399
- statusCode: response.status,
400
- remoteHost,
401
- };
402
- }
403
- if (redirectedUrl.protocol !== url.protocol) {
404
- logger.error(
405
- "Redirected to a different protocol ({protocol} to " +
406
- "{redirectedProtocol}) while fetching WebFinger resource " +
407
- "descriptor.",
408
- {
409
- protocol: url.protocol,
410
- redirectedProtocol: redirectedUrl.protocol,
411
- },
412
- );
413
- return {
414
- resource: null,
415
- result: "invalid",
416
- statusCode: response.status,
417
- remoteHost,
418
- };
419
- }
420
- url = redirectedUrl;
421
- continue;
422
- }
423
- if (!response.ok) {
424
- logger.debug(
425
- "Failed to fetch WebFinger resource descriptor: {status} {statusText}.",
426
- {
427
- url: url.href,
428
- status: response.status,
429
- statusText: response.statusText,
430
- },
431
- );
432
- const isNotFound = response.status === 404 || response.status === 410;
433
- return {
434
- resource: null,
435
- result: isNotFound ? "not_found" : "error",
436
- statusCode: response.status,
437
- remoteHost,
438
- };
439
- }
440
- try {
441
- const parsed = JSON.parse(
442
- await readBoundedText(response, MAX_BODY_SIZE, url),
443
- ) as ResourceDescriptor;
444
- return {
445
- resource: parsed,
446
- result: "found",
447
- statusCode: response.status,
448
- remoteHost,
449
- };
450
- } catch (e) {
451
- if (e instanceof BodyTooLargeError) {
452
- return {
453
- resource: null,
454
- result: "invalid",
455
- statusCode: response.status,
456
- remoteHost,
457
- };
458
- }
459
- if (e instanceof SyntaxError) {
460
- logger.debug(
461
- "Failed to parse WebFinger resource descriptor as JSON: {error}",
462
- { error: e },
463
- );
464
- return {
465
- resource: null,
466
- result: "invalid",
467
- statusCode: response.status,
468
- remoteHost,
469
- };
470
- }
471
- throw e;
472
- }
473
- }
474
- }