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