@fedify/vocab-runtime 2.4.0-pr.934.40 → 2.4.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.
- package/deno.json +4 -2
- package/dist/{tests/docloader-Xi61Le8w.mjs → contexts-BK1CqBR5.cjs} +164 -269
- package/dist/{tests/docloader-BFedvemb.cjs → contexts-DPuJ4UYL.js} +160 -288
- package/dist/{docloader-DnUMWHaJ.d.cts → docloader-CYwqh5Df.d.cts} +68 -2
- package/dist/{docloader-xRGn1azD.d.ts → docloader-CYwqh5Df.d.ts} +68 -2
- package/dist/internal/jsonld-cache.cjs +70 -1
- package/dist/internal/jsonld-cache.d.cts +35 -2
- package/dist/internal/jsonld-cache.d.ts +35 -2
- package/dist/internal/jsonld-cache.js +67 -2
- package/dist/internal/portable-dereference.cjs +721 -0
- package/dist/internal/portable-dereference.d.cts +349 -0
- package/dist/internal/portable-dereference.d.ts +349 -0
- package/dist/internal/portable-dereference.js +702 -0
- package/dist/internal/signed-representation.cjs +378 -0
- package/dist/internal/signed-representation.d.cts +144 -0
- package/dist/internal/signed-representation.d.ts +144 -0
- package/dist/internal/signed-representation.js +371 -0
- package/dist/jsonld.cjs +2 -2
- package/dist/mod.cjs +715 -4523
- package/dist/mod.d.cts +406 -6
- package/dist/mod.d.ts +406 -7
- package/dist/mod.js +694 -4518
- package/dist/portable-DqtfLy_1.d.cts +160 -0
- package/dist/portable-DtWsu2yU.d.ts +160 -0
- package/dist/tests/body-CYiu9CO-.mjs +130 -0
- package/dist/tests/body-CkEwROsn.cjs +153 -0
- package/dist/tests/body.test.cjs +89 -0
- package/dist/tests/body.test.d.cts +1 -0
- package/dist/tests/body.test.d.mts +1 -0
- package/dist/tests/body.test.mjs +90 -0
- package/dist/tests/contexts-CIKsin4e.mjs +4512 -0
- package/dist/tests/contexts-DizzBjz4.cjs +4523 -0
- package/dist/tests/decimal.test.cjs +8 -7
- package/dist/tests/decimal.test.mjs +8 -6
- package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
- package/dist/tests/digest-COC7xDiQ.mjs +141 -0
- package/dist/tests/digest.test.cjs +102 -0
- package/dist/tests/digest.test.d.cts +1 -0
- package/dist/tests/digest.test.d.mts +1 -0
- package/dist/tests/digest.test.mjs +103 -0
- package/dist/tests/docloader-C3YFl6y4.cjs +396 -0
- package/dist/tests/docloader-CvmS4sVq.mjs +373 -0
- package/dist/tests/docloader.test.cjs +692 -39
- package/dist/tests/docloader.test.mjs +686 -34
- package/dist/tests/internal/multicodec.test.cjs +2 -3
- package/dist/tests/internal/multicodec.test.mjs +2 -2
- package/dist/tests/internal/portable-dereference.test.cjs +172 -0
- package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
- package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
- package/dist/tests/internal/portable-dereference.test.mjs +173 -0
- package/dist/tests/jsonld-cache-BPQmOZWD.mjs +342 -0
- package/dist/tests/jsonld-cache-C07AyNOY.cjs +397 -0
- package/dist/tests/jsonld-cache.test.cjs +101 -298
- package/dist/tests/jsonld-cache.test.mjs +94 -289
- package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
- package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
- package/dist/tests/key.test.cjs +6 -5
- package/dist/tests/key.test.mjs +6 -4
- package/dist/tests/langstr.test.cjs +4 -4
- package/dist/tests/langstr.test.mjs +2 -2
- package/dist/tests/link.test.cjs +2 -3
- package/dist/tests/link.test.mjs +2 -2
- package/dist/tests/multibase/multibase.test.cjs +8 -9
- package/dist/tests/multibase/multibase.test.mjs +6 -6
- package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
- package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
- package/dist/tests/portable-dereference-BNXtgg5U.cjs +278 -0
- package/dist/tests/portable-dereference-DRE5bz-l.mjs +249 -0
- package/dist/tests/portable-media-BLKllU56.cjs +153 -0
- package/dist/tests/portable-media-DRSGBORB.mjs +148 -0
- package/dist/tests/portable-media.test.cjs +222 -0
- package/dist/tests/portable-media.test.d.cts +1 -0
- package/dist/tests/portable-media.test.d.mts +1 -0
- package/dist/tests/portable-media.test.mjs +223 -0
- package/dist/tests/portable-workers.test.cjs +36 -0
- package/dist/tests/portable-workers.test.d.cts +2 -0
- package/dist/tests/portable-workers.test.d.mts +2 -0
- package/dist/tests/portable-workers.test.mjs +35 -0
- package/dist/tests/{request-cCPgOxYG.cjs → request-8vPWtV1-.cjs} +10 -4
- package/dist/tests/{request-BOS-hNaf.mjs → request-DRaOaTqD.mjs} +8 -2
- package/dist/tests/request.test.cjs +7 -4
- package/dist/tests/request.test.mjs +5 -2
- package/dist/tests/signed-representation.test.cjs +588 -0
- package/dist/tests/signed-representation.test.d.cts +1 -0
- package/dist/tests/signed-representation.test.d.mts +1 -0
- package/dist/tests/signed-representation.test.mjs +589 -0
- package/dist/tests/temporal.test.cjs +1 -2
- package/dist/tests/temporal.test.mjs +1 -1
- package/dist/tests/url-BNakuZ8k.cjs +998 -0
- package/dist/tests/url-DMxmp7ZG.mjs +859 -0
- package/dist/tests/url.test.cjs +503 -3
- package/dist/tests/url.test.mjs +503 -2
- package/dist/url-DrGTR8yv.cjs +993 -0
- package/dist/url-Dzyp-NsC.js +860 -0
- package/package.json +27 -4
- package/scripts/test-bun.mjs +17 -0
- package/src/body.test.ts +125 -0
- package/src/body.ts +152 -0
- package/src/contexts/cid-v1.json +114 -0
- package/src/contexts/fep-22cd.json +21 -0
- package/src/contexts/fep-ef61.json +10 -0
- package/src/contexts/miscellany.json +17 -0
- package/src/contexts/security-data-integrity-v1.json +0 -4
- package/src/contexts.ts +40 -0
- package/src/digest.test.ts +220 -0
- package/src/digest.ts +229 -0
- package/src/docloader.test.ts +918 -27
- package/src/docloader.ts +322 -105
- package/src/internal/jsonld-cache.ts +97 -1
- package/src/internal/portable-dereference.test.ts +254 -0
- package/src/internal/portable-dereference.ts +1000 -0
- package/src/internal/signed-representation.ts +565 -0
- package/src/jsonld-cache.test.ts +89 -0
- package/src/key.test.ts +9 -0
- package/src/key.ts +11 -1
- package/src/mod.ts +30 -0
- package/src/multibase/multibase.test.ts +5 -5
- package/src/portable-media.test.ts +293 -0
- package/src/portable-media.ts +264 -0
- package/src/portable-workers.test.ts +81 -0
- package/src/portable.ts +180 -0
- package/src/preprocessor.ts +7 -0
- package/src/request.test.ts +10 -1
- package/src/request.ts +9 -1
- package/src/signed-representation.test.ts +338 -0
- package/src/url.test.ts +844 -1
- package/src/url.ts +717 -17
- package/tsdown.config.ts +2 -0
- package/dist/tests/url-BvjYQdxL.cjs +0 -456
- package/dist/tests/url-a2D8NAgh.mjs +0 -378
- package/dist/url-Ck3dGEwH.cjs +0 -457
- package/dist/url-m1YxGNZ0.js +0 -379
- /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
- /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-C4Fl80ae.cjs} +0 -0
- /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-CQ26J_L7.mjs} +0 -0
- /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
- /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
- /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
- /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
- /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
package/src/url.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import type { LookupAddress } from "node:dns";
|
|
2
|
-
import
|
|
2
|
+
// FIXME: the default dns import exists since tests can stub `dns.lookup()`.
|
|
3
|
+
// This should be replaced by injecting the lookup function such as an internal
|
|
4
|
+
// option on `validatePublicUrl()`
|
|
5
|
+
import dns from "node:dns/promises";
|
|
3
6
|
import { isIP } from "node:net";
|
|
4
7
|
|
|
5
8
|
export class UrlError extends Error {
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The failure category. Defaults to `"disallowed"`.
|
|
11
|
+
* `"dns"` includes lookup errors and results with no usable IP addresses.
|
|
12
|
+
* @since 2.0.29
|
|
13
|
+
*/
|
|
14
|
+
readonly reason: "dns" | "disallowed";
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
options?: { cause?: unknown; reason?: "dns" | "disallowed" },
|
|
19
|
+
) {
|
|
20
|
+
super(message, options);
|
|
8
21
|
this.name = "UrlError";
|
|
22
|
+
this.reason = options?.reason ?? "disallowed";
|
|
9
23
|
}
|
|
10
24
|
}
|
|
11
25
|
|
|
@@ -15,9 +29,66 @@ const INVALID_PERCENT_ENCODING_PATTERN = /%(?![0-9A-Fa-f]{2})/;
|
|
|
15
29
|
const PERCENT_ENCODING_PATTERN = /%[0-9A-Fa-f]{2}/g;
|
|
16
30
|
const DID_SCHEME_PATTERN = /^did:/i;
|
|
17
31
|
const DID_PATTERN = /^did:[a-z0-9]+:[-A-Za-z0-9._%]+(?::[-A-Za-z0-9._%]+)*$/i;
|
|
32
|
+
const DOT_SEGMENT_PATTERN = /(?:^|\/)(?:\.|%2e){1,2}(?=\/|$)/i;
|
|
33
|
+
|
|
34
|
+
function prepareUrlInput(iri: string): string {
|
|
35
|
+
// WHATWG URL removes these characters before it recognizes dot segments.
|
|
36
|
+
// Check the same spelling it will parse, while retaining the raw path for
|
|
37
|
+
// the comparison-only canonicalizer below.
|
|
38
|
+
let prepared = iri.replace(/[\t\n\r]/g, "");
|
|
39
|
+
while (prepared.length > 0 && prepared.charCodeAt(0) <= 0x20) {
|
|
40
|
+
prepared = prepared.slice(1);
|
|
41
|
+
}
|
|
42
|
+
while (
|
|
43
|
+
prepared.length > 0 && prepared.charCodeAt(prepared.length - 1) <= 0x20
|
|
44
|
+
) {
|
|
45
|
+
prepared = prepared.slice(0, -1);
|
|
46
|
+
}
|
|
47
|
+
return prepared;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertPortablePathCanBeParsed(iri: string): void {
|
|
51
|
+
const match = prepareUrlInput(iri).match(PORTABLE_IRI_PATTERN);
|
|
52
|
+
if (match != null && DOT_SEGMENT_PATTERN.test(match[3])) {
|
|
53
|
+
throw new TypeError(
|
|
54
|
+
"Portable ActivityPub IRI paths with dot segments cannot be represented as URLs.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertCompatiblePathCanBeParsed(raw: string, parsed: URL): void {
|
|
60
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
|
61
|
+
const prepared = prepareUrlInput(raw);
|
|
62
|
+
const match = prepared.match(/^https?:[\/\\]*[^/?#\\]*([^?#]*)/i);
|
|
63
|
+
if (match == null) return;
|
|
64
|
+
const rawPath = match[1].replace(/\\/g, "/");
|
|
65
|
+
const compatiblePath =
|
|
66
|
+
parsed.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX) &&
|
|
67
|
+
COMPATIBLE_ID_DID_PATTERN.test(
|
|
68
|
+
parsed.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length),
|
|
69
|
+
);
|
|
70
|
+
const rawCompatiblePath = rawPath.startsWith(COMPATIBLE_ID_PATH_PREFIX) &&
|
|
71
|
+
COMPATIBLE_ID_DID_PATTERN.test(
|
|
72
|
+
rawPath.slice(COMPATIBLE_ID_PATH_PREFIX.length),
|
|
73
|
+
);
|
|
74
|
+
if (
|
|
75
|
+
(compatiblePath || rawCompatiblePath) && DOT_SEGMENT_PATTERN.test(rawPath)
|
|
76
|
+
) {
|
|
77
|
+
throw new TypeError(
|
|
78
|
+
"FEP-ef61 compatible identifier paths with dot segments cannot be represented as URLs.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
18
82
|
|
|
19
83
|
/**
|
|
20
|
-
* Parses a JSON-LD `@id` value as an IRI
|
|
84
|
+
* Parses a JSON-LD `@id` value as an IRI, including FEP-ef61 portable
|
|
85
|
+
* ActivityPub IRIs. See {@link parseIri} for how IRIs are parsed.
|
|
86
|
+
* @param id The `@id` value.
|
|
87
|
+
* @param base The base IRI to resolve a relative `@id` against.
|
|
88
|
+
* @returns The parsed IRI, or `undefined` if `id` is missing or a blank node
|
|
89
|
+
* identifier.
|
|
90
|
+
* @throws {TypeError} If `id` is not a valid IRI.
|
|
91
|
+
* @since 2.4.0
|
|
21
92
|
*/
|
|
22
93
|
export function parseJsonLdId(
|
|
23
94
|
id: string | undefined,
|
|
@@ -33,11 +104,28 @@ export function parseJsonLdId(
|
|
|
33
104
|
|
|
34
105
|
/**
|
|
35
106
|
* Parses an IRI as a URL, including FEP-ef61 portable ActivityPub IRIs.
|
|
107
|
+
* Portable URI and FEP-ef61 compatible identifier strings whose path contains
|
|
108
|
+
* a `.` or `..` segment, including percent-encoded spellings, throw a
|
|
109
|
+
* `TypeError`: JavaScript `URL` would otherwise identify a different object.
|
|
110
|
+
* This also applies to compatible identifier strings used as relative bases.
|
|
111
|
+
* A `URL` argument may already have lost such segments before this function
|
|
112
|
+
* receives it.
|
|
113
|
+
*
|
|
114
|
+
* Portable IRIs, e.g., `ap://did:key:z6Mk.../actor`, cannot be represented by
|
|
115
|
+
* JavaScript `URL` as they are, so the returned `URL` keeps the DID authority
|
|
116
|
+
* percent-encoded, e.g., `ap+ef61://did%3Akey%3Az6Mk.../actor`. Use
|
|
117
|
+
* {@link formatIri} to get the canonical string back.
|
|
118
|
+
* @param iri The IRI to parse.
|
|
119
|
+
* @param base The base IRI to resolve a relative IRI against.
|
|
120
|
+
* @returns The parsed IRI.
|
|
121
|
+
* @throws {TypeError} If the IRI is malformed.
|
|
122
|
+
* @since 2.4.0
|
|
36
123
|
*/
|
|
37
124
|
export function parseIri(iri: string | URL, base?: string | URL): URL {
|
|
38
125
|
if (iri instanceof URL) {
|
|
39
126
|
return normalizePortableUrl(iri) ?? new URL(iri.href);
|
|
40
127
|
}
|
|
128
|
+
assertPortablePathCanBeParsed(iri);
|
|
41
129
|
const portable = parsePortableIri(iri);
|
|
42
130
|
if (portable != null) return portable;
|
|
43
131
|
base = normalizeBaseIri(base);
|
|
@@ -45,20 +133,38 @@ export function parseIri(iri: string | URL, base?: string | URL): URL {
|
|
|
45
133
|
return parseAtUri(iri);
|
|
46
134
|
}
|
|
47
135
|
const parsed = new URL(iri, base);
|
|
136
|
+
assertCompatiblePathCanBeParsed(iri, parsed);
|
|
48
137
|
return normalizePortableUrl(parsed) ?? parsed;
|
|
49
138
|
}
|
|
50
139
|
|
|
51
140
|
/**
|
|
52
141
|
* Formats a URL as an IRI, including FEP-ef61 portable ActivityPub IRIs.
|
|
142
|
+
* Portable URI and FEP-ef61 compatible identifier strings with dot segments
|
|
143
|
+
* throw a `TypeError` because their paths cannot be represented by JavaScript
|
|
144
|
+
* `URL` without normalization.
|
|
145
|
+
*
|
|
146
|
+
* Portable IRIs are formatted with the `ap+ef61:` scheme and a decoded DID
|
|
147
|
+
* authority, even if they were parsed from `ap:` IRIs. FEP-ef61 currently
|
|
148
|
+
* recommends the `ap:` scheme, so a future major version of Fedify may change
|
|
149
|
+
* the scheme this function produces. Do not compare its results as strings to
|
|
150
|
+
* tell whether two portable IRIs identify the same object; use
|
|
151
|
+
* `arePortableUrisEqual()` instead.
|
|
152
|
+
* @param iri The IRI to format.
|
|
153
|
+
* @returns The formatted IRI. A string that cannot be parsed as a URL at all
|
|
154
|
+
* is returned unchanged.
|
|
155
|
+
* @throws {TypeError} If the IRI is a malformed portable IRI or FEP-ef61
|
|
156
|
+
* compatible identifier, e.g., one with dot segments.
|
|
157
|
+
* @since 2.4.0
|
|
53
158
|
*/
|
|
54
159
|
export function formatIri(iri: string | URL): string {
|
|
160
|
+
if (typeof iri === "string") assertPortablePathCanBeParsed(iri);
|
|
55
161
|
const parsed = parsePortableIri(iri instanceof URL ? iri.href : iri);
|
|
56
162
|
if (parsed == null) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
163
|
+
if (iri instanceof URL) return iri.href;
|
|
164
|
+
if (!URL.canParse(iri)) return iri;
|
|
165
|
+
const url = new URL(iri);
|
|
166
|
+
assertCompatiblePathCanBeParsed(iri, url);
|
|
167
|
+
return url.href;
|
|
62
168
|
}
|
|
63
169
|
const authority = decodePortableAuthority(parsed.host);
|
|
64
170
|
return `ap+ef61://${authority}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
@@ -73,6 +179,12 @@ export function formatIri(iri: string | URL): string {
|
|
|
73
179
|
* string, not a `URL` object, because JavaScript `URL` normalizes opaque path
|
|
74
180
|
* segments before Fedify can compare them.
|
|
75
181
|
*
|
|
182
|
+
* The `ap+ef61:` scheme of the result is a choice of Fedify's, whereas
|
|
183
|
+
* FEP-ef61 currently canonicalizes portable URIs with the `ap:` scheme.
|
|
184
|
+
* A future major version of Fedify may change the scheme of the result, so
|
|
185
|
+
* do not persist the result as the only copy of an identifier; keep the
|
|
186
|
+
* original URI, and canonicalize it again when comparing.
|
|
187
|
+
*
|
|
76
188
|
* @param input The raw portable ActivityPub URI string to canonicalize.
|
|
77
189
|
* @returns The canonical portable ActivityPub URI string.
|
|
78
190
|
* @throws {TypeError} If the input is not a valid portable ActivityPub IRI.
|
|
@@ -182,7 +294,20 @@ export function haveSameFe34Origin(
|
|
|
182
294
|
}
|
|
183
295
|
|
|
184
296
|
/**
|
|
185
|
-
* Checks whether two IRIs have the same origin.
|
|
297
|
+
* Checks whether two IRIs have the same origin. Unlike comparing
|
|
298
|
+
* `URL.origin`, which is `"null"` for URLs with non-special schemes, this
|
|
299
|
+
* compares FEP-ef61 portable ActivityPub IRIs by their schemes and DIDs, so
|
|
300
|
+
* that `ap:` and `ap+ef61:` IRIs of the same DID with decoded or
|
|
301
|
+
* percent-encoded authorities have the same origin. Other IRIs with
|
|
302
|
+
* a non-special scheme and a host are compared by their schemes and hosts.
|
|
303
|
+
*
|
|
304
|
+
* This is not an FEP-fe34 origin check: an `ap+ef61:` IRI and the `did:key`
|
|
305
|
+
* verification method of the same DID have different origins here. Use
|
|
306
|
+
* {@link haveSameFe34Origin} for that.
|
|
307
|
+
* @param left The first IRI.
|
|
308
|
+
* @param right The second IRI.
|
|
309
|
+
* @returns `true` if the IRIs have the same origin.
|
|
310
|
+
* @since 2.4.0
|
|
186
311
|
*/
|
|
187
312
|
export function haveSameIriOrigin(left: URL, right: URL): boolean {
|
|
188
313
|
return getComparableIriOrigin(left) === getComparableIriOrigin(right);
|
|
@@ -238,14 +363,18 @@ function parsePortableIri(iri: string): URL | null {
|
|
|
238
363
|
|
|
239
364
|
function normalizePortableUrl(iri: URL): URL | null {
|
|
240
365
|
if (iri.protocol !== "ap:" && iri.protocol !== "ap+ef61:") return null;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
);
|
|
366
|
+
const raw = `ap+ef61://${iri.host}${iri.pathname}${iri.search}${iri.hash}`;
|
|
367
|
+
assertPortablePathCanBeParsed(raw);
|
|
368
|
+
return parsePortableIri(raw);
|
|
244
369
|
}
|
|
245
370
|
|
|
246
371
|
function normalizeBaseIri(base?: string | URL): string | URL | undefined {
|
|
247
372
|
if (base == null) return undefined;
|
|
248
373
|
if (base instanceof URL) return normalizePortableUrl(base) ?? base;
|
|
374
|
+
assertPortablePathCanBeParsed(base);
|
|
375
|
+
if (URL.canParse(base)) {
|
|
376
|
+
assertCompatiblePathCanBeParsed(base, new URL(base));
|
|
377
|
+
}
|
|
249
378
|
return parsePortableIri(base) ??
|
|
250
379
|
(base.startsWith("at://") && !URL.canParse(".", base)
|
|
251
380
|
? parseAtUri(base)
|
|
@@ -323,6 +452,545 @@ function parseAtUri(uri: string): URL {
|
|
|
323
452
|
return new URL("at://" + encodeURIComponent(authority) + path);
|
|
324
453
|
}
|
|
325
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Checks whether the URL is an FEP-ef61 gateway base URI, i.e., an HTTP(S)
|
|
457
|
+
* URI with no credentials, path, query, or fragment, such as
|
|
458
|
+
* `https://example.com/`. FEP-ef61 requires every item of a portable actor's
|
|
459
|
+
* `gateways` to be such a URI. A URI with an empty query or fragment
|
|
460
|
+
* delimiter, such as `https://example.com/?`, is not a gateway base URI.
|
|
461
|
+
*
|
|
462
|
+
* Note that the `URL` class normalizes `https://example.com` to
|
|
463
|
+
* `https://example.com/`, so both are gateway base URIs.
|
|
464
|
+
* @param url The URL to check.
|
|
465
|
+
* @returns `true` if the URL is an FEP-ef61 gateway base URI.
|
|
466
|
+
* @since 2.4.0
|
|
467
|
+
*/
|
|
468
|
+
export function isGatewayUrl(url: URL): boolean {
|
|
469
|
+
return (url.protocol === "http:" || url.protocol === "https:") &&
|
|
470
|
+
url.href === `${url.origin}/`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Parses and validates an FEP-ef61 gateway base URI, i.e., an HTTP(S) URI with
|
|
475
|
+
* no credentials, path, query, or fragment, such as `https://example.com`.
|
|
476
|
+
* See {@link isGatewayUrl} for the rules.
|
|
477
|
+
* @param url The gateway base URI to parse.
|
|
478
|
+
* @returns The parsed gateway base URI, whose path is `/`.
|
|
479
|
+
* @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
|
|
480
|
+
* with no credentials, path, query, or fragment. In the
|
|
481
|
+
* latter case, the message starts with
|
|
482
|
+
* `Invalid FEP-ef61 gateway:`.
|
|
483
|
+
* @since 2.4.0
|
|
484
|
+
*/
|
|
485
|
+
export function parseGatewayUrl(url: string): URL {
|
|
486
|
+
const parsed = parseIri(url);
|
|
487
|
+
if (!isGatewayUrl(parsed)) {
|
|
488
|
+
throw new TypeError(
|
|
489
|
+
`Invalid FEP-ef61 gateway: ${url}. FEP-ef61 gateways must be HTTP(S) ` +
|
|
490
|
+
"base URIs with no credentials, path, query, or fragment.",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return parsed;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
|
|
497
|
+
const COMPATIBLE_ID_DID_PATTERN = /^did(?::|%3A)/i;
|
|
498
|
+
const RAW_COMPATIBLE_ID_PREFIX_PATTERN =
|
|
499
|
+
/^(?:[hH][tT][tT][pP][sS]?):\/\/[^/?#]*\/\.well-known\/apgateway\/(?=[dD][iI][dD](?::|%3[aA]))/;
|
|
500
|
+
// `gateways` is the location hint parameter name used by earlier FEP-ef61
|
|
501
|
+
// revisions; strip it as well for compatibility with older publishers.
|
|
502
|
+
const LOCATION_HINT_PARAMETERS: ReadonlySet<string> = new Set([
|
|
503
|
+
"@gateway",
|
|
504
|
+
"gateways",
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Converts an [FEP-ef61] compatible identifier into a portable ActivityPub
|
|
509
|
+
* URI.
|
|
510
|
+
*
|
|
511
|
+
* A compatible identifier is an HTTP(S) URL under a gateway's fixed
|
|
512
|
+
* `/.well-known/apgateway/` path, such as
|
|
513
|
+
* `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
|
|
514
|
+
* This function removes the gateway part and returns the corresponding
|
|
515
|
+
* portable URI, e.g., `ap+ef61://did:key:z6Mk.../objects/1`, in the same
|
|
516
|
+
* internal `URL` form that {@link parseIri} produces. The path, query, and
|
|
517
|
+
* fragment are preserved, so the result is a portable URI, not a comparison
|
|
518
|
+
* form; pass its `href` to {@link canonicalizePortableUri} or
|
|
519
|
+
* {@link arePortableUrisEqual} to compare it with other portable URIs.
|
|
520
|
+
*
|
|
521
|
+
* The conversion only reveals the *claimed* portable identifier. Anyone can
|
|
522
|
+
* publish a compatible identifier for any DID on their own server, so the
|
|
523
|
+
* gateway that served it is neither the object's origin nor authorized to act
|
|
524
|
+
* for the DID. Callers must still verify the retrieved document's Object
|
|
525
|
+
* Integrity Proof against the DID, as FEP-ef61 requires, and should keep the
|
|
526
|
+
* original URL if they need the gateway as a retrieval hint.
|
|
527
|
+
*
|
|
528
|
+
* Arbitrary gateway paths are not supported.
|
|
529
|
+
*
|
|
530
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
531
|
+
*
|
|
532
|
+
* @param input The URL to convert.
|
|
533
|
+
* @returns The portable ActivityPub URI, or `null` if the input is not an
|
|
534
|
+
* HTTP(S) URL whose path starts with `/.well-known/apgateway/did:`.
|
|
535
|
+
* Other gateway routes, such as the gateway discovery endpoint and
|
|
536
|
+
* hashlink media URLs, also yield `null`.
|
|
537
|
+
* @throws {TypeError} If the input looks like a compatible identifier but is
|
|
538
|
+
* malformed, e.g., it has an invalid DID, no object path,
|
|
539
|
+
* invalid percent-encoding, credentials, or location
|
|
540
|
+
* hints (`@gateway` query parameters, or the legacy
|
|
541
|
+
* `gateways` parameter), or its raw string path would
|
|
542
|
+
* change during URL parsing. Already-parsed `URL`
|
|
543
|
+
* arguments cannot reveal segments lost by their parser.
|
|
544
|
+
* @since 2.4.0
|
|
545
|
+
*/
|
|
546
|
+
export function fromCompatibleEf61Id(input: string | URL): URL | null {
|
|
547
|
+
return convertCompatibleEf61Id(input)?.url ?? null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function convertCompatibleEf61Id(
|
|
551
|
+
input: string | URL,
|
|
552
|
+
): { url: URL; iri: string } | null {
|
|
553
|
+
let url: URL;
|
|
554
|
+
const rawPrefix = typeof input === "string"
|
|
555
|
+
? input.match(RAW_COMPATIBLE_ID_PREFIX_PATTERN)
|
|
556
|
+
: null;
|
|
557
|
+
if (input instanceof URL) url = input;
|
|
558
|
+
else if (typeof input === "string" && URL.canParse(input)) {
|
|
559
|
+
url = new URL(input);
|
|
560
|
+
} else return null;
|
|
561
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
562
|
+
// A raw compatible ID may lose path segments before URL.pathname is read.
|
|
563
|
+
// Likewise, preprocessing must not turn a different raw string into one.
|
|
564
|
+
if (typeof input === "string" && rawPrefix == null) {
|
|
565
|
+
if (
|
|
566
|
+
url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX) &&
|
|
567
|
+
COMPATIBLE_ID_DID_PATTERN.test(
|
|
568
|
+
url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length),
|
|
569
|
+
)
|
|
570
|
+
) {
|
|
571
|
+
throw new TypeError("Invalid FEP-ef61 compatible identifier.");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) {
|
|
575
|
+
if (rawPrefix != null) {
|
|
576
|
+
throw new TypeError("Invalid FEP-ef61 compatible identifier.");
|
|
577
|
+
}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
const tail = url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length);
|
|
581
|
+
if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) {
|
|
582
|
+
if (rawPrefix != null) {
|
|
583
|
+
throw new TypeError("Invalid FEP-ef61 compatible identifier.");
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
if (url.username !== "" || url.password !== "") {
|
|
588
|
+
throw new TypeError(
|
|
589
|
+
"Invalid FEP-ef61 compatible identifier: credentials are not allowed.",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
// Slice href instead of concatenating pathname, search, and hash, because
|
|
593
|
+
// the latter two drop empty query and fragment delimiters.
|
|
594
|
+
const iri = "ap+ef61://" +
|
|
595
|
+
url.href.slice(url.origin.length + COMPATIBLE_ID_PATH_PREFIX.length);
|
|
596
|
+
try {
|
|
597
|
+
const parsed = parsePortableIri(iri);
|
|
598
|
+
if (parsed == null) throw new TypeError("Not a portable IRI.");
|
|
599
|
+
if (
|
|
600
|
+
typeof input === "string" && rawPrefix != null &&
|
|
601
|
+
canonicalizePortableUri("ap://" + input.slice(rawPrefix[0].length)) !==
|
|
602
|
+
canonicalizePortableUri(iri)
|
|
603
|
+
) {
|
|
604
|
+
throw new TypeError("URL parsing changed the portable identifier.");
|
|
605
|
+
}
|
|
606
|
+
// parsePortableIri() does not validate path and fragment
|
|
607
|
+
// percent-encoding, but canonicalizePortableUri() does:
|
|
608
|
+
canonicalizePortableUri(iri);
|
|
609
|
+
// canonicalizePortableUri() ignores the query, so validate it here too.
|
|
610
|
+
// Compatible identifiers must not have location hints:
|
|
611
|
+
const query = url.search === ""
|
|
612
|
+
? []
|
|
613
|
+
: normalizePortableComponent(url.search.slice(1)).split("&");
|
|
614
|
+
if (query.some(isLocationHint)) {
|
|
615
|
+
throw new TypeError("Location hints are not allowed.");
|
|
616
|
+
}
|
|
617
|
+
return { url: parsed, iri };
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if (error instanceof TypeError) {
|
|
620
|
+
throw new TypeError("Invalid FEP-ef61 compatible identifier.", {
|
|
621
|
+
cause: error,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Converts a portable ActivityPub URI into an [FEP-ef61] compatible
|
|
630
|
+
* identifier, which is an HTTP(S) URL under the gateway's fixed
|
|
631
|
+
* `/.well-known/apgateway/` path.
|
|
632
|
+
*
|
|
633
|
+
* For example, `ap+ef61://did:key:z6Mk.../objects/1` and
|
|
634
|
+
* `https://server.example` yield
|
|
635
|
+
* `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
|
|
636
|
+
* Both `ap:` and `ap+ef61:` URIs with decoded or percent-encoded DID
|
|
637
|
+
* authorities are accepted. Publishers should use the first gateway in the
|
|
638
|
+
* actor's `gateways` list, as FEP-ef61 requires.
|
|
639
|
+
*
|
|
640
|
+
* The path and fragment are preserved, with characters that are not allowed
|
|
641
|
+
* in HTTP(S) URLs percent-encoded the same way as
|
|
642
|
+
* {@link canonicalizePortableUri} does. FEP-ef61 location hints (`@gateway`
|
|
643
|
+
* query parameters, and the legacy `gateways` parameter) are removed, since
|
|
644
|
+
* compatible identifiers must not have them; other query parameters are kept
|
|
645
|
+
* in order.
|
|
646
|
+
*
|
|
647
|
+
* Arbitrary gateway paths are not supported.
|
|
648
|
+
*
|
|
649
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
650
|
+
*
|
|
651
|
+
* @param portableId The `ap:` or `ap+ef61:` URI to convert.
|
|
652
|
+
* @param gateway The gateway's HTTP(S) origin, e.g., `https://server.example`.
|
|
653
|
+
* @returns The compatible identifier.
|
|
654
|
+
* @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
|
|
655
|
+
* URI, if its path has `.` or `..` segments (which
|
|
656
|
+
* HTTP(S) URLs cannot represent without changing the
|
|
657
|
+
* identified object), or if the gateway is
|
|
658
|
+
* not an HTTP(S) origin with no credentials, path, query,
|
|
659
|
+
* or fragment.
|
|
660
|
+
* @since 2.4.0
|
|
661
|
+
*/
|
|
662
|
+
export function toCompatibleEf61Id(
|
|
663
|
+
portableId: string | URL,
|
|
664
|
+
gateway: string | URL,
|
|
665
|
+
): URL {
|
|
666
|
+
const gatewayUrl = parseCompatibleEf61Gateway(gateway);
|
|
667
|
+
const raw = getRawPortableIri(portableId);
|
|
668
|
+
const match = raw.match(PORTABLE_IRI_PATTERN);
|
|
669
|
+
const parsed = parsePortableIri(raw);
|
|
670
|
+
if (match == null || parsed == null) {
|
|
671
|
+
throw new TypeError("Invalid portable ActivityPub IRI.");
|
|
672
|
+
}
|
|
673
|
+
// The parser decodes %25 once in a did:-prefixed authority, so escape
|
|
674
|
+
// percent signs only when that would otherwise change the DID. Other DIDs
|
|
675
|
+
// are kept literal (e.g., did:web:example.com%3A8080) so that gateways
|
|
676
|
+
// which read the path segment as is see the same DID:
|
|
677
|
+
let did = decodePortableAuthority(parsed.host);
|
|
678
|
+
if (/%25/i.test(did)) did = did.replace(/%/g, "%25");
|
|
679
|
+
const path = normalizePortableComponent(match[3]);
|
|
680
|
+
if (path.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
681
|
+
throw new TypeError(
|
|
682
|
+
"FEP-ef61 compatible identifiers cannot represent portable IRI paths " +
|
|
683
|
+
"with dot segments.",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
// Normalize the query before looking for location hints so that characters
|
|
687
|
+
// which the URL parser strips (e.g., tabs) cannot form a hint name later:
|
|
688
|
+
const query = match[4] == null
|
|
689
|
+
? ""
|
|
690
|
+
: stripLocationHints(normalizePortableComponent(match[4].slice(1)));
|
|
691
|
+
const fragment = match[5] == null ? "" : normalizePortableComponent(match[5]);
|
|
692
|
+
const result = new URL(
|
|
693
|
+
gatewayUrl.origin + COMPATIBLE_ID_PATH_PREFIX + did + path + query +
|
|
694
|
+
fragment,
|
|
695
|
+
);
|
|
696
|
+
// Guard against URL parser normalization that would silently change the
|
|
697
|
+
// identified object or reintroduce location hints (the latter makes
|
|
698
|
+
// convertCompatibleEf61Id() throw):
|
|
699
|
+
const converted = convertCompatibleEf61Id(result);
|
|
700
|
+
if (
|
|
701
|
+
converted == null ||
|
|
702
|
+
canonicalizePortableUri(converted.iri) !== canonicalizePortableUri(raw)
|
|
703
|
+
) {
|
|
704
|
+
throw new TypeError(
|
|
705
|
+
"The portable ActivityPub IRI cannot be represented as an FEP-ef61 " +
|
|
706
|
+
"compatible identifier.",
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function parseCompatibleEf61Gateway(gateway: string | URL): URL {
|
|
713
|
+
const url = gateway instanceof URL
|
|
714
|
+
? gateway
|
|
715
|
+
: typeof gateway === "string" && URL.canParse(gateway)
|
|
716
|
+
? new URL(gateway)
|
|
717
|
+
: null;
|
|
718
|
+
if (url == null || !isGatewayUrl(url)) {
|
|
719
|
+
throw new TypeError(
|
|
720
|
+
"FEP-ef61 gateways for compatible identifiers must be HTTP(S) origins " +
|
|
721
|
+
"with no credentials, path, query, or fragment.",
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
return url;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function getRawPortableIri(portableId: string | URL): string {
|
|
728
|
+
if (portableId instanceof URL) {
|
|
729
|
+
if (portableId.protocol !== "ap:" && portableId.protocol !== "ap+ef61:") {
|
|
730
|
+
throw new TypeError("Invalid portable ActivityPub IRI.");
|
|
731
|
+
}
|
|
732
|
+
// parseIri() would fold a port into the DID and drop credentials:
|
|
733
|
+
if (
|
|
734
|
+
portableId.username !== "" || portableId.password !== "" ||
|
|
735
|
+
portableId.port !== ""
|
|
736
|
+
) {
|
|
737
|
+
throw new TypeError("Invalid portable ActivityPub IRI authority.");
|
|
738
|
+
}
|
|
739
|
+
return portableId.href;
|
|
740
|
+
}
|
|
741
|
+
if (typeof portableId !== "string") {
|
|
742
|
+
throw new TypeError("Invalid portable ActivityPub IRI.");
|
|
743
|
+
}
|
|
744
|
+
return portableId;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function stripLocationHints(query: string): string {
|
|
748
|
+
const pairs = query.split("&").filter((pair) => !isLocationHint(pair));
|
|
749
|
+
return pairs.length < 1 ? "" : `?${pairs.join("&")}`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function isLocationHint(pair: string): boolean {
|
|
753
|
+
const name = pair.split("=", 1)[0].replace(/\+/g, " ");
|
|
754
|
+
try {
|
|
755
|
+
return LOCATION_HINT_PARAMETERS.has(decodeURIComponent(name));
|
|
756
|
+
} catch (error) {
|
|
757
|
+
if (error instanceof URIError) return false;
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The name of the FEP-ef61 location hint query parameter.
|
|
764
|
+
* @internal
|
|
765
|
+
*/
|
|
766
|
+
export const GATEWAY_HINT_PARAMETER = "@gateway";
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Parses an FEP-ef61 gateway, which has to be an HTTP(S) origin with no
|
|
770
|
+
* credentials, path, query, or fragment.
|
|
771
|
+
* @returns The gateway, or `null` if it is not a valid gateway.
|
|
772
|
+
* @internal
|
|
773
|
+
*/
|
|
774
|
+
export function parseGatewayOrigin(gateway: string | URL): URL | null {
|
|
775
|
+
let url: URL;
|
|
776
|
+
if (gateway instanceof URL) url = new URL(gateway.href);
|
|
777
|
+
else if (typeof gateway === "string" && URL.canParse(gateway)) {
|
|
778
|
+
url = new URL(gateway);
|
|
779
|
+
} else return null;
|
|
780
|
+
return isGatewayUrl(url) ? url : null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Returns a copy of an [FEP-ef61] portable ActivityPub URI with `@gateway`
|
|
785
|
+
* location hints for the given gateways, which tell consumers where they can
|
|
786
|
+
* retrieve the object. Put hints on *references* to portable actors, e.g.,
|
|
787
|
+
* in `actor`, `attributedTo`, `to`, or `cc`, when constructing an object, as
|
|
788
|
+
* FEP-ef61 recommends:
|
|
789
|
+
*
|
|
790
|
+
* ~~~~ typescript
|
|
791
|
+
* withGatewayHints("ap://did:key:z6Mk.../actor", [
|
|
792
|
+
* "https://server1.example",
|
|
793
|
+
* "https://server2.example",
|
|
794
|
+
* ]);
|
|
795
|
+
* // ap+ef61://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example
|
|
796
|
+
* ~~~~
|
|
797
|
+
*
|
|
798
|
+
* Do not put hints on an object's own `id`. Hints do not change the
|
|
799
|
+
* identity of a portable URI, since FEP-ef61 drops the query when comparing
|
|
800
|
+
* portable URIs, but implementations that do not canonicalize portable URIs
|
|
801
|
+
* would take a hinted ID for another object. Add hints before signing the
|
|
802
|
+
* object, since its Object Integrity Proof covers its references too.
|
|
803
|
+
*
|
|
804
|
+
* The hints that the URI already has, including the legacy `gateways`
|
|
805
|
+
* parameter, are replaced. Each gateway becomes a `@gateway` query
|
|
806
|
+
* parameter whose value is its URI-encoded origin, e.g.,
|
|
807
|
+
* `@gateway=https%3A%2F%2Fserver1.example`, in the given order after the
|
|
808
|
+
* other query parameters. Duplicate gateways are dropped, and an empty list
|
|
809
|
+
* removes the hints as {@link withoutGatewayHints} does. The other query
|
|
810
|
+
* parameters, their order, and the fragment are kept, but percent-encoding
|
|
811
|
+
* is normalized the same way as {@link canonicalizePortableUri} does it.
|
|
812
|
+
*
|
|
813
|
+
* Fedify follows at most five hints when dereferencing a portable URI
|
|
814
|
+
* (three for the key ID of an HTTP Signature), so list the preferred
|
|
815
|
+
* gateways first; there is no limit on the number of hints added here.
|
|
816
|
+
*
|
|
817
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
818
|
+
*
|
|
819
|
+
* @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
|
|
820
|
+
* than a `URL` if its path may have `.` or `..` segments,
|
|
821
|
+
* because the `URL` class resolves them.
|
|
822
|
+
* @param gateways The gateways, e.g., the `gateways` of the actor that the
|
|
823
|
+
* URI refers to. Each has to be an HTTP(S) origin with no
|
|
824
|
+
* credentials, path, query, or fragment.
|
|
825
|
+
* @returns The portable URI with the hints, in the same internal `URL` form
|
|
826
|
+
* as {@link parseIri} returns. Use {@link formatIri} to get its
|
|
827
|
+
* canonical string.
|
|
828
|
+
* @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
|
|
829
|
+
* URI, e.g., it is a compatible identifier, which must
|
|
830
|
+
* not have location hints; if its path has `.` or `..`
|
|
831
|
+
* segments, which the `URL` class cannot represent; or
|
|
832
|
+
* if a gateway is invalid.
|
|
833
|
+
* @since 2.4.0
|
|
834
|
+
*/
|
|
835
|
+
export function withGatewayHints(
|
|
836
|
+
portableId: string | URL,
|
|
837
|
+
gateways: Iterable<string | URL>,
|
|
838
|
+
): URL {
|
|
839
|
+
const parts = splitPortableIri(portableId);
|
|
840
|
+
if (typeof gateways === "string") {
|
|
841
|
+
throw new TypeError(
|
|
842
|
+
"The gateways must be an iterable of gateways, not a string.",
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
const hints: URL[] = [];
|
|
846
|
+
const seen = new Set<string>();
|
|
847
|
+
for (const gateway of gateways) {
|
|
848
|
+
const url = parseGatewayOrigin(gateway);
|
|
849
|
+
if (url == null) {
|
|
850
|
+
throw new TypeError(
|
|
851
|
+
"FEP-ef61 gateways must be HTTP(S) origins with no credentials, " +
|
|
852
|
+
"path, query, or fragment: " + String(gateway),
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (seen.has(url.href)) continue;
|
|
856
|
+
seen.add(url.href);
|
|
857
|
+
hints.push(url);
|
|
858
|
+
}
|
|
859
|
+
return replaceGatewayHints(parts, hints);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Returns a copy of an [FEP-ef61] portable ActivityPub URI without its
|
|
864
|
+
* location hints, i.e., `@gateway` query parameters and the legacy
|
|
865
|
+
* `gateways` parameter. The other query parameters, their order, and the
|
|
866
|
+
* fragment are kept, but percent-encoding is normalized the same way as
|
|
867
|
+
* {@link canonicalizePortableUri} does it.
|
|
868
|
+
*
|
|
869
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
870
|
+
*
|
|
871
|
+
* @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
|
|
872
|
+
* than a `URL` if its path may have `.` or `..` segments,
|
|
873
|
+
* because the `URL` class resolves them.
|
|
874
|
+
* @returns The portable URI without the hints, in the same internal `URL`
|
|
875
|
+
* form as {@link parseIri} returns.
|
|
876
|
+
* @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
|
|
877
|
+
* URI, or if its path has `.` or `..` segments, which
|
|
878
|
+
* the `URL` class cannot represent.
|
|
879
|
+
* @since 2.4.0
|
|
880
|
+
*/
|
|
881
|
+
export function withoutGatewayHints(portableId: string | URL): URL {
|
|
882
|
+
return replaceGatewayHints(splitPortableIri(portableId), []);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Gets the gateways in the `@gateway` location hints of an [FEP-ef61]
|
|
887
|
+
* portable ActivityPub URI, in order. Hints that are not valid gateways,
|
|
888
|
+
* i.e., HTTP(S) origins with no credentials, path, query, or fragment, are
|
|
889
|
+
* skipped, and so are duplicates. The legacy `gateways` parameter is not
|
|
890
|
+
* read.
|
|
891
|
+
*
|
|
892
|
+
* Unlike Fedify's dereferencing, which follows at most five hints, this
|
|
893
|
+
* returns all of them.
|
|
894
|
+
*
|
|
895
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
896
|
+
*
|
|
897
|
+
* @param portableId The `ap:` or `ap+ef61:` URI.
|
|
898
|
+
* @returns The gateways, e.g., `https://server1.example/`.
|
|
899
|
+
* @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
|
|
900
|
+
* URI.
|
|
901
|
+
* @since 2.4.0
|
|
902
|
+
*/
|
|
903
|
+
export function getGatewayHints(portableId: string | URL): URL[] {
|
|
904
|
+
const { query } = splitPortableIri(portableId);
|
|
905
|
+
return query == null ? [] : parseGatewayHints(query);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
interface PortableIriParts {
|
|
909
|
+
/** The portable ID as it was given, or the `href` of a `URL`. */
|
|
910
|
+
readonly raw: string;
|
|
911
|
+
/** The parsed portable ID. */
|
|
912
|
+
readonly parsed: URL;
|
|
913
|
+
/** The normalized path. */
|
|
914
|
+
readonly path: string;
|
|
915
|
+
/** The normalized query without `?`, or `null` if there is none. */
|
|
916
|
+
readonly query: string | null;
|
|
917
|
+
/** The normalized fragment with `#`, or an empty string if there is none. */
|
|
918
|
+
readonly fragment: string;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function splitPortableIri(portableId: string | URL): PortableIriParts {
|
|
922
|
+
const raw = getRawPortableIri(portableId);
|
|
923
|
+
const match = raw.match(PORTABLE_IRI_PATTERN);
|
|
924
|
+
const parsed = parsePortableIri(raw);
|
|
925
|
+
if (match == null || parsed == null) {
|
|
926
|
+
throw new TypeError("Invalid portable ActivityPub IRI.");
|
|
927
|
+
}
|
|
928
|
+
// Normalize the components before looking at them, as the URL parser would
|
|
929
|
+
// otherwise strip characters such as tabs later, which could turn
|
|
930
|
+
// an unrelated query parameter into a location hint:
|
|
931
|
+
return {
|
|
932
|
+
raw,
|
|
933
|
+
parsed,
|
|
934
|
+
path: normalizePortableComponent(match[3]),
|
|
935
|
+
query: match[4] == null
|
|
936
|
+
? null
|
|
937
|
+
: normalizePortableComponent(match[4].slice(1)),
|
|
938
|
+
fragment: match[5] == null ? "" : normalizePortableComponent(match[5]),
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function parseGatewayHints(query: string): URL[] {
|
|
943
|
+
const hints: URL[] = [];
|
|
944
|
+
const seen = new Set<string>();
|
|
945
|
+
// Keep the delimiter, as URLSearchParams would otherwise strip a leading
|
|
946
|
+
// question mark that is part of the first parameter's name:
|
|
947
|
+
for (
|
|
948
|
+
const hint of new URLSearchParams(`?${query}`).getAll(
|
|
949
|
+
GATEWAY_HINT_PARAMETER,
|
|
950
|
+
)
|
|
951
|
+
) {
|
|
952
|
+
const url = parseGatewayOrigin(hint);
|
|
953
|
+
if (url == null || seen.has(url.href)) continue;
|
|
954
|
+
seen.add(url.href);
|
|
955
|
+
hints.push(url);
|
|
956
|
+
}
|
|
957
|
+
return hints;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function replaceGatewayHints(
|
|
961
|
+
parts: PortableIriParts,
|
|
962
|
+
hints: readonly URL[],
|
|
963
|
+
): URL {
|
|
964
|
+
const pairs = parts.query == null
|
|
965
|
+
? []
|
|
966
|
+
: parts.query.split("&").filter((pair) =>
|
|
967
|
+
pair !== "" && !isLocationHint(pair)
|
|
968
|
+
);
|
|
969
|
+
for (const hint of hints) {
|
|
970
|
+
pairs.push(`${GATEWAY_HINT_PARAMETER}=${encodeURIComponent(hint.origin)}`);
|
|
971
|
+
}
|
|
972
|
+
const query = pairs.length < 1 ? "" : `?${pairs.join("&")}`;
|
|
973
|
+
const result = parsePortableIri(
|
|
974
|
+
`ap+ef61://${parts.parsed.host}${parts.path}${query}${parts.fragment}`,
|
|
975
|
+
);
|
|
976
|
+
// Guard against URL parser normalization that would silently change
|
|
977
|
+
// the referenced object (e.g., dot segments) or its hints:
|
|
978
|
+
if (
|
|
979
|
+
result == null ||
|
|
980
|
+
canonicalizePortableUri(result.href) !==
|
|
981
|
+
canonicalizePortableUri(parts.raw) ||
|
|
982
|
+
parseGatewayHints(result.search.slice(1)).map((url) => url.href).join(
|
|
983
|
+
" ",
|
|
984
|
+
) !== hints.map((url) => url.href).join(" ")
|
|
985
|
+
) {
|
|
986
|
+
throw new TypeError(
|
|
987
|
+
"The portable ActivityPub IRI cannot be represented as a URL without " +
|
|
988
|
+
"changing the object it refers to.",
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
993
|
+
|
|
326
994
|
/**
|
|
327
995
|
* Validates a URL to prevent SSRF attacks.
|
|
328
996
|
*/
|
|
@@ -364,13 +1032,45 @@ export async function validatePublicUrl(url: string): Promise<void> {
|
|
|
364
1032
|
// and ensure that they are all public:
|
|
365
1033
|
let addresses: LookupAddress[];
|
|
366
1034
|
try {
|
|
367
|
-
addresses = await lookup(hostname, { all: true });
|
|
368
|
-
} catch {
|
|
369
|
-
|
|
1035
|
+
addresses = await dns.lookup(hostname, { all: true });
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
throw new UrlError("DNS lookup failed", { cause: error, reason: "dns" });
|
|
370
1038
|
}
|
|
371
|
-
|
|
1039
|
+
validateLookupAddresses(addresses);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
1044
|
+
*
|
|
1045
|
+
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
1046
|
+
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
1047
|
+
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
1048
|
+
* contain an IPv4 or IPv6 literal. See:
|
|
1049
|
+
* https://github.com/cloudflare/workerd/issues/6886
|
|
1050
|
+
*
|
|
1051
|
+
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
1052
|
+
* returns at least one actual IP address. This remains fail-closed: a result
|
|
1053
|
+
* containing no IP addresses is rejected, and every returned IP address is
|
|
1054
|
+
* still validated and must be public. This workaround can be revisited once
|
|
1055
|
+
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
1056
|
+
*
|
|
1057
|
+
* @internal
|
|
1058
|
+
*/
|
|
1059
|
+
export function validateLookupAddresses(
|
|
1060
|
+
addresses: readonly LookupAddress[],
|
|
1061
|
+
): void {
|
|
1062
|
+
let ipAddressCount = 0;
|
|
1063
|
+
for (const { address } of addresses) {
|
|
1064
|
+
const family = isIP(address);
|
|
1065
|
+
if (family === 0) continue;
|
|
1066
|
+
ipAddressCount++;
|
|
372
1067
|
validatePublicIpAddress(address, family);
|
|
373
1068
|
}
|
|
1069
|
+
if (ipAddressCount === 0) {
|
|
1070
|
+
throw new UrlError("DNS lookup did not return any IP address", {
|
|
1071
|
+
reason: "dns",
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
374
1074
|
}
|
|
375
1075
|
|
|
376
1076
|
function validatePublicIpAddress(address: string, family: number): void {
|