@oxy.so/federation 1.0.0 → 2.0.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/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/networkIdentity.js +33 -5
- package/dist/cjs/node/actorResolver.js +39 -12
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/networkIdentity.js +33 -5
- package/dist/esm/node/actorResolver.js +39 -12
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/networkIdentity.d.ts +4 -0
- package/dist/types/node/actorResolver.d.ts +39 -4
- package/dist/types/node/index.d.ts +1 -1
- package/package.json +3 -3
- package/src/__tests__/actorCollectionCounts.test.ts +172 -0
- package/src/__tests__/networkIdentity.test.ts +26 -0
- package/src/networkIdentity.ts +28 -5
- package/src/node/actorResolver.ts +64 -15
- package/src/node/index.ts +1 -0
|
@@ -132,6 +132,8 @@ function parseUpstreamProfileUrl(candidateUrl, networks = Object.values(exports.
|
|
|
132
132
|
}
|
|
133
133
|
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
134
134
|
return undefined;
|
|
135
|
+
if (url.username || url.password || url.port)
|
|
136
|
+
return undefined;
|
|
135
137
|
const host = (0, apUri_1.canonicalFederationHost)(url.hostname);
|
|
136
138
|
for (const network of networks) {
|
|
137
139
|
if (!network.profileHosts.some((allowed) => (0, apUri_1.canonicalFederationHost)(allowed) === host))
|
|
@@ -188,6 +190,8 @@ function profileUrlHandle(href, allowedHosts, pathPrefix) {
|
|
|
188
190
|
}
|
|
189
191
|
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
190
192
|
return undefined;
|
|
193
|
+
if (url.username || url.password || url.port)
|
|
194
|
+
return undefined;
|
|
191
195
|
const host = (0, apUri_1.canonicalFederationHost)(url.hostname);
|
|
192
196
|
if (!allowedHosts.some((allowed) => (0, apUri_1.canonicalFederationHost)(allowed) === host))
|
|
193
197
|
return undefined;
|
|
@@ -201,8 +205,19 @@ function profileUrlHandle(href, allowedHosts, pathPrefix) {
|
|
|
201
205
|
return decodeURIComponent(segments[pathPrefix.length]);
|
|
202
206
|
}
|
|
203
207
|
/** Every `href="…"` in a sanitized field value, in document order. */
|
|
204
|
-
function fieldHrefs(value) {
|
|
208
|
+
function fieldHrefs(value, requireRelMe = false) {
|
|
205
209
|
const hrefs = [];
|
|
210
|
+
if (requireRelMe) {
|
|
211
|
+
for (const anchor of value.matchAll(/<a\b([^>]*)>/gi)) {
|
|
212
|
+
const rel = /\brel\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(anchor[1]);
|
|
213
|
+
if (!(rel?.[1] ?? rel?.[2] ?? '').toLowerCase().split(/\s+/).includes('me'))
|
|
214
|
+
continue;
|
|
215
|
+
const href = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(anchor[1]);
|
|
216
|
+
if (href)
|
|
217
|
+
hrefs.push(href[1] ?? href[2]);
|
|
218
|
+
}
|
|
219
|
+
return hrefs;
|
|
220
|
+
}
|
|
206
221
|
const pattern = /href="([^"]*)"/gi;
|
|
207
222
|
let match = pattern.exec(value);
|
|
208
223
|
while (match !== null) {
|
|
@@ -221,16 +236,29 @@ function upstreamHandleFromProfileField(options) {
|
|
|
221
236
|
const wanted = options.fieldName.toLowerCase();
|
|
222
237
|
const prefix = options.pathPrefix ?? [];
|
|
223
238
|
return (candidate) => {
|
|
239
|
+
const handles = new Map();
|
|
224
240
|
for (const field of candidate.fields) {
|
|
225
241
|
if (field.name.trim().toLowerCase() !== wanted)
|
|
226
242
|
continue;
|
|
227
|
-
for (const href of fieldHrefs(field.value)) {
|
|
228
|
-
|
|
243
|
+
for (const href of fieldHrefs(field.value, options.requireRelMe)) {
|
|
244
|
+
// Observed bird.makeup Official assertion, 2026-09-13:
|
|
245
|
+
// https://https://twitter.com/jordievole. Repair exactly one duplicated
|
|
246
|
+
// HTTPS prefix, then run the ordinary host/path/credential validation.
|
|
247
|
+
const candidateHref = options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
|
|
248
|
+
? href.slice('https://'.length) : href;
|
|
249
|
+
let handle;
|
|
250
|
+
try {
|
|
251
|
+
handle = profileUrlHandle(candidateHref, options.hosts, prefix);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
229
256
|
if (handle !== undefined && handle.length > 0)
|
|
230
|
-
|
|
257
|
+
handles.set(handle.toLowerCase(), handle);
|
|
231
258
|
}
|
|
232
259
|
}
|
|
233
|
-
|
|
260
|
+
// Conflicting source assertions cannot be settled by document order.
|
|
261
|
+
return handles.size === 1 ? handles.values().next().value : undefined;
|
|
234
262
|
};
|
|
235
263
|
}
|
|
236
264
|
/**
|
|
@@ -36,6 +36,12 @@ const AP_CONTENT_TYPE = 'application/activity+json';
|
|
|
36
36
|
/** Maximum decompressed response sizes accepted from untrusted federation hosts. */
|
|
37
37
|
const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
|
|
38
38
|
const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* Collection responses that DEFINITIVELY withhold the count: the owner hid the
|
|
41
|
+
* collection (401/403) or it no longer exists (404/410). Anything else non-2xx
|
|
42
|
+
* is a failed attempt, not an answer.
|
|
43
|
+
*/
|
|
44
|
+
const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
|
|
39
45
|
const ERROR_BODY_MAX_BYTES = 4 * 1024;
|
|
40
46
|
async function readBoundedResponseBody(res, maxBytes) {
|
|
41
47
|
const contentLength = res.headers.get('content-length');
|
|
@@ -393,9 +399,11 @@ class ActorResolver {
|
|
|
393
399
|
alsoKnownAs,
|
|
394
400
|
networkAcct: networkIdentity?.federatedUsername,
|
|
395
401
|
remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
// Omitted, not written as `undefined`, when this refresh could not tell:
|
|
403
|
+
// an absent key is what tells the store to keep the value it has.
|
|
404
|
+
...(followersCount !== undefined && { followersCount }),
|
|
405
|
+
...(followingCount !== undefined && { followingCount }),
|
|
406
|
+
...(postsCount !== undefined && { postsCount }),
|
|
399
407
|
lastFetchedAt: new Date(),
|
|
400
408
|
};
|
|
401
409
|
const fedActor = await this.config.store.upsertActor(actorId, update);
|
|
@@ -427,9 +435,11 @@ class ActorResolver {
|
|
|
427
435
|
// string away made both of those unrepresentable, so the stale text
|
|
428
436
|
// survived every later refresh with nothing in the logs.
|
|
429
437
|
bio: identityBio,
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
438
|
+
// The identity bridge takes a number or nothing; an unknown count is
|
|
439
|
+
// sent as nothing rather than as a zero it would store.
|
|
440
|
+
followersCount: followersCount ?? undefined,
|
|
441
|
+
followingCount: followingCount ?? undefined,
|
|
442
|
+
postsCount: postsCount ?? undefined,
|
|
433
443
|
oxyUserId: fedActor.oxyUserId ?? undefined,
|
|
434
444
|
};
|
|
435
445
|
const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
|
|
@@ -507,19 +517,36 @@ class ActorResolver {
|
|
|
507
517
|
this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
|
|
508
518
|
}
|
|
509
519
|
}
|
|
510
|
-
/**
|
|
520
|
+
/**
|
|
521
|
+
* Read an ActivityPub collection's `totalItems`.
|
|
522
|
+
*
|
|
523
|
+
* Every failure used to come back as `0`, so a follower count the remote HID,
|
|
524
|
+
* or one a timeout kept us from reading, was stored and shown as a real
|
|
525
|
+
* "0 followers" — indistinguishable from an account nobody follows. A failure
|
|
526
|
+
* now says which kind it is (see {@link CollectionCount}):
|
|
527
|
+
*
|
|
528
|
+
* - `null` when the answer is definitive: no collection advertised, 401/403
|
|
529
|
+
* (the owner hid it), 404/410 (it is gone), or a readable collection with no
|
|
530
|
+
* usable `totalItems` (the server does not publish the count).
|
|
531
|
+
* - `undefined` when this attempt simply failed — a thrown fetch (timeout,
|
|
532
|
+
* network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
|
|
533
|
+
* not be read as a JSON object. The next refresh may well succeed, so the
|
|
534
|
+
* caller keeps whatever it last knew rather than forgetting it.
|
|
535
|
+
*/
|
|
511
536
|
async fetchCollectionCount(url) {
|
|
512
537
|
if (!url)
|
|
513
|
-
return
|
|
538
|
+
return null;
|
|
514
539
|
try {
|
|
515
540
|
const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
|
|
516
|
-
if (!res.ok)
|
|
517
|
-
return
|
|
541
|
+
if (!res.ok) {
|
|
542
|
+
return COLLECTION_WITHHELD_STATUSES.has(res.status) ? null : undefined;
|
|
543
|
+
}
|
|
518
544
|
const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
|
|
519
|
-
|
|
545
|
+
const total = col.totalItems;
|
|
546
|
+
return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
|
|
520
547
|
}
|
|
521
548
|
catch {
|
|
522
|
-
return
|
|
549
|
+
return undefined;
|
|
523
550
|
}
|
|
524
551
|
}
|
|
525
552
|
/**
|