@oxy.so/federation 2.2.1 → 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/README.md +31 -0
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/apUri.js +4 -2
- package/dist/cjs/httpSignature.js +1 -1
- package/dist/cjs/index.js +2 -2
- package/dist/cjs/networkIdentity.js +3 -2
- package/dist/cjs/node/actorResolver.js +24 -20
- package/dist/cjs/node/actorRouter.js +16 -6
- package/dist/cjs/node/delivery.js +35 -10
- package/dist/cjs/node/httpCacheControl.js +11 -0
- package/dist/cjs/node/inboundDispatch.js +4 -3
- package/dist/cjs/node/index.js +8 -1
- package/dist/cjs/node/tcoLinks.js +97 -0
- package/dist/cjs/node/webfingerRouter.js +6 -4
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/apUri.js +4 -2
- package/dist/esm/httpSignature.js +1 -1
- package/dist/esm/index.js +3 -3
- package/dist/esm/networkIdentity.js +3 -2
- package/dist/esm/node/actorResolver.js +24 -20
- package/dist/esm/node/actorRouter.js +16 -6
- package/dist/esm/node/delivery.js +33 -10
- package/dist/esm/node/httpCacheControl.js +8 -0
- package/dist/esm/node/inboundDispatch.js +4 -3
- package/dist/esm/node/index.js +6 -1
- package/dist/esm/node/tcoLinks.js +94 -0
- package/dist/esm/node/webfingerRouter.js +6 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/httpSignature.d.ts +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/node/actorRouter.d.ts +2 -0
- package/dist/types/node/delivery.d.ts +23 -4
- package/dist/types/node/httpCacheControl.d.ts +2 -0
- package/dist/types/node/index.d.ts +6 -1
- package/dist/types/node/tcoLinks.d.ts +41 -0
- package/dist/types/node/webfingerRouter.d.ts +2 -0
- package/package.json +2 -3
- package/src/__tests__/actorCollectionCounts.test.ts +27 -18
- package/src/__tests__/actorObject.test.ts +27 -7
- package/src/__tests__/actorResolver.test.ts +37 -22
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +73 -36
- package/src/__tests__/apUri.test.ts +9 -5
- package/src/__tests__/delivery.test.ts +165 -25
- package/src/__tests__/federationHost.test.ts +13 -3
- package/src/__tests__/httpSignature.test.ts +1 -3
- package/src/__tests__/inboundDispatch.test.ts +80 -30
- package/src/__tests__/networkIdentity.test.ts +240 -113
- package/src/__tests__/routers.test.ts +157 -27
- package/src/__tests__/tcoLinks.test.ts +21 -0
- package/src/actorObject.ts +8 -3
- package/src/apUri.ts +6 -2
- package/src/httpSignature.ts +1 -1
- package/src/index.ts +8 -3
- package/src/networkIdentity.ts +14 -5
- package/src/node/actorResolver.ts +91 -50
- package/src/node/actorRouter.ts +44 -14
- package/src/node/delivery.ts +112 -30
- package/src/node/httpCacheControl.ts +8 -0
- package/src/node/identityBridge.ts +38 -18
- package/src/node/inboundDispatch.ts +33 -13
- package/src/node/index.ts +13 -0
- package/src/node/signedFetch.ts +3 -1
- package/src/node/tcoLinks.ts +114 -0
- package/src/node/webfingerRouter.ts +8 -4
package/dist/esm/apUri.js
CHANGED
|
@@ -99,7 +99,9 @@ export function createDomainPolicy(config) {
|
|
|
99
99
|
canonicalFederationHost(config.domain),
|
|
100
100
|
canonicalFederationHost(config.actorDomain ?? config.domain),
|
|
101
101
|
]);
|
|
102
|
-
const identityApex = config.identityApex
|
|
102
|
+
const identityApex = config.identityApex
|
|
103
|
+
? canonicalFederationHost(config.identityApex)
|
|
104
|
+
: undefined;
|
|
103
105
|
const blocked = new Set();
|
|
104
106
|
for (const d of config.blockedDomains ?? []) {
|
|
105
107
|
blocked.add(canonicalFederationHost(d));
|
|
@@ -107,7 +109,7 @@ export function createDomainPolicy(config) {
|
|
|
107
109
|
return {
|
|
108
110
|
isBlockedDomain(domain) {
|
|
109
111
|
const d = canonicalFederationHost(domain);
|
|
110
|
-
return localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d);
|
|
112
|
+
return (localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d));
|
|
111
113
|
},
|
|
112
114
|
extractLocalPostId(objectUri) {
|
|
113
115
|
let parsed;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Mention runs behind a CF-proxied apex that rewrites the origin `Host`, so it
|
|
17
17
|
* passes `true`; a directly-exposed origin leaves it `false`.
|
|
18
18
|
*
|
|
19
|
-
* Lives in the isomorphic `.` entry (no Express /
|
|
19
|
+
* Lives in the isomorphic `.` entry (no Express / database driver): it depends only on
|
|
20
20
|
* the runtime `crypto` builtin (Node / Bun) and is never invoked from browser /
|
|
21
21
|
* React-Native bundles — RN consumers import only the connector TYPES, which are
|
|
22
22
|
* erased at compile time.
|
package/dist/esm/index.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* connector produces, the local-event union connectors deliver outbound, and
|
|
9
9
|
* the connector interface itself.
|
|
10
10
|
*
|
|
11
|
-
* IMPORTANT: this entry is intentionally free of
|
|
12
|
-
* Native so it can be imported from any Oxy app backend (and, in later phases,
|
|
11
|
+
* IMPORTANT: this entry is intentionally free of database drivers / Express /
|
|
12
|
+
* React Native so it can be imported from any Oxy app backend (and, in later phases,
|
|
13
13
|
* share the pure HTTP-signature + actor-object surface with browser/isomorphic
|
|
14
14
|
* callers). The runnable Express/Node engine — signed fetch, delivery transport,
|
|
15
15
|
* webfinger/actor/inbox routers, remote-actor resolution — lives under the
|
|
@@ -30,7 +30,7 @@ export { signRequest, verifyHttpSignature, HTTP_SIGNATURE_ALGORITHM, DEFAULT_SIG
|
|
|
30
30
|
* Domain-parameterized ActivityPub URL builders — each app instantiates them once
|
|
31
31
|
* with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
|
|
32
32
|
*/
|
|
33
|
-
export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME } from './urls.js';
|
|
33
|
+
export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, } from './urls.js';
|
|
34
34
|
/**
|
|
35
35
|
* Network identity: the MECHANISM for re-labelling an account republished by a
|
|
36
36
|
* bridge onto the network it actually came from, plus the network vocabulary and
|
|
@@ -230,7 +230,8 @@ export function upstreamHandleFromProfileField(options) {
|
|
|
230
230
|
// https://https://twitter.com/jordievole. Repair exactly one duplicated
|
|
231
231
|
// HTTPS prefix, then run the ordinary host/path/credential validation.
|
|
232
232
|
const candidateHref = options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
|
|
233
|
-
? href.slice('https://'.length)
|
|
233
|
+
? href.slice('https://'.length)
|
|
234
|
+
: href;
|
|
234
235
|
let handle;
|
|
235
236
|
try {
|
|
236
237
|
handle = profileUrlHandle(candidateHref, options.hosts, prefix);
|
|
@@ -431,7 +432,7 @@ export function createBridgeRelabeller(entries) {
|
|
|
431
432
|
const bridge = findBridge(actorHost);
|
|
432
433
|
if (!bridge)
|
|
433
434
|
return false;
|
|
434
|
-
return canonicalFederationHost(bridge.network.domain) === canonicalFederationHost(networkDomain);
|
|
435
|
+
return (canonicalFederationHost(bridge.network.domain) === canonicalFederationHost(networkDomain));
|
|
435
436
|
},
|
|
436
437
|
deriveNetworkIdentity: (candidate) => {
|
|
437
438
|
const entry = findBridge(candidate.host);
|
|
@@ -104,9 +104,9 @@ function sameOriginUrl(a, b) {
|
|
|
104
104
|
try {
|
|
105
105
|
const urlA = new URL(a);
|
|
106
106
|
const urlB = new URL(b);
|
|
107
|
-
return urlA.protocol === urlB.protocol
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
return (urlA.protocol === urlB.protocol &&
|
|
108
|
+
urlA.port === urlB.port &&
|
|
109
|
+
isSameFederationHost(urlA.hostname, urlB.hostname));
|
|
110
110
|
}
|
|
111
111
|
catch {
|
|
112
112
|
return false;
|
|
@@ -226,8 +226,10 @@ export class ActorResolver {
|
|
|
226
226
|
// the username-based URI we may have stored.
|
|
227
227
|
const parsed = new URL(currentUri);
|
|
228
228
|
const pathUsername = parsed.pathname.split('/').filter(Boolean).pop();
|
|
229
|
-
const acct = canonicalAcctHint
|
|
230
|
-
|
|
229
|
+
const acct = canonicalAcctHint ||
|
|
230
|
+
(pathUsername
|
|
231
|
+
? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`)
|
|
232
|
+
: undefined);
|
|
231
233
|
if (acct) {
|
|
232
234
|
this.config.logger.info(`[FedSync] attempting WebFinger fallback for ${acct}`);
|
|
233
235
|
const resolved = await this.resolveWebFinger(acct);
|
|
@@ -275,9 +277,9 @@ export class ActorResolver {
|
|
|
275
277
|
return null;
|
|
276
278
|
}
|
|
277
279
|
const actorHost = new URL(actorId).hostname.toLowerCase();
|
|
278
|
-
const username = this.config.text.inlineField(actor.preferredUsername)
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
const username = this.config.text.inlineField(actor.preferredUsername) ||
|
|
281
|
+
this.config.text.inlineField(actor.name) ||
|
|
282
|
+
'unknown';
|
|
281
283
|
const actorWebfinger = typeof actor.webfinger === 'string'
|
|
282
284
|
? this.config.normalizeFederatedAcct(actor.webfinger)
|
|
283
285
|
: undefined;
|
|
@@ -287,10 +289,10 @@ export class ActorResolver {
|
|
|
287
289
|
const verifiedActorWebfinger = this.acctMatchesActorHost(actorWebfinger, actorHost)
|
|
288
290
|
? actorWebfinger
|
|
289
291
|
: undefined;
|
|
290
|
-
const acct = verifiedAcctHint
|
|
291
|
-
||
|
|
292
|
-
|
|
293
|
-
|
|
292
|
+
const acct = verifiedAcctHint ||
|
|
293
|
+
verifiedActorWebfinger ||
|
|
294
|
+
this.config.normalizeFederatedAcct(`${username}@${actorHost}`) ||
|
|
295
|
+
`${username.toLowerCase()}@${actorHost}`;
|
|
294
296
|
const domain = this.config.domainFromAcct(acct) || actorHost;
|
|
295
297
|
// Re-check against the RESOLVED host/acct (post-redirect / WebFinger), which
|
|
296
298
|
// can differ from the originally-requested URI host the early guard screened.
|
|
@@ -438,7 +440,9 @@ export class ActorResolver {
|
|
|
438
440
|
postsCount: postsCount ?? undefined,
|
|
439
441
|
oxyUserId: fedActor.oxyUserId ?? undefined,
|
|
440
442
|
};
|
|
441
|
-
const oxyId = await this.config.identity.resolveExternalUser(normalized, {
|
|
443
|
+
const oxyId = await this.config.identity.resolveExternalUser(normalized, {
|
|
444
|
+
forceAvatarRefresh,
|
|
445
|
+
});
|
|
442
446
|
if (oxyId && fedActor.oxyUserId !== oxyId && fedActor._id != null) {
|
|
443
447
|
await this.config.store.setActorOxyUserId(fedActor._id, oxyId);
|
|
444
448
|
}
|
|
@@ -479,11 +483,11 @@ export class ActorResolver {
|
|
|
479
483
|
// instead of needing its own clause.
|
|
480
484
|
const atIndex = federatedUsername.indexOf('@');
|
|
481
485
|
const localPart = atIndex > 0 ? federatedUsername.slice(0, atIndex) : '';
|
|
482
|
-
if (domain.length === 0
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
this.config.logger.warn(`[FedSync] refusing network identity for ${candidate.actorUri}: `
|
|
486
|
-
|
|
486
|
+
if (domain.length === 0 ||
|
|
487
|
+
localPart.length === 0 ||
|
|
488
|
+
federatedUsername !== `${localPart}@${domain}`) {
|
|
489
|
+
this.config.logger.warn(`[FedSync] refusing network identity for ${candidate.actorUri}: ` +
|
|
490
|
+
`"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`);
|
|
487
491
|
return undefined;
|
|
488
492
|
}
|
|
489
493
|
return { federatedUsername, instanceDomain: domain, bio: derived.bio };
|
|
@@ -586,8 +590,8 @@ export class ActorResolver {
|
|
|
586
590
|
return;
|
|
587
591
|
const missingProfile = !existing || !existing.avatarUrl || !existing.headerUrl;
|
|
588
592
|
const lastFetchedMs = existing?.lastFetchedAt?.getTime();
|
|
589
|
-
const refreshedRecently = typeof lastFetchedMs === 'number'
|
|
590
|
-
|
|
593
|
+
const refreshedRecently = typeof lastFetchedMs === 'number' &&
|
|
594
|
+
Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
|
|
591
595
|
// Skip if we refreshed recently AND the cached profile is already complete.
|
|
592
596
|
if (refreshedRecently && !missingProfile)
|
|
593
597
|
return;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { Router } from 'express';
|
|
22
22
|
import { AP_CONTEXT } from '../apContext.js';
|
|
23
|
+
import { discoveryCacheControl } from './httpCacheControl.js';
|
|
23
24
|
import { verifyHttpSignature } from '../httpSignature.js';
|
|
24
25
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
|
|
25
26
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
@@ -54,6 +55,7 @@ function parseFollowOffset(raw) {
|
|
|
54
55
|
export function createActorRouter(config) {
|
|
55
56
|
const router = Router();
|
|
56
57
|
const { urls, domain, apContentType, logger } = config;
|
|
58
|
+
const httpCacheControl = discoveryCacheControl(config.httpCacheTtlSeconds, 1800);
|
|
57
59
|
function wantsActivityPub(req) {
|
|
58
60
|
return config.wantsActivityPub(req.headers.accept);
|
|
59
61
|
}
|
|
@@ -61,7 +63,7 @@ export function createActorRouter(config) {
|
|
|
61
63
|
async function handleInbox(req, res) {
|
|
62
64
|
try {
|
|
63
65
|
// Verify HTTP signature (use originalUrl to avoid proxy path mangling).
|
|
64
|
-
const { verified, actorUri, reason: signatureError } = await verifyHttpSignature({
|
|
66
|
+
const { verified, actorUri, reason: signatureError, } = await verifyHttpSignature({
|
|
65
67
|
method: req.method,
|
|
66
68
|
path: req.originalUrl || req.path,
|
|
67
69
|
headers: req.headers,
|
|
@@ -91,7 +93,10 @@ export function createActorRouter(config) {
|
|
|
91
93
|
// is never dropped.
|
|
92
94
|
let enqueued = false;
|
|
93
95
|
try {
|
|
94
|
-
enqueued = await config.inbound.enqueueInboxActivity({
|
|
96
|
+
enqueued = await config.inbound.enqueueInboxActivity({
|
|
97
|
+
activity,
|
|
98
|
+
verifiedActorUri: actorUri,
|
|
99
|
+
});
|
|
95
100
|
}
|
|
96
101
|
catch (err) {
|
|
97
102
|
logger.error('Failed to enqueue inbox activity — processing inline:', err);
|
|
@@ -139,7 +144,9 @@ export function createActorRouter(config) {
|
|
|
139
144
|
}
|
|
140
145
|
catch (err) {
|
|
141
146
|
logger.warn('[Federation] follow-collection summary total lookup failed', {
|
|
142
|
-
username,
|
|
147
|
+
username,
|
|
148
|
+
direction,
|
|
149
|
+
error: err,
|
|
143
150
|
});
|
|
144
151
|
}
|
|
145
152
|
}
|
|
@@ -166,7 +173,10 @@ export function createActorRouter(config) {
|
|
|
166
173
|
// Fail-soft: never 500 the whole collection on an Oxy graph hiccup — serve
|
|
167
174
|
// an empty page against the best-known total rather than crashing.
|
|
168
175
|
logger.warn('[Federation] follow-collection Oxy graph list failed, serving empty page', {
|
|
169
|
-
username,
|
|
176
|
+
username,
|
|
177
|
+
direction,
|
|
178
|
+
offset,
|
|
179
|
+
error: err,
|
|
170
180
|
});
|
|
171
181
|
}
|
|
172
182
|
const orderedItems = members
|
|
@@ -231,7 +241,7 @@ export function createActorRouter(config) {
|
|
|
231
241
|
},
|
|
232
242
|
};
|
|
233
243
|
res.set('Content-Type', apContentType);
|
|
234
|
-
res.set('Cache-Control',
|
|
244
|
+
res.set('Cache-Control', httpCacheControl);
|
|
235
245
|
return res.json(actorObject);
|
|
236
246
|
}
|
|
237
247
|
const user = await config.resolveUser(username);
|
|
@@ -266,7 +276,7 @@ export function createActorRouter(config) {
|
|
|
266
276
|
alsoKnownAs: user.alsoKnownAs,
|
|
267
277
|
});
|
|
268
278
|
res.set('Content-Type', apContentType);
|
|
269
|
-
res.set('Cache-Control',
|
|
279
|
+
res.set('Cache-Control', httpCacheControl);
|
|
270
280
|
return res.json({ '@context': AP_CONTEXT, ...actorObject });
|
|
271
281
|
}
|
|
272
282
|
catch (err) {
|
|
@@ -31,6 +31,14 @@ const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
|
31
31
|
const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
|
|
32
32
|
/** The ActivityStreams public collection — the `to` addressee of a public activity. */
|
|
33
33
|
const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
34
|
+
/** A definitive refusal: workers must discard, not retry or count a delivery. */
|
|
35
|
+
export class DeliveryAuthorizationDeniedError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super('Outbound federation delivery is not authorized');
|
|
38
|
+
this.code = 'DELIVERY_AUTHORIZATION_DENIED';
|
|
39
|
+
this.name = 'DeliveryAuthorizationDeniedError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
34
42
|
/** Read a bounded prefix of a failed-delivery response body for the debug log. */
|
|
35
43
|
async function readResponsePreview(response) {
|
|
36
44
|
const chunks = [];
|
|
@@ -69,7 +77,17 @@ export function createDeliveryService(config) {
|
|
|
69
77
|
const host = urlHost(url);
|
|
70
78
|
return host === null || config.isBlockedDomain(host);
|
|
71
79
|
}
|
|
80
|
+
async function authorizeDelivery(activity, senderAccountId) {
|
|
81
|
+
if (config.authorizeDelivery &&
|
|
82
|
+
!(await config.authorizeDelivery({ activity, senderAccountId }))) {
|
|
83
|
+
throw new DeliveryAuthorizationDeniedError();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
72
86
|
async function deliverActivity(activity, targetInbox, senderOxyUserId, senderUsername) {
|
|
87
|
+
await authorizeDelivery(activity, senderOxyUserId);
|
|
88
|
+
return deliverAuthorizedActivity(activity, targetInbox, senderUsername);
|
|
89
|
+
}
|
|
90
|
+
async function deliverAuthorizedActivity(activity, targetInbox, senderUsername) {
|
|
73
91
|
if (isBlockedUrl(targetInbox)) {
|
|
74
92
|
logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
|
|
75
93
|
return false;
|
|
@@ -107,6 +125,7 @@ export function createDeliveryService(config) {
|
|
|
107
125
|
}
|
|
108
126
|
}
|
|
109
127
|
async function queueDelivery(activity, targetInbox, senderOxyUserId) {
|
|
128
|
+
await authorizeDelivery(activity, senderOxyUserId);
|
|
110
129
|
if (isBlockedUrl(targetInbox)) {
|
|
111
130
|
logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
|
|
112
131
|
return;
|
|
@@ -144,6 +163,7 @@ export function createDeliveryService(config) {
|
|
|
144
163
|
return actor.sharedInboxUrl ?? actor.inboxUrl ?? undefined;
|
|
145
164
|
}
|
|
146
165
|
async function deliverToFollowers(activity, senderOxyUserId, senderUsername, options = {}) {
|
|
166
|
+
await authorizeDelivery(activity, senderOxyUserId);
|
|
147
167
|
const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
|
|
148
168
|
const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
|
|
149
169
|
// Group by shared inbox to avoid duplicate deliveries. Follower inboxes
|
|
@@ -189,7 +209,12 @@ export function createDeliveryService(config) {
|
|
|
189
209
|
return false;
|
|
190
210
|
});
|
|
191
211
|
if (!enqueued) {
|
|
192
|
-
durableFallback.push({
|
|
212
|
+
durableFallback.push({
|
|
213
|
+
activityJson: activity,
|
|
214
|
+
targetInbox: inbox,
|
|
215
|
+
senderOxyUserId,
|
|
216
|
+
nextAttemptAt: now,
|
|
217
|
+
});
|
|
193
218
|
}
|
|
194
219
|
}
|
|
195
220
|
if (durableFallback.length > 0) {
|
|
@@ -233,19 +258,12 @@ export function createDeliveryService(config) {
|
|
|
233
258
|
// cached; if the actor is unknown locally we still record the follow and
|
|
234
259
|
// queue the Follow activity, then refresh the actor in the background.
|
|
235
260
|
const cached = await config.store.findActorByUri(remoteActorUri);
|
|
236
|
-
// Always refresh the actor in the background so its inbox/profile stay
|
|
237
|
-
// current (and so a missing actor gets resolved for delivery shortly).
|
|
238
|
-
config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
|
|
239
261
|
const canonicalUri = cached?.uri ?? remoteActorUri;
|
|
240
262
|
const localActorUri = urls.actor(localUsername);
|
|
241
263
|
// Use the actor _id when known, otherwise a stable hash of the URI so the
|
|
242
264
|
// activity ID is deterministic across retries before the actor is cached.
|
|
243
|
-
const activityIdSuffix = cached?._id
|
|
244
|
-
? String(cached._id)
|
|
245
|
-
: encodeURIComponent(canonicalUri);
|
|
265
|
+
const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
|
|
246
266
|
const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
|
|
247
|
-
// Create or update the follow record
|
|
248
|
-
await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
|
|
249
267
|
const activity = {
|
|
250
268
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
251
269
|
id: activityId,
|
|
@@ -253,11 +271,16 @@ export function createDeliveryService(config) {
|
|
|
253
271
|
actor: localActorUri,
|
|
254
272
|
object: canonicalUri,
|
|
255
273
|
};
|
|
274
|
+
// Refuse before committing an outbound follow or reporting success.
|
|
275
|
+
await authorizeDelivery(activity, localOxyUserId);
|
|
276
|
+
// A refused follow must not start remote actor discovery either.
|
|
277
|
+
config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
|
|
278
|
+
await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
|
|
256
279
|
// If we know the inbox, attempt delivery in the background; otherwise queue
|
|
257
280
|
// for the delivery worker, which resolves the inbox once the actor lands.
|
|
258
281
|
const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
|
|
259
282
|
if (targetInbox && !isBlockedUrl(targetInbox)) {
|
|
260
|
-
void
|
|
283
|
+
void deliverAuthorizedActivity(activity, targetInbox, localUsername)
|
|
261
284
|
.then((delivered) => {
|
|
262
285
|
if (!delivered)
|
|
263
286
|
return queueDelivery(activity, targetInbox, localOxyUserId);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Shared discovery config validation; zero forbids downstream response retention. */
|
|
2
|
+
export function discoveryCacheControl(value, defaultSeconds) {
|
|
3
|
+
const seconds = value ?? defaultSeconds;
|
|
4
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 86400) {
|
|
5
|
+
throw new RangeError('httpCacheTtlSeconds must be an integer from 0 to 86400');
|
|
6
|
+
}
|
|
7
|
+
return seconds === 0 ? 'no-store' : `max-age=${seconds}`;
|
|
8
|
+
}
|
|
@@ -217,9 +217,10 @@ export function createInboundDispatcher(config) {
|
|
|
217
217
|
}
|
|
218
218
|
else if (object.type === 'Follow') {
|
|
219
219
|
const followActivityId = object.id;
|
|
220
|
-
updated =
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
updated =
|
|
221
|
+
typeof followActivityId === 'string' && followActivityId.length > 0
|
|
222
|
+
? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
|
|
223
|
+
: await config.follows.markOutboundAcceptedAnyPending(actorUri);
|
|
223
224
|
}
|
|
224
225
|
if (updated) {
|
|
225
226
|
logger.debug(`Follow accepted by ${actorUri}`);
|
package/dist/esm/node/index.js
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* actor + inbox + follow-graph Express routers.
|
|
18
18
|
*/
|
|
19
19
|
export { createSignedFetch, } from './signedFetch.js';
|
|
20
|
+
/**
|
|
21
|
+
* X's `t.co` wrapper unwrapped into the link the author posted, over the same
|
|
22
|
+
* injected single-hop transport — for bridged X bios and posts.
|
|
23
|
+
*/
|
|
24
|
+
export { createTcoLinkExpander, } from './tcoLinks.js';
|
|
20
25
|
/**
|
|
21
26
|
* The actor↔Oxy-user identity bridge — the default implementation of the
|
|
22
27
|
* `PUT /users/resolve` + actor-gone archive/delete seam over an injected
|
|
@@ -34,7 +39,7 @@ export { createActorResolver, ActorResolver, } from './actorResolver.js';
|
|
|
34
39
|
* Accept(Follow)) + the `Update(Person)` actor rebroadcast, over injected key
|
|
35
40
|
* custody, an SSRF-safe delivery transport, and bring-your-own-store adapters.
|
|
36
41
|
*/
|
|
37
|
-
export { createDeliveryService, } from './delivery.js';
|
|
42
|
+
export { createDeliveryService, DeliveryAuthorizationDeniedError, } from './delivery.js';
|
|
38
43
|
/**
|
|
39
44
|
* Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
|
|
40
45
|
* follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expanding X's `t.co` link wrapper back into the link the author posted.
|
|
3
|
+
*
|
|
4
|
+
* X rewrites EVERY link in a post or bio into `https://t.co/<id>`, and a bridge
|
|
5
|
+
* that mirrors an X account (bird.makeup, mastox) republishes that text as it
|
|
6
|
+
* is: the actor `summary` and the Note `content` carry only the wrapper. The
|
|
7
|
+
* author never wrote `t.co`; it is X's click tracker standing in for their link,
|
|
8
|
+
* and an X API client never shows it because it reads the expansion beside it.
|
|
9
|
+
* The bridges drop that expansion, so the only place left to recover it is the
|
|
10
|
+
* wrapper's own redirect.
|
|
11
|
+
*
|
|
12
|
+
* Only `t.co` is unwrapped, and only ONE hop. A `bit.ly` the author chose is
|
|
13
|
+
* their link and stays; so does wherever their link redirects next. What is
|
|
14
|
+
* replaced is exactly X's wrapper, by exactly the URL it names.
|
|
15
|
+
*
|
|
16
|
+
* The transport is injected — the same SSRF-safe, IP-pinned single-hop fetch
|
|
17
|
+
* {@link createSignedFetch} takes — so each backend keeps its own network
|
|
18
|
+
* contract. A link that does not resolve (timeout, 404, a malformed or
|
|
19
|
+
* self-referencing `Location`) is left as `t.co`: still a working link, never a
|
|
20
|
+
* broken one.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* A `t.co` link in plain text. X ids are alphanumeric; the lookahead stops a
|
|
24
|
+
* longer token (`t.co/abc/def`, `t.co/abcé`) from being half-replaced.
|
|
25
|
+
*/
|
|
26
|
+
const TCO_LINK = /\bhttps?:\/\/t\.co\/([A-Za-z0-9]+)(?![\w/])/gi;
|
|
27
|
+
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
28
|
+
/** A link that has not answered by then is kept as `t.co`. */
|
|
29
|
+
const TCO_TIMEOUT_MS = 5000;
|
|
30
|
+
/** Bounds the requests one text can cost; the rest keep their wrapper. */
|
|
31
|
+
const MAX_LINKS_PER_TEXT = 10;
|
|
32
|
+
/** The `t.co` ids in `text`, in order of first appearance. */
|
|
33
|
+
function findTcoLinkIds(text) {
|
|
34
|
+
return [...new Set(Array.from(text.matchAll(TCO_LINK), (match) => match[1]))];
|
|
35
|
+
}
|
|
36
|
+
/** The redirect target, if it is an http(s) URL that is not another `t.co` wrapper. */
|
|
37
|
+
function readTarget(response, from) {
|
|
38
|
+
if (!REDIRECT_STATUS_CODES.has(response.status))
|
|
39
|
+
return null;
|
|
40
|
+
const location = response.headers.get('location');
|
|
41
|
+
if (!location)
|
|
42
|
+
return null;
|
|
43
|
+
let target;
|
|
44
|
+
try {
|
|
45
|
+
target = new URL(location, from);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (target.protocol !== 'https:' && target.protocol !== 'http:')
|
|
51
|
+
return null;
|
|
52
|
+
if (target.hostname.toLowerCase() === 't.co')
|
|
53
|
+
return null;
|
|
54
|
+
return target.href;
|
|
55
|
+
}
|
|
56
|
+
export function createTcoLinkExpander(config) {
|
|
57
|
+
const { cache } = config;
|
|
58
|
+
async function resolve(id) {
|
|
59
|
+
const cached = await cache?.get(id).catch(() => null);
|
|
60
|
+
if (cached)
|
|
61
|
+
return cached;
|
|
62
|
+
const from = `https://t.co/${id}`;
|
|
63
|
+
let target;
|
|
64
|
+
try {
|
|
65
|
+
const response = await config.fetchSingleHop(from, {
|
|
66
|
+
headers: { Accept: '*/*', 'User-Agent': config.userAgent },
|
|
67
|
+
signal: AbortSignal.timeout(TCO_TIMEOUT_MS),
|
|
68
|
+
headersTimeoutMs: TCO_TIMEOUT_MS,
|
|
69
|
+
});
|
|
70
|
+
target = readTarget(response, from);
|
|
71
|
+
await response.body?.cancel().catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (target)
|
|
77
|
+
await cache?.set(id, target).catch(() => undefined);
|
|
78
|
+
return target;
|
|
79
|
+
}
|
|
80
|
+
return async function expandTcoLinks(text) {
|
|
81
|
+
const ids = findTcoLinkIds(text).slice(0, MAX_LINKS_PER_TEXT);
|
|
82
|
+
if (ids.length === 0)
|
|
83
|
+
return text;
|
|
84
|
+
const targets = new Map();
|
|
85
|
+
await Promise.all(ids.map(async (id) => {
|
|
86
|
+
const target = await resolve(id);
|
|
87
|
+
if (target)
|
|
88
|
+
targets.set(id, target);
|
|
89
|
+
}));
|
|
90
|
+
if (targets.size === 0)
|
|
91
|
+
return text;
|
|
92
|
+
return text.replace(TCO_LINK, (whole, id) => targets.get(id) ?? whole);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* Extracted behaviour-identically from Mention's `wellKnown.routes.ts`.
|
|
13
13
|
*/
|
|
14
14
|
import { Router } from 'express';
|
|
15
|
+
import { discoveryCacheControl } from './httpCacheControl.js';
|
|
15
16
|
import { isSameFederationHost } from '../apUri.js';
|
|
16
17
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
|
|
17
|
-
/**
|
|
18
|
+
/** Default HTTP response max-age. The injected JRD cache has its own policy. */
|
|
18
19
|
const WEBFINGER_CACHE_TTL = 3600;
|
|
19
20
|
/** 24h — host-meta is effectively static. */
|
|
20
21
|
const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
|
|
@@ -22,6 +23,7 @@ const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
|
|
|
22
23
|
export function createWebfingerRouter(config) {
|
|
23
24
|
const router = Router();
|
|
24
25
|
const { domain } = config;
|
|
26
|
+
const httpCacheControl = discoveryCacheControl(config.httpCacheTtlSeconds, WEBFINGER_CACHE_TTL);
|
|
25
27
|
const webfingerTemplate = `https://${domain}/.well-known/webfinger?resource={uri}`;
|
|
26
28
|
router.get('/webfinger', async (req, res) => {
|
|
27
29
|
if (!config.federationEnabled) {
|
|
@@ -74,14 +76,14 @@ export function createWebfingerRouter(config) {
|
|
|
74
76
|
],
|
|
75
77
|
};
|
|
76
78
|
res.set('Content-Type', 'application/jrd+json; charset=utf-8');
|
|
77
|
-
res.set('Cache-Control',
|
|
79
|
+
res.set('Cache-Control', httpCacheControl);
|
|
78
80
|
return res.json(instanceJrd);
|
|
79
81
|
}
|
|
80
82
|
// Check the JRD cache first.
|
|
81
83
|
const cached = await config.cache.get(username);
|
|
82
84
|
if (cached) {
|
|
83
85
|
res.set('Content-Type', 'application/jrd+json; charset=utf-8');
|
|
84
|
-
res.set('Cache-Control',
|
|
86
|
+
res.set('Cache-Control', httpCacheControl);
|
|
85
87
|
return res.json(cached);
|
|
86
88
|
}
|
|
87
89
|
const user = await config.resolveUser(username);
|
|
@@ -122,7 +124,7 @@ export function createWebfingerRouter(config) {
|
|
|
122
124
|
};
|
|
123
125
|
config.cache.set(username, response);
|
|
124
126
|
res.set('Content-Type', 'application/jrd+json; charset=utf-8');
|
|
125
|
-
res.set('Cache-Control',
|
|
127
|
+
res.set('Cache-Control', httpCacheControl);
|
|
126
128
|
return res.json(response);
|
|
127
129
|
}
|
|
128
130
|
catch (err) {
|