@fedify/vocab-runtime 2.0.0-pr.451.1730

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ import fetchMock from "fetch-mock";
2
+ import { deepStrictEqual, rejects } from "node:assert";
3
+ import { test } from "node:test";
4
+ import preloadedContexts from "./contexts.ts";
5
+ import { getDocumentLoader } from "./docloader.ts";
6
+ import { FetchError } from "./request.ts";
7
+ import { UrlError } from "./url.ts";
8
+
9
+ test("new FetchError()", () => {
10
+ const e = new FetchError("https://example.com/", "An error message.");
11
+ deepStrictEqual(e.name, "FetchError");
12
+ deepStrictEqual(e.url, new URL("https://example.com/"));
13
+ deepStrictEqual(e.message, "https://example.com/: An error message.");
14
+
15
+ const e2 = new FetchError(new URL("https://example.org/"));
16
+ deepStrictEqual(e2.url, new URL("https://example.org/"));
17
+ deepStrictEqual(e2.message, "https://example.org/");
18
+ });
19
+
20
+ test("getDocumentLoader()", async (t) => {
21
+ const fetchDocumentLoader = getDocumentLoader();
22
+
23
+ fetchMock.spyGlobal();
24
+
25
+ fetchMock.get("https://example.com/object", {
26
+ body: {
27
+ "@context": "https://www.w3.org/ns/activitystreams",
28
+ id: "https://example.com/object",
29
+ name: "Fetched object",
30
+ type: "Object",
31
+ },
32
+ });
33
+
34
+ await t.test("ok", async () => {
35
+ deepStrictEqual(await fetchDocumentLoader("https://example.com/object"), {
36
+ contextUrl: null,
37
+ documentUrl: "https://example.com/object",
38
+ document: {
39
+ "@context": "https://www.w3.org/ns/activitystreams",
40
+ id: "https://example.com/object",
41
+ name: "Fetched object",
42
+ type: "Object",
43
+ },
44
+ });
45
+ });
46
+
47
+ fetchMock.get("https://example.com/link-ctx", {
48
+ body: {
49
+ id: "https://example.com/link-ctx",
50
+ name: "Fetched object",
51
+ type: "Object",
52
+ },
53
+ headers: {
54
+ "Content-Type": "application/activity+json",
55
+ Link: "<https://www.w3.org/ns/activitystreams>; " +
56
+ 'rel="http://www.w3.org/ns/json-ld#context"; ' +
57
+ 'type="application/ld+json"',
58
+ },
59
+ });
60
+
61
+ fetchMock.get("https://example.com/link-obj", {
62
+ headers: {
63
+ "Content-Type": "text/html; charset=utf-8",
64
+ Link: '<https://example.com/object>; rel="alternate"; ' +
65
+ 'type="application/activity+json"',
66
+ },
67
+ });
68
+
69
+ fetchMock.get("https://example.com/link-obj-relative", {
70
+ headers: {
71
+ "Content-Type": "text/html; charset=utf-8",
72
+ Link: '</object>; rel="alternate"; ' +
73
+ 'type="application/activity+json"',
74
+ },
75
+ });
76
+
77
+ fetchMock.get("https://example.com/obj-w-wrong-link", {
78
+ body: {
79
+ "@context": "https://www.w3.org/ns/activitystreams",
80
+ id: "https://example.com/obj-w-wrong-link",
81
+ name: "Fetched object",
82
+ type: "Object",
83
+ },
84
+ headers: {
85
+ "Content-Type": "text/html; charset=utf-8",
86
+ Link: '<https://example.com/object>; rel="alternate"; ' +
87
+ 'type="application/ld+json; profile="https://www.w3.org/ns/activitystreams""',
88
+ },
89
+ });
90
+
91
+ await t.test("Link header", async () => {
92
+ deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
93
+ contextUrl: "https://www.w3.org/ns/activitystreams",
94
+ documentUrl: "https://example.com/link-ctx",
95
+ document: {
96
+ id: "https://example.com/link-ctx",
97
+ name: "Fetched object",
98
+ type: "Object",
99
+ },
100
+ });
101
+
102
+ deepStrictEqual(await fetchDocumentLoader("https://example.com/link-obj"), {
103
+ contextUrl: null,
104
+ documentUrl: "https://example.com/object",
105
+ document: {
106
+ "@context": "https://www.w3.org/ns/activitystreams",
107
+ id: "https://example.com/object",
108
+ name: "Fetched object",
109
+ type: "Object",
110
+ },
111
+ });
112
+ });
113
+
114
+ await t.test("Link header relative url", async () => {
115
+ deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
116
+ contextUrl: "https://www.w3.org/ns/activitystreams",
117
+ documentUrl: "https://example.com/link-ctx",
118
+ document: {
119
+ id: "https://example.com/link-ctx",
120
+ name: "Fetched object",
121
+ type: "Object",
122
+ },
123
+ });
124
+
125
+ deepStrictEqual(
126
+ await fetchDocumentLoader("https://example.com/link-obj-relative"),
127
+ {
128
+ contextUrl: null,
129
+ documentUrl: "https://example.com/object",
130
+ document: {
131
+ "@context": "https://www.w3.org/ns/activitystreams",
132
+ id: "https://example.com/object",
133
+ name: "Fetched object",
134
+ type: "Object",
135
+ },
136
+ },
137
+ );
138
+ });
139
+
140
+ await t.test("wrong Link header syntax", async () => {
141
+ deepStrictEqual(
142
+ await fetchDocumentLoader("https://example.com/obj-w-wrong-link"),
143
+ {
144
+ contextUrl: null,
145
+ documentUrl: "https://example.com/obj-w-wrong-link",
146
+ document: {
147
+ "@context": "https://www.w3.org/ns/activitystreams",
148
+ id: "https://example.com/obj-w-wrong-link",
149
+ name: "Fetched object",
150
+ type: "Object",
151
+ },
152
+ },
153
+ );
154
+ });
155
+
156
+ fetchMock.get("https://example.com/html-link", {
157
+ body: `<html>
158
+ <head>
159
+ <meta charset=utf-8>
160
+ <link
161
+ rel=alternate
162
+ type='application/activity+json'
163
+ href="https://example.com/object">
164
+ </head>
165
+ </html>`,
166
+ headers: { "Content-Type": "text/html; charset=utf-8" },
167
+ });
168
+
169
+ await t.test("HTML <link>", async () => {
170
+ deepStrictEqual(
171
+ await fetchDocumentLoader("https://example.com/html-link"),
172
+ {
173
+ contextUrl: null,
174
+ documentUrl: "https://example.com/object",
175
+ document: {
176
+ "@context": "https://www.w3.org/ns/activitystreams",
177
+ id: "https://example.com/object",
178
+ name: "Fetched object",
179
+ type: "Object",
180
+ },
181
+ },
182
+ );
183
+ });
184
+
185
+ fetchMock.get("https://example.com/xhtml-link", {
186
+ body: `<html>
187
+ <head>
188
+ <meta charset="utf-8" />
189
+ <link
190
+ rel=alternate
191
+ type="application/activity+json"
192
+ href="https://example.com/object" />
193
+ </head>
194
+ </html>`,
195
+ headers: { "Content-Type": "application/xhtml+xml; charset=utf-8" },
196
+ });
197
+
198
+ await t.test("XHTML <link>", async () => {
199
+ deepStrictEqual(
200
+ await fetchDocumentLoader("https://example.com/xhtml-link"),
201
+ {
202
+ contextUrl: null,
203
+ documentUrl: "https://example.com/object",
204
+ document: {
205
+ "@context": "https://www.w3.org/ns/activitystreams",
206
+ id: "https://example.com/object",
207
+ name: "Fetched object",
208
+ type: "Object",
209
+ },
210
+ },
211
+ );
212
+ });
213
+
214
+ fetchMock.get("https://example.com/html-a", {
215
+ body: `<html>
216
+ <head>
217
+ <meta charset=utf-8>
218
+ </head>
219
+ <body>
220
+ <a
221
+ rel=alternate
222
+ type=application/activity+json
223
+ href=https://example.com/object>test</a>
224
+ </body>
225
+ </html>`,
226
+ headers: { "Content-Type": "text/html; charset=utf-8" },
227
+ });
228
+
229
+ await t.test("HTML <a>", async () => {
230
+ deepStrictEqual(await fetchDocumentLoader("https://example.com/html-a"), {
231
+ contextUrl: null,
232
+ documentUrl: "https://example.com/object",
233
+ document: {
234
+ "@context": "https://www.w3.org/ns/activitystreams",
235
+ id: "https://example.com/object",
236
+ name: "Fetched object",
237
+ type: "Object",
238
+ },
239
+ });
240
+ });
241
+
242
+ fetchMock.get("https://example.com/wrong-content-type", {
243
+ body: {
244
+ "@context": "https://www.w3.org/ns/activitystreams",
245
+ id: "https://example.com/wrong-content-type",
246
+ name: "Fetched object",
247
+ type: "Object",
248
+ },
249
+ headers: { "Content-Type": "text/html; charset=utf-8" },
250
+ });
251
+
252
+ await t.test("Wrong Content-Type", async () => {
253
+ deepStrictEqual(
254
+ await fetchDocumentLoader("https://example.com/wrong-content-type"),
255
+ {
256
+ contextUrl: null,
257
+ documentUrl: "https://example.com/wrong-content-type",
258
+ document: {
259
+ "@context": "https://www.w3.org/ns/activitystreams",
260
+ id: "https://example.com/wrong-content-type",
261
+ name: "Fetched object",
262
+ type: "Object",
263
+ },
264
+ },
265
+ );
266
+ });
267
+
268
+ fetchMock.get("https://example.com/404", { status: 404 });
269
+
270
+ await t.test("not ok", async () => {
271
+ await rejects(
272
+ () => fetchDocumentLoader("https://example.com/404"),
273
+ FetchError,
274
+ "HTTP 404: https://example.com/404",
275
+ );
276
+ });
277
+
278
+ await t.test("preloaded contexts", async () => {
279
+ for (const [url, document] of Object.entries(preloadedContexts)) {
280
+ deepStrictEqual(await fetchDocumentLoader(url), {
281
+ contextUrl: null,
282
+ documentUrl: url,
283
+ document,
284
+ });
285
+ }
286
+ });
287
+
288
+ await t.test("deny non-HTTP/HTTPS", async () => {
289
+ await rejects(
290
+ () => fetchDocumentLoader("ftp://localhost"),
291
+ UrlError,
292
+ );
293
+ });
294
+
295
+ fetchMock.get("https://example.com/localhost-redirect", {
296
+ status: 302,
297
+ headers: { Location: "https://localhost/object" },
298
+ });
299
+
300
+ fetchMock.get("https://example.com/localhost-link", {
301
+ body: `<html>
302
+ <head>
303
+ <meta charset=utf-8>
304
+ <link
305
+ rel=alternate
306
+ type='application/activity+json'
307
+ href="https://localhost/object">
308
+ </head>
309
+ </html>`,
310
+ headers: { "Content-Type": "text/html; charset=utf-8" },
311
+ });
312
+
313
+ fetchMock.get("https://localhost/object", {
314
+ body: {
315
+ "@context": "https://www.w3.org/ns/activitystreams",
316
+ id: "https://localhost/object",
317
+ name: "Fetched object",
318
+ type: "Object",
319
+ },
320
+ });
321
+
322
+ await t.test("allowPrivateAddress: false", async () => {
323
+ await rejects(
324
+ () => fetchDocumentLoader("https://localhost/object"),
325
+ UrlError,
326
+ );
327
+ await rejects(
328
+ () => fetchDocumentLoader("https://example.com/localhost-redirect"),
329
+ UrlError,
330
+ );
331
+ await rejects(
332
+ () => fetchDocumentLoader("https://example.com/localhost-link"),
333
+ UrlError,
334
+ );
335
+ });
336
+
337
+ const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
338
+
339
+ await t.test("allowPrivateAddress: true", async () => {
340
+ const expected = {
341
+ contextUrl: null,
342
+ documentUrl: "https://localhost/object",
343
+ document: {
344
+ "@context": "https://www.w3.org/ns/activitystreams",
345
+ id: "https://localhost/object",
346
+ name: "Fetched object",
347
+ type: "Object",
348
+ },
349
+ };
350
+ deepStrictEqual(
351
+ await fetchDocumentLoader2("https://localhost/object"),
352
+ expected,
353
+ );
354
+ deepStrictEqual(
355
+ await fetchDocumentLoader2("https://example.com/localhost-redirect"),
356
+ expected,
357
+ );
358
+ deepStrictEqual(
359
+ await fetchDocumentLoader2("https://example.com/localhost-link"),
360
+ expected,
361
+ );
362
+ });
363
+
364
+ fetchMock.hardReset();
365
+ });
@@ -0,0 +1,311 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import preloadedContexts from "./contexts.ts";
3
+ import { HttpHeaderLink } from "./link.ts";
4
+ import {
5
+ createRequest,
6
+ FetchError,
7
+ type GetUserAgentOptions,
8
+ logRequest,
9
+ } from "./request.ts";
10
+ import { UrlError, validatePublicUrl } from "./url.ts";
11
+
12
+ const logger = getLogger(["fedify", "runtime", "docloader"]);
13
+
14
+ /**
15
+ * A remote JSON-LD document and its context fetched by
16
+ * a {@link DocumentLoader}.
17
+ */
18
+ export interface RemoteDocument {
19
+ /**
20
+ * The URL of the context document.
21
+ */
22
+ contextUrl: string | null;
23
+
24
+ /**
25
+ * The fetched JSON-LD document.
26
+ */
27
+ document: unknown;
28
+
29
+ /**
30
+ * The URL of the fetched document.
31
+ */
32
+ documentUrl: string;
33
+ }
34
+
35
+ /**
36
+ * Options for {@link DocumentLoader}.
37
+ * @since 1.8.0
38
+ */
39
+ export interface DocumentLoaderOptions {
40
+ /**
41
+ * An `AbortSignal` for cancellation.
42
+ * @since 1.8.0
43
+ */
44
+ signal?: AbortSignal;
45
+ }
46
+
47
+ /**
48
+ * A JSON-LD document loader that fetches documents from the Web.
49
+ * @param url The URL of the document to load.
50
+ * @param options The options for the document loader.
51
+ * @returns The loaded remote document.
52
+ */
53
+ export type DocumentLoader = (
54
+ url: string,
55
+ options?: DocumentLoaderOptions,
56
+ ) => Promise<RemoteDocument>;
57
+
58
+ /**
59
+ * A factory function that creates a {@link DocumentLoader} with options.
60
+ * @param options The options for the document loader.
61
+ * @returns The document loader.
62
+ * @since 1.4.0
63
+ */
64
+ export type DocumentLoaderFactory = (
65
+ options?: DocumentLoaderFactoryOptions,
66
+ ) => DocumentLoader;
67
+
68
+ /**
69
+ * Options for {@link DocumentLoaderFactory}.
70
+ * @see {@link DocumentLoaderFactory}
71
+ * @see {@link AuthenticatedDocumentLoaderFactory}
72
+ * @since 1.4.0
73
+ */
74
+ export interface DocumentLoaderFactoryOptions {
75
+ /**
76
+ * Whether to allow fetching private network addresses.
77
+ * Turned off by default.
78
+ * @default `false``
79
+ */
80
+ allowPrivateAddress?: boolean;
81
+
82
+ /**
83
+ * Options for making `User-Agent` string.
84
+ * If a string is given, it is used as the `User-Agent` header value.
85
+ * If an object is given, it is passed to {@link getUserAgent} function.
86
+ */
87
+ userAgent?: GetUserAgentOptions | string;
88
+ }
89
+
90
+ /**
91
+ * A factory function that creates an authenticated {@link DocumentLoader} for
92
+ * a given identity. This is used for fetching documents that require
93
+ * authentication.
94
+ * @param identity The identity to create the document loader for.
95
+ * The actor's key pair.
96
+ * @param options The options for the document loader.
97
+ * @returns The authenticated document loader.
98
+ * @since 0.4.0
99
+ */
100
+ export type AuthenticatedDocumentLoaderFactory = (
101
+ identity: { keyId: URL; privateKey: CryptoKey },
102
+ options?: DocumentLoaderFactoryOptions,
103
+ ) => DocumentLoader;
104
+
105
+ /**
106
+ * Gets a {@link RemoteDocument} from the given response.
107
+ * @param url The URL of the document to load.
108
+ * @param response The response to get the document from.
109
+ * @param fetch The function to fetch the document.
110
+ * @returns The loaded remote document.
111
+ * @throws {FetchError} If the response is not OK.
112
+ * @internal
113
+ */
114
+ export async function getRemoteDocument(
115
+ url: string,
116
+ response: Response,
117
+ fetch: (
118
+ url: string,
119
+ options?: DocumentLoaderOptions,
120
+ ) => Promise<RemoteDocument>,
121
+ ): Promise<RemoteDocument> {
122
+ const documentUrl = response.url === "" ? url : response.url;
123
+ const docUrl = new URL(documentUrl);
124
+ if (!response.ok) {
125
+ logger.error(
126
+ "Failed to fetch document: {status} {url} {headers}",
127
+ {
128
+ status: response.status,
129
+ url: documentUrl,
130
+ headers: Object.fromEntries(response.headers.entries()),
131
+ },
132
+ );
133
+ throw new FetchError(
134
+ documentUrl,
135
+ `HTTP ${response.status}: ${documentUrl}`,
136
+ );
137
+ }
138
+ const contentType = response.headers.get("Content-Type");
139
+ const jsonLd = contentType == null ||
140
+ contentType === "application/activity+json" ||
141
+ contentType.startsWith("application/activity+json;") ||
142
+ contentType === "application/ld+json" ||
143
+ contentType.startsWith("application/ld+json;");
144
+ const linkHeader = response.headers.get("Link");
145
+ let contextUrl: string | null = null;
146
+ if (linkHeader != null) {
147
+ let link: HttpHeaderLink;
148
+ try {
149
+ link = new HttpHeaderLink(linkHeader);
150
+ } catch (e) {
151
+ if (e instanceof SyntaxError) {
152
+ link = new HttpHeaderLink();
153
+ } else {
154
+ throw e;
155
+ }
156
+ }
157
+ if (jsonLd) {
158
+ const entries = link.getByRel("http://www.w3.org/ns/json-ld#context");
159
+ for (const [uri, params] of entries) {
160
+ if ("type" in params && params.type === "application/ld+json") {
161
+ contextUrl = uri;
162
+ break;
163
+ }
164
+ }
165
+ } else {
166
+ const entries = link.getByRel("alternate");
167
+ for (const [uri, params] of entries) {
168
+ const altUri = new URL(uri, docUrl);
169
+ if (
170
+ "type" in params &&
171
+ (params.type === "application/activity+json" ||
172
+ params.type === "application/ld+json" ||
173
+ params.type.startsWith("application/ld+json;")) &&
174
+ altUri.href !== docUrl.href
175
+ ) {
176
+ logger.debug(
177
+ "Found alternate document: {alternateUrl} from {url}",
178
+ { alternateUrl: altUri.href, url: documentUrl },
179
+ );
180
+ return await fetch(altUri.href);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ let document: unknown;
186
+ if (
187
+ !jsonLd &&
188
+ (contentType === "text/html" || contentType?.startsWith("text/html;") ||
189
+ contentType === "application/xhtml+xml" ||
190
+ contentType?.startsWith("application/xhtml+xml;"))
191
+ ) {
192
+ const p =
193
+ /<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig;
194
+ const p2 = /\s+([a-z][a-z:_-]*)=("([^"]*)"|'([^']*)'|([^\s>]+))/ig;
195
+ const html = await response.text();
196
+ let m: RegExpExecArray | null;
197
+ const rawAttribs: string[] = [];
198
+ while ((m = p.exec(html)) !== null) rawAttribs.push(m[2]);
199
+ for (const rawAttrs of rawAttribs) {
200
+ let m2: RegExpExecArray | null;
201
+ const attribs: Record<string, string> = {};
202
+ while ((m2 = p2.exec(rawAttrs)) !== null) {
203
+ const key = m2[1].toLowerCase();
204
+ const value = m2[3] ?? m2[4] ?? m2[5] ?? "";
205
+ attribs[key] = value;
206
+ }
207
+ if (
208
+ attribs.rel === "alternate" && "type" in attribs && (
209
+ attribs.type === "application/activity+json" ||
210
+ attribs.type === "application/ld+json" ||
211
+ attribs.type.startsWith("application/ld+json;")
212
+ ) && "href" in attribs &&
213
+ new URL(attribs.href, docUrl).href !== docUrl.href
214
+ ) {
215
+ logger.debug(
216
+ "Found alternate document: {alternateUrl} from {url}",
217
+ { alternateUrl: attribs.href, url: documentUrl },
218
+ );
219
+ return await fetch(new URL(attribs.href, docUrl).href);
220
+ }
221
+ }
222
+ document = JSON.parse(html);
223
+ } else {
224
+ document = await response.json();
225
+ }
226
+ logger.debug(
227
+ "Fetched document: {status} {url} {headers}",
228
+ {
229
+ status: response.status,
230
+ url: documentUrl,
231
+ headers: Object.fromEntries(response.headers.entries()),
232
+ },
233
+ );
234
+ return { contextUrl, document, documentUrl };
235
+ }
236
+
237
+ /**
238
+ * Options for {@link getDocumentLoader}.
239
+ * @since 1.3.0
240
+ */
241
+ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
242
+ /**
243
+ * Whether to preload the frequently used contexts.
244
+ */
245
+ skipPreloadedContexts?: boolean;
246
+ }
247
+
248
+ /**
249
+ * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
250
+ *
251
+ * The created loader preloads the below frequently used contexts by default
252
+ * (unless `options.ignorePreloadedContexts` is set to `true`):
253
+ *
254
+ * - <https://www.w3.org/ns/activitystreams>
255
+ * - <https://w3id.org/security/v1>
256
+ * - <https://w3id.org/security/data-integrity/v1>
257
+ * - <https://www.w3.org/ns/did/v1>
258
+ * - <https://w3id.org/security/multikey/v1>
259
+ * - <https://purl.archive.org/socialweb/webfinger>
260
+ * - <http://schema.org/>
261
+ * @param options Options for the document loader.
262
+ * @returns The document loader.
263
+ * @since 1.3.0
264
+ */
265
+ export function getDocumentLoader(
266
+ { allowPrivateAddress, skipPreloadedContexts, userAgent }:
267
+ GetDocumentLoaderOptions = {},
268
+ ): DocumentLoader {
269
+ async function load(
270
+ url: string,
271
+ options?: DocumentLoaderOptions,
272
+ ): Promise<RemoteDocument> {
273
+ options?.signal?.throwIfAborted();
274
+ if (!skipPreloadedContexts && url in preloadedContexts) {
275
+ logger.debug("Using preloaded context: {url}.", { url });
276
+ return {
277
+ contextUrl: null,
278
+ document: preloadedContexts[url],
279
+ documentUrl: url,
280
+ };
281
+ }
282
+ if (!allowPrivateAddress) {
283
+ try {
284
+ await validatePublicUrl(url);
285
+ } catch (error) {
286
+ if (error instanceof UrlError) {
287
+ logger.error("Disallowed private URL: {url}", { url, error });
288
+ }
289
+ throw error;
290
+ }
291
+ }
292
+ const request = createRequest(url, { userAgent });
293
+ logRequest(logger, request);
294
+ const response = await fetch(request, {
295
+ // Since Bun has a bug that ignores the `Request.redirect` option,
296
+ // to work around it we specify `redirect: "manual"` here too:
297
+ // https://github.com/oven-sh/bun/issues/10754
298
+ redirect: "manual",
299
+ signal: options?.signal,
300
+ });
301
+ // Follow redirects manually to get the final URL:
302
+ if (
303
+ response.status >= 300 && response.status < 400 &&
304
+ response.headers.has("Location")
305
+ ) {
306
+ return load(response.headers.get("Location")!, options);
307
+ }
308
+ return getRemoteDocument(url, response, load);
309
+ }
310
+ return load;
311
+ }