@fedify/vocab-runtime 2.4.0-pr.936.41 → 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-Ck8bcBir.mjs → contexts-BK1CqBR5.cjs} +164 -275
- package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DPuJ4UYL.js} +160 -294
- package/dist/{docloader-C_dir7Xb.d.ts → docloader-CYwqh5Df.d.cts} +66 -2
- package/dist/{docloader-D2DTRiyA.d.cts → docloader-CYwqh5Df.d.ts} +66 -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 +711 -4527
- package/dist/mod.d.cts +400 -8
- package/dist/mod.d.ts +400 -9
- package/dist/mod.js +692 -4522
- 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-uk51rkhO.cjs → request-8vPWtV1-.cjs} +10 -4
- package/dist/tests/{request-C8CaGwtt.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 +489 -6
- package/dist/tests/url.test.mjs +489 -5
- 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/miscellany.json +17 -0
- package/src/contexts.ts +39 -1
- package/src/digest.test.ts +220 -0
- package/src/digest.ts +229 -0
- package/src/docloader.test.ts +918 -27
- package/src/docloader.ts +320 -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 +28 -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 +823 -3
- package/src/url.ts +700 -23
- package/tsdown.config.ts +2 -0
- package/dist/tests/url-CsOV_B_P.cjs +0 -482
- package/dist/tests/url-Du7RQQgP.mjs +0 -392
- package/dist/url-DD4F0ULf.cjs +0 -483
- package/dist/url-DGVbSVVi.js +0 -393
- /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/key.ts
CHANGED
|
@@ -174,12 +174,22 @@ export async function importDidKey(did: string | URL): Promise<CryptoKey> {
|
|
|
174
174
|
/**
|
|
175
175
|
* Exports an Ed25519 public key as a `did:key` DID.
|
|
176
176
|
*
|
|
177
|
+
* The DID is encoded in base58-btc with the Ed25519 multicodec prefix, e.g.,
|
|
178
|
+
* `did:key:z6Mk...`, as [FEP-ef61] requires for the DIDs of portable
|
|
179
|
+
* objects.
|
|
180
|
+
*
|
|
181
|
+
* [FEP-ef61]: https://w3id.org/fep/ef61
|
|
182
|
+
*
|
|
177
183
|
* @param key The Ed25519 public key.
|
|
178
184
|
* @returns The `did:key` DID.
|
|
179
|
-
* @throws {TypeError} If the key is invalid or unsupported
|
|
185
|
+
* @throws {TypeError} If the key is invalid or unsupported, or it is not
|
|
186
|
+
* a public key.
|
|
180
187
|
* @since 2.4.0
|
|
181
188
|
*/
|
|
182
189
|
export async function exportDidKey(key: CryptoKey): Promise<string> {
|
|
190
|
+
if (key.type !== "public") {
|
|
191
|
+
throw new TypeError("The key must be a public key.");
|
|
192
|
+
}
|
|
183
193
|
if (key.algorithm.name !== "Ed25519") {
|
|
184
194
|
throw new TypeError(
|
|
185
195
|
"Unsupported key type: " + JSON.stringify(key.algorithm),
|
package/src/mod.ts
CHANGED
|
@@ -15,6 +15,8 @@ export {
|
|
|
15
15
|
type GetDocumentLoaderOptions,
|
|
16
16
|
getRemoteDocument,
|
|
17
17
|
type RemoteDocument,
|
|
18
|
+
resolveDocumentLoaderTimeout,
|
|
19
|
+
withDocumentLoaderTimeout,
|
|
18
20
|
} from "./docloader.ts";
|
|
19
21
|
export {
|
|
20
22
|
type DidKeyVerificationMethod,
|
|
@@ -34,7 +36,28 @@ export {
|
|
|
34
36
|
isDecimal,
|
|
35
37
|
parseDecimal,
|
|
36
38
|
} from "./decimal.ts";
|
|
39
|
+
export {
|
|
40
|
+
computeDigestMultibase,
|
|
41
|
+
createHashlink,
|
|
42
|
+
type ParsedDigestMultibase,
|
|
43
|
+
type ParsedHashlink,
|
|
44
|
+
parseDigestMultibase,
|
|
45
|
+
parseHashlink,
|
|
46
|
+
verifyDigestMultibase,
|
|
47
|
+
verifyHashlink,
|
|
48
|
+
} from "./digest.ts";
|
|
37
49
|
export { LanguageString } from "./langstr.ts";
|
|
50
|
+
export {
|
|
51
|
+
fetchPortableMedia,
|
|
52
|
+
type FetchPortableMediaOptions,
|
|
53
|
+
type PortableMedia,
|
|
54
|
+
} from "./portable-media.ts";
|
|
55
|
+
export type {
|
|
56
|
+
PortableObjectReferrer,
|
|
57
|
+
PortableObjectVerification,
|
|
58
|
+
PortableObjectVerifier,
|
|
59
|
+
PortableObjectVerifierOptions,
|
|
60
|
+
} from "./portable.ts";
|
|
38
61
|
export {
|
|
39
62
|
decodeMultibase,
|
|
40
63
|
encodeMultibase,
|
|
@@ -58,7 +81,9 @@ export {
|
|
|
58
81
|
canonicalizePortableUri,
|
|
59
82
|
expandIPv6Address,
|
|
60
83
|
formatIri,
|
|
84
|
+
fromCompatibleEf61Id,
|
|
61
85
|
getFe34Origin,
|
|
86
|
+
getGatewayHints,
|
|
62
87
|
haveSameFe34Origin,
|
|
63
88
|
haveSameIriOrigin,
|
|
64
89
|
isGatewayUrl,
|
|
@@ -67,6 +92,9 @@ export {
|
|
|
67
92
|
parseGatewayUrl,
|
|
68
93
|
parseIri,
|
|
69
94
|
parseJsonLdId,
|
|
95
|
+
toCompatibleEf61Id,
|
|
70
96
|
UrlError,
|
|
71
97
|
validatePublicUrl,
|
|
98
|
+
withGatewayHints,
|
|
99
|
+
withoutGatewayHints,
|
|
72
100
|
} from "./url.ts";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { deepStrictEqual } from "node:assert";
|
|
2
|
-
import { test } from "
|
|
2
|
+
import { test } from "@fedify/fixture";
|
|
3
3
|
import * as constants from "./constants.ts";
|
|
4
4
|
import { decodeMultibase, encodeMultibase } from "./mod.ts";
|
|
5
5
|
import type { BaseName } from "./types.d.ts";
|
|
@@ -76,7 +76,7 @@ test("multibase.encode and decode", async (t) => {
|
|
|
76
76
|
];
|
|
77
77
|
|
|
78
78
|
for (const [name, input, expectedOutput] of testCases) {
|
|
79
|
-
await t.
|
|
79
|
+
await t.step(`Encoding/Decoding ${name} with ${input}`, () => {
|
|
80
80
|
const encoded = encodeMultibase(name, encodeText(input));
|
|
81
81
|
deepStrictEqual(
|
|
82
82
|
decodeText(encoded),
|
|
@@ -96,7 +96,7 @@ test("multibase.encode and decode", async (t) => {
|
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
await t.
|
|
99
|
+
await t.step("should allow base32pad full alphabet", () => {
|
|
100
100
|
const encodedStr = "ctimaq4ygg2iegci7";
|
|
101
101
|
const decoded = decodeMultibase(encodedStr);
|
|
102
102
|
const encoded = encodeMultibase("c", decoded);
|
|
@@ -105,12 +105,12 @@ test("multibase.encode and decode", async (t) => {
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("constants", async (t) => {
|
|
108
|
-
await t.
|
|
108
|
+
await t.step("constants indexed by name", () => {
|
|
109
109
|
const names = constants.names;
|
|
110
110
|
deepStrictEqual(Object.keys(names).length, 23);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
await t.
|
|
113
|
+
await t.step("constants indexed by code", () => {
|
|
114
114
|
const codes = constants.codes;
|
|
115
115
|
deepStrictEqual(Object.keys(codes).length, 23);
|
|
116
116
|
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { deepStrictEqual, equal, rejects } from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import type { Document, Image, Link } from "@fedify/vocab";
|
|
5
|
+
import { computeDigestMultibase, createHashlink } from "./digest.ts";
|
|
6
|
+
import { fetchPortableMedia, type PortableMedia } from "./portable-media.ts";
|
|
7
|
+
|
|
8
|
+
const bytes = new TextEncoder().encode("portable image");
|
|
9
|
+
const digest = "zQmU2Jds4B2J83K7CrYC34CEJdB6vCLyqkM2cgBxYeaNWTA";
|
|
10
|
+
const hashlink = createHashlink(digest);
|
|
11
|
+
|
|
12
|
+
const networkTest = (name: string, run: () => Promise<void>) =>
|
|
13
|
+
test(name, async (t) => {
|
|
14
|
+
if (
|
|
15
|
+
"Deno" in globalThis &&
|
|
16
|
+
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
17
|
+
) {
|
|
18
|
+
t.skip("Requires network permission for a local test server.");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await run();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type Handler = (path: string) => Response | undefined;
|
|
25
|
+
|
|
26
|
+
async function withServers(
|
|
27
|
+
handlers: Handler[],
|
|
28
|
+
run: (origins: string[]) => Promise<void>,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const servers = handlers.map((handler) =>
|
|
31
|
+
createServer(async (request, reply) => {
|
|
32
|
+
const result = handler(request.url ?? "/");
|
|
33
|
+
if (result == null) return;
|
|
34
|
+
reply.writeHead(result.status, Object.fromEntries(result.headers));
|
|
35
|
+
reply.end(new Uint8Array(await result.arrayBuffer()));
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
try {
|
|
39
|
+
const origins: string[] = [];
|
|
40
|
+
for (const server of servers) {
|
|
41
|
+
await new Promise<void>((resolve, reject) => {
|
|
42
|
+
server.once("error", reject);
|
|
43
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
44
|
+
});
|
|
45
|
+
const address = server.address();
|
|
46
|
+
if (address == null || typeof address === "string") {
|
|
47
|
+
throw new Error("Server did not bind a TCP port.");
|
|
48
|
+
}
|
|
49
|
+
origins.push(`http://127.0.0.1:${address.port}`);
|
|
50
|
+
}
|
|
51
|
+
await run(origins);
|
|
52
|
+
} finally {
|
|
53
|
+
await Promise.all(servers.map(async (server) => {
|
|
54
|
+
server.closeAllConnections();
|
|
55
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
networkTest(
|
|
61
|
+
"fetchPortableMedia() accepts Link, Image, and Document",
|
|
62
|
+
async () => {
|
|
63
|
+
const accepts = <T extends PortableMedia>() => {};
|
|
64
|
+
accepts<Link>();
|
|
65
|
+
accepts<Image>();
|
|
66
|
+
accepts<Document>();
|
|
67
|
+
const link: PortableMedia = {
|
|
68
|
+
href: new URL(hashlink),
|
|
69
|
+
digestMultibase: digest,
|
|
70
|
+
};
|
|
71
|
+
const image: PortableMedia = {
|
|
72
|
+
url: new URL(hashlink),
|
|
73
|
+
digestMultibase: digest,
|
|
74
|
+
};
|
|
75
|
+
const document: PortableMedia = {
|
|
76
|
+
url: { href: new URL(hashlink), digestMultibase: digest },
|
|
77
|
+
};
|
|
78
|
+
const requested: string[] = [];
|
|
79
|
+
await withServers([(path) => {
|
|
80
|
+
requested.push(path);
|
|
81
|
+
return new Response(bytes);
|
|
82
|
+
}], async ([gateway]) => {
|
|
83
|
+
for (const media of [link, image, document]) {
|
|
84
|
+
equal(
|
|
85
|
+
await (await fetchPortableMedia(media, {
|
|
86
|
+
gateways: [gateway],
|
|
87
|
+
allowPrivateAddress: true,
|
|
88
|
+
})).text(),
|
|
89
|
+
"portable image",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
deepStrictEqual(
|
|
94
|
+
requested,
|
|
95
|
+
Array(3).fill(
|
|
96
|
+
`/.well-known/apgateway/${hashlink}`,
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
networkTest(
|
|
103
|
+
"fetchPortableMedia() falls back on status and digest mismatch",
|
|
104
|
+
async () => {
|
|
105
|
+
const requested: string[] = [];
|
|
106
|
+
await withServers([
|
|
107
|
+
(path) => {
|
|
108
|
+
requested.push(`first:${path}`);
|
|
109
|
+
return new Response(null, { status: 503 });
|
|
110
|
+
},
|
|
111
|
+
(path) => {
|
|
112
|
+
requested.push(`second:${path}`);
|
|
113
|
+
return new Response("wrong bytes");
|
|
114
|
+
},
|
|
115
|
+
(path) => {
|
|
116
|
+
requested.push(`third:${path}`);
|
|
117
|
+
return new Response(bytes, {
|
|
118
|
+
headers: { "Content-Type": "image/png", "Set-Cookie": "secret=1" },
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
], async (gateways) => {
|
|
122
|
+
const result = await fetchPortableMedia(
|
|
123
|
+
{ url: new URL(hashlink), digestMultibase: digest },
|
|
124
|
+
{ gateways, allowPrivateAddress: true },
|
|
125
|
+
);
|
|
126
|
+
equal(await result.text(), "portable image");
|
|
127
|
+
equal(result.headers.get("Content-Type"), "image/png");
|
|
128
|
+
equal(result.headers.get("Set-Cookie"), null);
|
|
129
|
+
});
|
|
130
|
+
deepStrictEqual(requested.map((r) => r.split(":")[0]), [
|
|
131
|
+
"first",
|
|
132
|
+
"second",
|
|
133
|
+
"third",
|
|
134
|
+
]);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
networkTest("fetchPortableMedia() verifies direct HTTP media", async () => {
|
|
139
|
+
const wrongDigest = await computeDigestMultibase(new Uint8Array());
|
|
140
|
+
await withServers([() => new Response(bytes)], async ([origin]) => {
|
|
141
|
+
const result = await fetchPortableMedia(`${origin}/a`, {
|
|
142
|
+
digestMultibase: digest,
|
|
143
|
+
allowPrivateAddress: true,
|
|
144
|
+
});
|
|
145
|
+
equal(await result.text(), "portable image");
|
|
146
|
+
await rejects(() =>
|
|
147
|
+
fetchPortableMedia(`${origin}/a`, {
|
|
148
|
+
digestMultibase: wrongDigest,
|
|
149
|
+
allowPrivateAddress: true,
|
|
150
|
+
}), /digest does not match/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("fetchPortableMedia() rejects invalid references before fetching", async () => {
|
|
155
|
+
const wrongDigest = await computeDigestMultibase(new Uint8Array());
|
|
156
|
+
await rejects(
|
|
157
|
+
() => fetchPortableMedia({ url: new URL(hashlink) }),
|
|
158
|
+
/requires digestMultibase/,
|
|
159
|
+
);
|
|
160
|
+
await rejects(() =>
|
|
161
|
+
fetchPortableMedia({
|
|
162
|
+
url: new URL(hashlink),
|
|
163
|
+
digestMultibase: digest,
|
|
164
|
+
}), /requires at least one gateway/);
|
|
165
|
+
await rejects(() =>
|
|
166
|
+
fetchPortableMedia({
|
|
167
|
+
url: new URL(hashlink),
|
|
168
|
+
digestMultibase: wrongDigest,
|
|
169
|
+
}, { gateways: ["https://example.com"] }), /disagree/);
|
|
170
|
+
await rejects(() =>
|
|
171
|
+
fetchPortableMedia({
|
|
172
|
+
url: new URL("hl:invalid"),
|
|
173
|
+
digestMultibase: digest,
|
|
174
|
+
}, { gateways: ["https://example.com"] }), TypeError);
|
|
175
|
+
await rejects(() =>
|
|
176
|
+
fetchPortableMedia({
|
|
177
|
+
url: { href: new URL(hashlink), digestMultibase: wrongDigest },
|
|
178
|
+
digestMultibase: digest,
|
|
179
|
+
}, { gateways: ["https://example.com"] }), /digests disagree/);
|
|
180
|
+
await rejects(() =>
|
|
181
|
+
fetchPortableMedia("file:///tmp/media", {
|
|
182
|
+
digestMultibase: digest,
|
|
183
|
+
}), TypeError);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
networkTest("fetchPortableMedia() bounds response bytes", async () => {
|
|
187
|
+
await withServers([() => new Response(bytes)], async ([origin]) => {
|
|
188
|
+
await rejects(() =>
|
|
189
|
+
fetchPortableMedia(`${origin}/media`, {
|
|
190
|
+
digestMultibase: digest,
|
|
191
|
+
maxBytes: bytes.length - 1,
|
|
192
|
+
allowPrivateAddress: true,
|
|
193
|
+
}), /exceeds the limit/);
|
|
194
|
+
});
|
|
195
|
+
await rejects(() =>
|
|
196
|
+
fetchPortableMedia("https://example.com/media", {
|
|
197
|
+
digestMultibase: digest,
|
|
198
|
+
maxBytes: 0,
|
|
199
|
+
}), RangeError);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("fetchPortableMedia() rejects private media and gateways", async () => {
|
|
203
|
+
await rejects(() =>
|
|
204
|
+
fetchPortableMedia("http://127.0.0.1/media", {
|
|
205
|
+
digestMultibase: digest,
|
|
206
|
+
}), /private address/);
|
|
207
|
+
await rejects(
|
|
208
|
+
() =>
|
|
209
|
+
fetchPortableMedia({
|
|
210
|
+
url: new URL(hashlink),
|
|
211
|
+
digestMultibase: digest,
|
|
212
|
+
}, { gateways: ["http://127.0.0.1"] }),
|
|
213
|
+
/No gateway returned verified portable media/,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("fetchPortableMedia() propagates cancellation", async () => {
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const reason = new Error("cancelled");
|
|
220
|
+
controller.abort(reason);
|
|
221
|
+
await rejects(() =>
|
|
222
|
+
fetchPortableMedia({
|
|
223
|
+
url: new URL(hashlink),
|
|
224
|
+
digestMultibase: digest,
|
|
225
|
+
}, {
|
|
226
|
+
gateways: ["https://example.com"],
|
|
227
|
+
signal: controller.signal,
|
|
228
|
+
}), (error) => error === reason);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
networkTest(
|
|
232
|
+
"fetchPortableMedia() falls back after a gateway timeout",
|
|
233
|
+
async () => {
|
|
234
|
+
await withServers([
|
|
235
|
+
() => undefined,
|
|
236
|
+
() => new Response(bytes),
|
|
237
|
+
], async (gateways) => {
|
|
238
|
+
const response = await fetchPortableMedia({
|
|
239
|
+
url: new URL(hashlink),
|
|
240
|
+
digestMultibase: digest,
|
|
241
|
+
}, {
|
|
242
|
+
gateways,
|
|
243
|
+
allowPrivateAddress: true,
|
|
244
|
+
gatewayTimeout: 500,
|
|
245
|
+
timeout: 5_000,
|
|
246
|
+
});
|
|
247
|
+
equal(await response.text(), "portable image");
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
networkTest("fetchPortableMedia() applies one overall timeout", async () => {
|
|
253
|
+
await withServers(
|
|
254
|
+
[() => undefined, () => new Response(bytes)],
|
|
255
|
+
async (gateways) => {
|
|
256
|
+
await rejects(
|
|
257
|
+
() =>
|
|
258
|
+
fetchPortableMedia({
|
|
259
|
+
url: new URL(hashlink),
|
|
260
|
+
digestMultibase: digest,
|
|
261
|
+
}, {
|
|
262
|
+
gateways,
|
|
263
|
+
allowPrivateAddress: true,
|
|
264
|
+
gatewayTimeout: 100,
|
|
265
|
+
timeout: 20,
|
|
266
|
+
}),
|
|
267
|
+
(error) =>
|
|
268
|
+
error instanceof DOMException && error.name === "TimeoutError",
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
networkTest("fetchPortableMedia() follows a safe redirect", async () => {
|
|
275
|
+
const requested: string[] = [];
|
|
276
|
+
await withServers([(path) => {
|
|
277
|
+
requested.push(path);
|
|
278
|
+
return path === "/media" ? new Response(bytes) : new Response(null, {
|
|
279
|
+
status: 302,
|
|
280
|
+
headers: { Location: "/media" },
|
|
281
|
+
});
|
|
282
|
+
}], async ([gateway]) => {
|
|
283
|
+
const response = await fetchPortableMedia({
|
|
284
|
+
url: new URL(hashlink),
|
|
285
|
+
digestMultibase: digest,
|
|
286
|
+
}, { gateways: [gateway], allowPrivateAddress: true });
|
|
287
|
+
equal(await response.text(), "portable image");
|
|
288
|
+
});
|
|
289
|
+
deepStrictEqual(requested, [
|
|
290
|
+
`/.well-known/apgateway/${hashlink}`,
|
|
291
|
+
"/media",
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { MAX_BODY_SIZE, readBoundedBytes } from "./body.ts";
|
|
2
|
+
import {
|
|
3
|
+
parseDigestMultibase,
|
|
4
|
+
parseHashlink,
|
|
5
|
+
verifyDigestMultibase,
|
|
6
|
+
} from "./digest.ts";
|
|
7
|
+
import { getUserAgent, type GetUserAgentOptions } from "./request.ts";
|
|
8
|
+
import { parseGatewayUrl, validatePublicUrl } from "./url.ts";
|
|
9
|
+
|
|
10
|
+
/** A link or document with an external resource and its integrity digest. */
|
|
11
|
+
export interface PortableMedia {
|
|
12
|
+
readonly href?: URL | null;
|
|
13
|
+
readonly url?: URL | {
|
|
14
|
+
readonly href: URL | null;
|
|
15
|
+
readonly digestMultibase?: string | null;
|
|
16
|
+
} | null;
|
|
17
|
+
readonly digestMultibase?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Options for {@link fetchPortableMedia}. */
|
|
21
|
+
export interface FetchPortableMediaOptions {
|
|
22
|
+
/** Ordered FEP-ef61 gateway origins used for `hl:` resources. */
|
|
23
|
+
readonly gateways?: Iterable<string | URL>;
|
|
24
|
+
/** Expected digest when the first argument is a URL or string. */
|
|
25
|
+
readonly digestMultibase?: string;
|
|
26
|
+
/** Maximum decoded response size in bytes. Defaults to 16 MiB. */
|
|
27
|
+
readonly maxBytes?: number;
|
|
28
|
+
/** Whether private network addresses may be fetched. Defaults to `false`. */
|
|
29
|
+
readonly allowPrivateAddress?: boolean;
|
|
30
|
+
/** The HTTP `User-Agent` value or its components. */
|
|
31
|
+
readonly userAgent?: string | GetUserAgentOptions;
|
|
32
|
+
/** Cancels the entire retrieval and all gateway attempts. */
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
34
|
+
/** Overall timeout in milliseconds. Defaults to 10 seconds; `null` disables it. */
|
|
35
|
+
readonly timeout?: number | null;
|
|
36
|
+
/** Timeout per gateway in milliseconds. Defaults to 3 seconds. */
|
|
37
|
+
readonly gatewayTimeout?: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveTimeout(
|
|
41
|
+
value: number | null | undefined,
|
|
42
|
+
fallback: number,
|
|
43
|
+
): number | null {
|
|
44
|
+
if (value === null) return null;
|
|
45
|
+
const result = value ?? fallback;
|
|
46
|
+
if (!Number.isFinite(result) || result <= 0 || result > 2_147_483_647) {
|
|
47
|
+
throw new RangeError(
|
|
48
|
+
"A media timeout must be a positive finite number of milliseconds.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return Math.ceil(result);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function withTimeout<T>(
|
|
55
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
56
|
+
parent: AbortSignal | undefined,
|
|
57
|
+
timeout: number | null,
|
|
58
|
+
): Promise<T> {
|
|
59
|
+
parent?.throwIfAborted();
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const onParentAbort = () => controller.abort(parent?.reason);
|
|
62
|
+
parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
63
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
if (timeout != null) {
|
|
65
|
+
timer = setTimeout(() =>
|
|
66
|
+
controller.abort(
|
|
67
|
+
new DOMException(
|
|
68
|
+
`Media retrieval timed out after ${timeout} ms.`,
|
|
69
|
+
"TimeoutError",
|
|
70
|
+
),
|
|
71
|
+
), timeout);
|
|
72
|
+
}
|
|
73
|
+
let onAbort: (() => void) | undefined;
|
|
74
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
75
|
+
onAbort = () => reject(controller.signal.reason);
|
|
76
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([operation(controller.signal), aborted]);
|
|
80
|
+
} finally {
|
|
81
|
+
if (timer != null) clearTimeout(timer);
|
|
82
|
+
parent?.removeEventListener("abort", onParentAbort);
|
|
83
|
+
if (onAbort != null) {
|
|
84
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mediaTarget(media: string | URL | PortableMedia): {
|
|
90
|
+
url: string;
|
|
91
|
+
digestMultibase: string | null | undefined;
|
|
92
|
+
} {
|
|
93
|
+
if (typeof media === "string" || media instanceof URL) {
|
|
94
|
+
return { url: media.toString(), digestMultibase: undefined };
|
|
95
|
+
}
|
|
96
|
+
const target = media.href ?? media.url;
|
|
97
|
+
const url = target instanceof URL
|
|
98
|
+
? target.href
|
|
99
|
+
: target != null && typeof target === "object" && "href" in target
|
|
100
|
+
? target.href?.href
|
|
101
|
+
: undefined;
|
|
102
|
+
if (url == null) throw new TypeError("Portable media has no URL or href.");
|
|
103
|
+
const linkDigest = target != null && !(target instanceof URL) &&
|
|
104
|
+
typeof target === "object" && "digestMultibase" in target
|
|
105
|
+
? target.digestMultibase
|
|
106
|
+
: undefined;
|
|
107
|
+
if (media.digestMultibase != null && linkDigest != null) {
|
|
108
|
+
const outer = parseDigestMultibase(media.digestMultibase).digest;
|
|
109
|
+
const inner = parseDigestMultibase(linkDigest).digest;
|
|
110
|
+
if (outer.some((byte, i) => byte !== inner[i])) {
|
|
111
|
+
throw new TypeError("Media and linked resource digests disagree.");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { url, digestMultibase: media.digestMultibase ?? linkDigest };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fetchVerified(
|
|
118
|
+
url: URL,
|
|
119
|
+
digestMultibase: string,
|
|
120
|
+
options: FetchPortableMediaOptions,
|
|
121
|
+
signal: AbortSignal,
|
|
122
|
+
maxBytes: number,
|
|
123
|
+
): Promise<Response> {
|
|
124
|
+
const visited = new Set<string>();
|
|
125
|
+
for (let redirects = 0; redirects <= 20; redirects++) {
|
|
126
|
+
signal.throwIfAborted();
|
|
127
|
+
if (url.username !== "" || url.password !== "") {
|
|
128
|
+
throw new TypeError("Media URLs must not contain credentials.");
|
|
129
|
+
}
|
|
130
|
+
if (!options.allowPrivateAddress) await validatePublicUrl(url.href);
|
|
131
|
+
else if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
132
|
+
throw new TypeError("Media URLs must use HTTP(S).");
|
|
133
|
+
}
|
|
134
|
+
if (visited.has(url.href)) throw new TypeError("Media redirect loop.");
|
|
135
|
+
visited.add(url.href);
|
|
136
|
+
const response = await fetch(url, {
|
|
137
|
+
method: "GET",
|
|
138
|
+
headers: {
|
|
139
|
+
Accept: "*/*",
|
|
140
|
+
"User-Agent": typeof options.userAgent === "string"
|
|
141
|
+
? options.userAgent
|
|
142
|
+
: getUserAgent(options.userAgent),
|
|
143
|
+
},
|
|
144
|
+
redirect: "manual",
|
|
145
|
+
signal,
|
|
146
|
+
});
|
|
147
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
148
|
+
const location = response.headers.get("Location");
|
|
149
|
+
void response.body?.cancel().catch(() => {});
|
|
150
|
+
if (location == null || redirects === 20) {
|
|
151
|
+
throw new TypeError("Invalid or excessive media redirects.");
|
|
152
|
+
}
|
|
153
|
+
url = new URL(location, url);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (response.status !== 200) {
|
|
157
|
+
void response.body?.cancel().catch(() => {});
|
|
158
|
+
throw new Error(`Media request failed with HTTP ${response.status}.`);
|
|
159
|
+
}
|
|
160
|
+
const bytes = await readBoundedBytes(response, maxBytes, url);
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
if (!await verifyDigestMultibase(bytes, digestMultibase)) {
|
|
163
|
+
throw new Error("Portable media digest does not match.");
|
|
164
|
+
}
|
|
165
|
+
const headers = new Headers();
|
|
166
|
+
const contentType = response.headers.get("Content-Type");
|
|
167
|
+
if (contentType != null) headers.set("Content-Type", contentType);
|
|
168
|
+
return new Response(bytes, { headers });
|
|
169
|
+
}
|
|
170
|
+
throw new TypeError("Too many media redirects.");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fetches a portable object's external media and returns it only after its
|
|
175
|
+
* SHA-256 `digestMultibase` has been verified. `hl:` resources are tried at
|
|
176
|
+
* the supplied gateways in order. The result contains verified body bytes;
|
|
177
|
+
* its `Content-Type` is unverified metadata supplied by the server.
|
|
178
|
+
*
|
|
179
|
+
* @param media A Link, Image, Document, or URL with an external resource.
|
|
180
|
+
* @param options Retrieval, integrity, and network policy options.
|
|
181
|
+
* @returns A response containing only verified bytes.
|
|
182
|
+
* @throws {TypeError} If the media reference or digest is invalid.
|
|
183
|
+
* @throws {Error} If no source supplies a matching bounded resource.
|
|
184
|
+
* @since 2.4.0
|
|
185
|
+
*/
|
|
186
|
+
export async function fetchPortableMedia(
|
|
187
|
+
media: string | URL | PortableMedia,
|
|
188
|
+
options: FetchPortableMediaOptions = {},
|
|
189
|
+
): Promise<Response> {
|
|
190
|
+
const { url, digestMultibase: propertyDigest } = mediaTarget(media);
|
|
191
|
+
const digestMultibase = propertyDigest ?? options.digestMultibase;
|
|
192
|
+
if (digestMultibase == null) {
|
|
193
|
+
throw new TypeError("Portable media requires digestMultibase.");
|
|
194
|
+
}
|
|
195
|
+
const expected = parseDigestMultibase(digestMultibase).digest;
|
|
196
|
+
const maxBytes = options.maxBytes ?? MAX_BODY_SIZE;
|
|
197
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
198
|
+
throw new RangeError(
|
|
199
|
+
"The media size limit must be a positive safe integer.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const timeout = resolveTimeout(options.timeout, 10_000);
|
|
203
|
+
const gatewayTimeout = resolveTimeout(options.gatewayTimeout, 3_000);
|
|
204
|
+
let targets: URL[];
|
|
205
|
+
let hashlink = false;
|
|
206
|
+
if (/^hl:/i.test(url)) {
|
|
207
|
+
hashlink = true;
|
|
208
|
+
const parsed = parseHashlink(url);
|
|
209
|
+
const hashDigest = parseDigestMultibase(parsed.digestMultibase).digest;
|
|
210
|
+
if (expected.some((byte, i) => byte !== hashDigest[i])) {
|
|
211
|
+
throw new TypeError("Hashlink and digestMultibase disagree.");
|
|
212
|
+
}
|
|
213
|
+
const gateways = [
|
|
214
|
+
...new Set(
|
|
215
|
+
[...options.gateways ?? []].map((gateway) =>
|
|
216
|
+
parseGatewayUrl(gateway.toString()).origin
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
];
|
|
220
|
+
if (gateways.length === 0) {
|
|
221
|
+
throw new TypeError("Hashlink media requires at least one gateway.");
|
|
222
|
+
}
|
|
223
|
+
targets = gateways.map((gateway) =>
|
|
224
|
+
new URL(`/.well-known/apgateway/${url}`, gateway)
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
const target = new URL(url);
|
|
228
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
229
|
+
throw new TypeError("Portable media URLs must use HTTP(S) or hl:.");
|
|
230
|
+
}
|
|
231
|
+
targets = [target];
|
|
232
|
+
}
|
|
233
|
+
return await withTimeout(
|
|
234
|
+
async (signal) => {
|
|
235
|
+
let lastError: unknown;
|
|
236
|
+
for (const target of targets) {
|
|
237
|
+
signal.throwIfAborted();
|
|
238
|
+
try {
|
|
239
|
+
return await withTimeout(
|
|
240
|
+
(attemptSignal) =>
|
|
241
|
+
fetchVerified(
|
|
242
|
+
target,
|
|
243
|
+
digestMultibase,
|
|
244
|
+
options,
|
|
245
|
+
attemptSignal,
|
|
246
|
+
maxBytes,
|
|
247
|
+
),
|
|
248
|
+
signal,
|
|
249
|
+
hashlink ? gatewayTimeout : null,
|
|
250
|
+
);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
if (!hashlink) throw error;
|
|
254
|
+
lastError = error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new Error("No gateway returned verified portable media.", {
|
|
258
|
+
cause: lastError,
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
options.signal,
|
|
262
|
+
timeout,
|
|
263
|
+
);
|
|
264
|
+
}
|