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