@oxy.so/federation 1.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 +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/actorObject.js +216 -0
- package/dist/cjs/apContext.js +48 -0
- package/dist/cjs/apUri.js +132 -0
- package/dist/cjs/httpSignature.js +187 -0
- package/dist/cjs/index.js +99 -0
- package/dist/cjs/networkIdentity.js +487 -0
- package/dist/cjs/node/actorResolver.js +625 -0
- package/dist/cjs/node/actorRouter.js +307 -0
- package/dist/cjs/node/delivery.js +415 -0
- package/dist/cjs/node/identityBridge.js +133 -0
- package/dist/cjs/node/inboundDispatch.js +268 -0
- package/dist/cjs/node/index.js +63 -0
- package/dist/cjs/node/signedFetch.js +122 -0
- package/dist/cjs/node/webfingerRouter.js +166 -0
- package/dist/cjs/urls.js +55 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/actorObject.js +210 -0
- package/dist/esm/apContext.js +45 -0
- package/dist/esm/apUri.js +126 -0
- package/dist/esm/httpSignature.js +179 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/networkIdentity.js +472 -0
- package/dist/esm/node/actorResolver.js +620 -0
- package/dist/esm/node/actorRouter.js +304 -0
- package/dist/esm/node/delivery.js +412 -0
- package/dist/esm/node/identityBridge.js +130 -0
- package/dist/esm/node/inboundDispatch.js +263 -0
- package/dist/esm/node/index.js +51 -0
- package/dist/esm/node/signedFetch.js +119 -0
- package/dist/esm/node/webfingerRouter.js +163 -0
- package/dist/esm/urls.js +50 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/actorObject.d.ts +182 -0
- package/dist/types/apContext.d.ts +35 -0
- package/dist/types/apUri.d.ts +107 -0
- package/dist/types/httpSignature.d.ts +113 -0
- package/dist/types/index.d.ts +336 -0
- package/dist/types/networkIdentity.d.ts +509 -0
- package/dist/types/node/actorResolver.d.ts +287 -0
- package/dist/types/node/actorRouter.d.ts +108 -0
- package/dist/types/node/delivery.d.ts +248 -0
- package/dist/types/node/identityBridge.d.ts +84 -0
- package/dist/types/node/inboundDispatch.d.ts +156 -0
- package/dist/types/node/index.d.ts +51 -0
- package/dist/types/node/signedFetch.d.ts +74 -0
- package/dist/types/node/webfingerRouter.d.ts +62 -0
- package/dist/types/urls.d.ts +55 -0
- package/package.json +119 -0
- package/src/__tests__/actorObject.test.ts +258 -0
- package/src/__tests__/actorResolver.test.ts +252 -0
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
- package/src/__tests__/apUri.test.ts +53 -0
- package/src/__tests__/delivery.test.ts +432 -0
- package/src/__tests__/federationHost.test.ts +281 -0
- package/src/__tests__/httpSignature.test.ts +343 -0
- package/src/__tests__/inboundDispatch.test.ts +381 -0
- package/src/__tests__/index.test.ts +8 -0
- package/src/__tests__/networkIdentity.test.ts +525 -0
- package/src/__tests__/routers.test.ts +460 -0
- package/src/__tests__/urls.test.ts +26 -0
- package/src/actorObject.ts +313 -0
- package/src/apContext.ts +45 -0
- package/src/apUri.ts +161 -0
- package/src/httpSignature.ts +282 -0
- package/src/index.ts +419 -0
- package/src/networkIdentity.ts +731 -0
- package/src/node/actorResolver.ts +839 -0
- package/src/node/actorRouter.ts +438 -0
- package/src/node/delivery.ts +729 -0
- package/src/node/identityBridge.ts +230 -0
- package/src/node/inboundDispatch.ts +420 -0
- package/src/node/index.ts +136 -0
- package/src/node/signedFetch.ts +177 -0
- package/src/node/webfingerRouter.ts +226 -0
- package/src/urls.ts +71 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution, caching and refresh of remote ActivityPub actors.
|
|
3
|
+
*
|
|
4
|
+
* Extracted behaviour-identically from Mention's `ActorService`. The engine owns
|
|
5
|
+
* the PROTOCOL — webfinger resolution, the signed actor fetch, the redirect /
|
|
6
|
+
* WebFinger fallback, the 410-Gone tombstone, the self-consistency + same-origin
|
|
7
|
+
* guards, and the staleness/refresh policy. Everything app-specific is injected:
|
|
8
|
+
*
|
|
9
|
+
* - the FederatedActor CACHE lives in the app DB, reached through a
|
|
10
|
+
* {@link FederatedActorStore} adapter ("bring your own store" — no data move),
|
|
11
|
+
* - the actor↔Oxy-user bridge is the injected {@link ActorResolverIdentity}
|
|
12
|
+
* (`PUT /users/resolve` + actor-gone archive),
|
|
13
|
+
* - the signed AP fetch + the SSRF-safe WebFinger fetch are injected transports,
|
|
14
|
+
* - remote-text normalization is an injected {@link ActorTextAdapter} (the app's
|
|
15
|
+
* canonical normalizer + sanitizer), so the engine ships no HTML deps.
|
|
16
|
+
*
|
|
17
|
+
* The resolver is generic over the app's stored actor record shape (`TActor`,
|
|
18
|
+
* e.g. Mention's `IFederatedActor`) so callers keep full typing on the returned
|
|
19
|
+
* document.
|
|
20
|
+
*/
|
|
21
|
+
import { isSameFederationHost } from '../apUri.js';
|
|
22
|
+
import { readProxyDeclarations, } from '../networkIdentity.js';
|
|
23
|
+
/**
|
|
24
|
+
* Minimum interval between background actor refreshes for the same actor.
|
|
25
|
+
* Prevents refresh storms when a profile is viewed repeatedly in a short window.
|
|
26
|
+
*/
|
|
27
|
+
const ACTOR_REFRESH_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
28
|
+
/** Staleness threshold after which a cached actor is eligible for a background re-fetch. */
|
|
29
|
+
const ACTOR_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
30
|
+
/** The AP content type asked for on signed actor/collection fetches. */
|
|
31
|
+
const AP_CONTENT_TYPE = 'application/activity+json';
|
|
32
|
+
/** Maximum decompressed response sizes accepted from untrusted federation hosts. */
|
|
33
|
+
const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
|
|
34
|
+
const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
|
|
35
|
+
const ERROR_BODY_MAX_BYTES = 4 * 1024;
|
|
36
|
+
async function readBoundedResponseBody(res, maxBytes) {
|
|
37
|
+
const contentLength = res.headers.get('content-length');
|
|
38
|
+
if (contentLength) {
|
|
39
|
+
const declaredBytes = Number(contentLength);
|
|
40
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
41
|
+
await res.body?.cancel().catch(() => { });
|
|
42
|
+
throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!res.body)
|
|
46
|
+
return '';
|
|
47
|
+
const reader = res.body.getReader();
|
|
48
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
49
|
+
let bytesRead = 0;
|
|
50
|
+
let text = '';
|
|
51
|
+
try {
|
|
52
|
+
while (true) {
|
|
53
|
+
const { done, value } = await reader.read();
|
|
54
|
+
if (done)
|
|
55
|
+
break;
|
|
56
|
+
bytesRead += value.byteLength;
|
|
57
|
+
if (bytesRead > maxBytes) {
|
|
58
|
+
await reader.cancel().catch(() => { });
|
|
59
|
+
throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
|
|
60
|
+
}
|
|
61
|
+
text += decoder.decode(value, { stream: true });
|
|
62
|
+
}
|
|
63
|
+
return text + decoder.decode();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function readBoundedJson(res, maxBytes) {
|
|
70
|
+
const body = await readBoundedResponseBody(res, maxBytes);
|
|
71
|
+
// An empty body is a REMOTE's answer, not a parser error. Without this,
|
|
72
|
+
// `JSON.parse('')` throws a SyntaxError whose message names a column number,
|
|
73
|
+
// which is a worse thing to find in a federation log than the fact that the
|
|
74
|
+
// instance sent nothing.
|
|
75
|
+
if (body.length === 0)
|
|
76
|
+
throw new Error('Remote response has no body');
|
|
77
|
+
const value = JSON.parse(body);
|
|
78
|
+
const record = asRecord(value);
|
|
79
|
+
if (!record)
|
|
80
|
+
throw new Error('Remote response is not a JSON object');
|
|
81
|
+
return record;
|
|
82
|
+
}
|
|
83
|
+
function isApActorContentType(type) {
|
|
84
|
+
if (!type)
|
|
85
|
+
return false;
|
|
86
|
+
const base = type.split(';')[0]?.trim().toLowerCase();
|
|
87
|
+
return base === 'application/activity+json' || base === 'application/ld+json';
|
|
88
|
+
}
|
|
89
|
+
function asRecord(value) {
|
|
90
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
91
|
+
? value
|
|
92
|
+
: null;
|
|
93
|
+
}
|
|
94
|
+
function asString(value) {
|
|
95
|
+
return typeof value === 'string' ? value : undefined;
|
|
96
|
+
}
|
|
97
|
+
function sameOriginUrl(a, b) {
|
|
98
|
+
try {
|
|
99
|
+
const urlA = new URL(a);
|
|
100
|
+
const urlB = new URL(b);
|
|
101
|
+
return urlA.protocol === urlB.protocol
|
|
102
|
+
&& urlA.port === urlB.port
|
|
103
|
+
&& isSameFederationHost(urlA.hostname, urlB.hostname);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function actorPublicKeyIsSelfConsistent(actor, actorId) {
|
|
110
|
+
const publicKey = asRecord(actor.publicKey);
|
|
111
|
+
if (!publicKey)
|
|
112
|
+
return true;
|
|
113
|
+
const publicKeyId = asString(publicKey.id);
|
|
114
|
+
if (publicKeyId && !sameOriginUrl(publicKeyId, actorId))
|
|
115
|
+
return false;
|
|
116
|
+
const owner = asString(publicKey.owner);
|
|
117
|
+
if (owner && owner !== actorId)
|
|
118
|
+
return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolution, caching and refresh of remote ActivityPub actors, over app-provided
|
|
123
|
+
* storage + identity + transports. A class so that internal cross-calls dispatch
|
|
124
|
+
* through the instance (e.g. `fetchRemoteActor` → `this.tombstoneGoneActor`),
|
|
125
|
+
* which keeps them spy-able and overridable in tests.
|
|
126
|
+
*/
|
|
127
|
+
export class ActorResolver {
|
|
128
|
+
constructor(config) {
|
|
129
|
+
this.config = config;
|
|
130
|
+
/** Actor URIs with an in-flight background refresh (guards against refresh storms). */
|
|
131
|
+
this.inFlightActorRefreshes = new Set();
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Whether an actor URI's host is refused by the instance domain policy. An
|
|
135
|
+
* unparseable URI has no host to check, so it is refused too — the policy is a
|
|
136
|
+
* safety gate and fails closed rather than letting a malformed URI slip past it.
|
|
137
|
+
*/
|
|
138
|
+
isBlockedActorUri(actorUri) {
|
|
139
|
+
let host;
|
|
140
|
+
try {
|
|
141
|
+
host = new URL(actorUri).hostname.toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return this.config.isBlockedDomain(host);
|
|
147
|
+
}
|
|
148
|
+
acctMatchesActorHost(acct, actorHost) {
|
|
149
|
+
if (!acct)
|
|
150
|
+
return false;
|
|
151
|
+
const domain = this.config.domainFromAcct(acct);
|
|
152
|
+
if (!domain)
|
|
153
|
+
return false;
|
|
154
|
+
return isSameFederationHost(domain, actorHost);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a WebFinger acct to an ActivityPub actor URI.
|
|
158
|
+
* @param acct - e.g. "alice@mastodon.social" or "@alice@mastodon.social"
|
|
159
|
+
*/
|
|
160
|
+
async resolveWebFinger(acct) {
|
|
161
|
+
const cleaned = this.config.normalizeFederatedAcct(acct);
|
|
162
|
+
if (!cleaned)
|
|
163
|
+
return null;
|
|
164
|
+
const domain = this.config.domainFromAcct(cleaned);
|
|
165
|
+
if (!domain)
|
|
166
|
+
return null;
|
|
167
|
+
if (this.config.isBlockedDomain(domain))
|
|
168
|
+
return null;
|
|
169
|
+
const resource = `acct:${cleaned}`;
|
|
170
|
+
const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
|
|
171
|
+
try {
|
|
172
|
+
const data = await this.config.fetchWebFinger(url);
|
|
173
|
+
if (!data)
|
|
174
|
+
return null;
|
|
175
|
+
const link = data.links?.find((l) => l.rel === 'self' && isApActorContentType(l.type));
|
|
176
|
+
return link?.href || null;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
this.config.logger.warn(`WebFinger resolution failed for ${acct}:`, err);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Fetch and store/update a remote ActivityPub actor by URI.
|
|
185
|
+
*
|
|
186
|
+
* @param actorUri - the remote actor URI to fetch.
|
|
187
|
+
* @param forceAvatarRefresh - when true, tell Oxy's `PUT /users/resolve` to
|
|
188
|
+
* re-download and replace the federated avatar even if it already has a stored
|
|
189
|
+
* file ID. Pass `true` from refresh paths and `false` for first-time creation.
|
|
190
|
+
*/
|
|
191
|
+
async fetchRemoteActor(actorUri, forceAvatarRefresh = false, acctHint) {
|
|
192
|
+
// A WebFinger fallback may resolve the stored URI to a different canonical one;
|
|
193
|
+
// track that here rather than reassigning the parameter (the stored row is
|
|
194
|
+
// keyed by the fetched `actor.id`, so a redirect only affects log context).
|
|
195
|
+
let currentUri = actorUri;
|
|
196
|
+
try {
|
|
197
|
+
// Reject our own/blocked domains before any network I/O. A malformed URI
|
|
198
|
+
// throws here and is handled by the catch below.
|
|
199
|
+
const requestedHost = new URL(currentUri).hostname.toLowerCase();
|
|
200
|
+
if (this.config.isBlockedDomain(requestedHost)) {
|
|
201
|
+
this.config.logger.info(`[FedSync] fetchRemoteActor skipping own/blocked domain ${requestedHost} for ${currentUri}`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const canonicalAcctHint = this.config.normalizeFederatedAcct(acctHint);
|
|
205
|
+
// Use signed fetch for servers that enforce authorized fetch (e.g., Threads)
|
|
206
|
+
let res = await this.config.signedFetch(currentUri, AP_CONTENT_TYPE);
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
// A definitive 410 Gone is authoritative: tombstone and stop — do NOT fall
|
|
209
|
+
// through to the WebFinger fallback (which recovers a STALE/wrong URI on a
|
|
210
|
+
// transient failure, not a permanent removal). Only 410 does this.
|
|
211
|
+
if (res.status === 410) {
|
|
212
|
+
this.config.logger.info(`[FedSync] fetchRemoteActor 410 Gone for ${currentUri} — tombstoning actor`);
|
|
213
|
+
await this.tombstoneGoneActor(currentUri);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const body = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(() => '');
|
|
217
|
+
this.config.logger.info(`[FedSync] fetchRemoteActor HTTP ${res.status} ${res.statusText} for ${currentUri} body=${body.slice(0, 500)}`);
|
|
218
|
+
// If direct fetch failed, try WebFinger to resolve the canonical actor URI.
|
|
219
|
+
// Some servers (e.g., Threads) use numeric IDs in AP URIs that differ from
|
|
220
|
+
// the username-based URI we may have stored.
|
|
221
|
+
const parsed = new URL(currentUri);
|
|
222
|
+
const pathUsername = parsed.pathname.split('/').filter(Boolean).pop();
|
|
223
|
+
const acct = canonicalAcctHint
|
|
224
|
+
|| (pathUsername ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`) : undefined);
|
|
225
|
+
if (acct) {
|
|
226
|
+
this.config.logger.info(`[FedSync] attempting WebFinger fallback for ${acct}`);
|
|
227
|
+
const resolved = await this.resolveWebFinger(acct);
|
|
228
|
+
if (resolved && resolved !== currentUri) {
|
|
229
|
+
this.config.logger.info(`[FedSync] WebFinger resolved ${acct} → ${resolved}`);
|
|
230
|
+
res = await this.config.signedFetch(resolved, AP_CONTENT_TYPE);
|
|
231
|
+
if (res.ok) {
|
|
232
|
+
currentUri = resolved;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// A 410 on the WebFinger-RESOLVED URI is just as definitive. Tombstone
|
|
236
|
+
// against the stored URI (not reassigned on this branch).
|
|
237
|
+
if (res.status === 410) {
|
|
238
|
+
this.config.logger.info(`[FedSync] fetchRemoteActor 410 Gone for resolved ${resolved} — tombstoning actor ${currentUri}`);
|
|
239
|
+
await this.tombstoneGoneActor(currentUri);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const body2 = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(() => '');
|
|
243
|
+
this.config.logger.info(`[FedSync] fetchRemoteActor HTTP ${res.status} for resolved ${resolved} body=${body2.slice(0, 500)}`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
this.config.logger.info(`[FedSync] WebFinger returned ${resolved ?? 'null'} for ${acct}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const actor = await readBoundedJson(res, ACTOR_BODY_MAX_BYTES);
|
|
257
|
+
const actorId = asString(actor.id);
|
|
258
|
+
const actorInbox = asString(actor.inbox);
|
|
259
|
+
if (!actorId || !actorInbox) {
|
|
260
|
+
this.config.logger.info(`[FedSync] fetchRemoteActor missing fields for ${currentUri}: id=${!!actor.id} inbox=${!!actor.inbox} type=${String(actor.type)} keys=${Object.keys(actor).join(',')}`);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
if (!sameOriginUrl(currentUri, actorId)) {
|
|
264
|
+
this.config.logger.warn(`[FedSync] rejecting actor ${currentUri}: fetched URI is not authoritative for claimed id ${actorId}`);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (!actorPublicKeyIsSelfConsistent(actor, actorId)) {
|
|
268
|
+
this.config.logger.warn(`[FedSync] rejecting actor ${currentUri}: publicKey is not self-consistent for claimed id ${actorId}`);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const actorHost = new URL(actorId).hostname.toLowerCase();
|
|
272
|
+
const username = this.config.text.inlineField(actor.preferredUsername)
|
|
273
|
+
|| this.config.text.inlineField(actor.name)
|
|
274
|
+
|| 'unknown';
|
|
275
|
+
const actorWebfinger = typeof actor.webfinger === 'string'
|
|
276
|
+
? this.config.normalizeFederatedAcct(actor.webfinger)
|
|
277
|
+
: undefined;
|
|
278
|
+
const verifiedAcctHint = this.acctMatchesActorHost(canonicalAcctHint, actorHost)
|
|
279
|
+
? canonicalAcctHint
|
|
280
|
+
: undefined;
|
|
281
|
+
const verifiedActorWebfinger = this.acctMatchesActorHost(actorWebfinger, actorHost)
|
|
282
|
+
? actorWebfinger
|
|
283
|
+
: undefined;
|
|
284
|
+
const acct = verifiedAcctHint
|
|
285
|
+
|| verifiedActorWebfinger
|
|
286
|
+
|| this.config.normalizeFederatedAcct(`${username}@${actorHost}`)
|
|
287
|
+
|| `${username.toLowerCase()}@${actorHost}`;
|
|
288
|
+
const domain = this.config.domainFromAcct(acct) || actorHost;
|
|
289
|
+
// Re-check against the RESOLVED host/acct (post-redirect / WebFinger), which
|
|
290
|
+
// can differ from the originally-requested URI host the early guard screened.
|
|
291
|
+
if (this.config.isBlockedDomain(domain) || this.config.isBlockedDomain(actorHost)) {
|
|
292
|
+
this.config.logger.info(`[FedSync] fetchRemoteActor blocked domain ${domain} actorHost=${actorHost} for ${currentUri}`);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const actorEndpoints = asRecord(actor.endpoints);
|
|
296
|
+
const actorPublicKey = asRecord(actor.publicKey);
|
|
297
|
+
// Fetch collection counts (followers, following, posts) in parallel
|
|
298
|
+
const [followersCount, followingCount, postsCount] = await Promise.all([
|
|
299
|
+
this.fetchCollectionCount(asString(actor.followers)),
|
|
300
|
+
this.fetchCollectionCount(asString(actor.following)),
|
|
301
|
+
this.fetchCollectionCount(asString(actor.outbox)),
|
|
302
|
+
]);
|
|
303
|
+
// Extract profile fields (PropertyValue attachments). Sanitize BEFORE
|
|
304
|
+
// normalizing: the canonical normalizer collapses whitespace, it never
|
|
305
|
+
// strips markup — so the sanitizer must run first, on the raw value.
|
|
306
|
+
const fields = [];
|
|
307
|
+
if (Array.isArray(actor.attachment)) {
|
|
308
|
+
for (const att of actor.attachment) {
|
|
309
|
+
const attRecord = asRecord(att);
|
|
310
|
+
if (!attRecord || attRecord.type !== 'PropertyValue')
|
|
311
|
+
continue;
|
|
312
|
+
const fieldName = this.config.text.inlineField(attRecord.name);
|
|
313
|
+
const fieldValue = typeof attRecord.value === 'string'
|
|
314
|
+
? this.config.text.sanitizeFieldValue(attRecord.value)
|
|
315
|
+
: '';
|
|
316
|
+
if (!fieldName || !fieldValue)
|
|
317
|
+
continue;
|
|
318
|
+
fields.push({
|
|
319
|
+
name: fieldName,
|
|
320
|
+
value: fieldValue,
|
|
321
|
+
verifiedAt: attRecord.verifiedAt ? new Date(String(attRecord.verifiedAt)) : undefined,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const avatarUrl = this.config.firstStringUrl(actor.icon);
|
|
326
|
+
const headerUrl = this.config.firstStringUrl(actor.image);
|
|
327
|
+
// `summary` is the actor's bio — a BODY, so its line breaks are the author's
|
|
328
|
+
// and must survive; `htmlToPlainText` normalizes it as multiline.
|
|
329
|
+
const summary = typeof actor.summary === 'string' ? this.config.text.htmlToPlainText(actor.summary) : '';
|
|
330
|
+
// The display name is one line. Entity-decode FIRST (an encoded ` ` or
|
|
331
|
+
// ` ` only becomes whitespace once decoded), THEN collapse.
|
|
332
|
+
const rawDisplayName = typeof actor.name === 'string' ? actor.name : '';
|
|
333
|
+
const displayName = this.config.text.inlineDisplayName(rawDisplayName) || username;
|
|
334
|
+
const alsoKnownAs = Array.isArray(actor.alsoKnownAs)
|
|
335
|
+
? actor.alsoKnownAs.filter((v) => typeof v === 'string')
|
|
336
|
+
: undefined;
|
|
337
|
+
// The IDENTITY the actor is stored under in Oxy, which is not necessarily the
|
|
338
|
+
// host it was fetched from — see `DeriveNetworkIdentity`. Everything below
|
|
339
|
+
// that addresses the actor over the PROTOCOL (`acct`, `uri`, `domain`) is
|
|
340
|
+
// deliberately left alone.
|
|
341
|
+
const networkIdentity = this.resolveNetworkIdentity({
|
|
342
|
+
host: actorHost,
|
|
343
|
+
acct,
|
|
344
|
+
preferredUsername: username,
|
|
345
|
+
actorUri: actorId,
|
|
346
|
+
actorType: asString(actor.type) || 'Person',
|
|
347
|
+
alsoKnownAs: alsoKnownAs ?? [],
|
|
348
|
+
fields,
|
|
349
|
+
// FEP-fffd: an actor's own machine-readable statement of what it proxies.
|
|
350
|
+
// Parsed here so a derivation rule never re-reads the raw document — and
|
|
351
|
+
// deliberately only ever CONSULTED by a reviewed entry, since every field
|
|
352
|
+
// in it is asserted by the untrusted actor itself.
|
|
353
|
+
proxyOf: readProxyDeclarations(actor.proxyOf),
|
|
354
|
+
bio: summary,
|
|
355
|
+
});
|
|
356
|
+
const identityUsername = networkIdentity?.federatedUsername ?? acct;
|
|
357
|
+
const identityDomain = networkIdentity?.instanceDomain ?? domain;
|
|
358
|
+
// Qualified HERE, at the one point both writes below read from: the stored
|
|
359
|
+
// row's `summary` and the Oxy profile's `bio` are the same value, so they
|
|
360
|
+
// cannot drift into disagreeing about what the actor said.
|
|
361
|
+
const resolvedBio = networkIdentity?.bio ?? summary;
|
|
362
|
+
const identityBio = this.config.text.qualifyHandles
|
|
363
|
+
? this.config.text.qualifyHandles(resolvedBio, identityDomain)
|
|
364
|
+
: resolvedBio;
|
|
365
|
+
const update = {
|
|
366
|
+
protocol: 'activitypub',
|
|
367
|
+
uri: actorId,
|
|
368
|
+
username,
|
|
369
|
+
domain,
|
|
370
|
+
acct,
|
|
371
|
+
summary: identityBio,
|
|
372
|
+
avatarUrl,
|
|
373
|
+
headerUrl,
|
|
374
|
+
inboxUrl: actorInbox,
|
|
375
|
+
outboxUrl: asString(actor.outbox) || undefined,
|
|
376
|
+
sharedInboxUrl: asString(actorEndpoints?.sharedInbox) || undefined,
|
|
377
|
+
followersUrl: asString(actor.followers) || undefined,
|
|
378
|
+
followingUrl: asString(actor.following) || undefined,
|
|
379
|
+
publicKeyPem: asString(actorPublicKey?.publicKeyPem) || undefined,
|
|
380
|
+
publicKeyId: asString(actorPublicKey?.id) || undefined,
|
|
381
|
+
type: asString(actor.type) || 'Person',
|
|
382
|
+
manuallyApprovesFollowers: actor.manuallyApprovesFollowers === true,
|
|
383
|
+
discoverable: actor.discoverable !== false,
|
|
384
|
+
memorial: actor.memorial === true,
|
|
385
|
+
suspended: actor.suspended === true,
|
|
386
|
+
fields,
|
|
387
|
+
featuredUrl: asString(actor.featured) || undefined,
|
|
388
|
+
featuredTagsUrl: asString(actor.featuredTags) || undefined,
|
|
389
|
+
alsoKnownAs,
|
|
390
|
+
networkAcct: networkIdentity?.federatedUsername,
|
|
391
|
+
remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
|
|
392
|
+
followersCount,
|
|
393
|
+
followingCount,
|
|
394
|
+
postsCount,
|
|
395
|
+
lastFetchedAt: new Date(),
|
|
396
|
+
};
|
|
397
|
+
const fedActor = await this.config.store.upsertActor(actorId, update);
|
|
398
|
+
// Always upsert into Oxy so profile changes (avatar, name, bio) are synced.
|
|
399
|
+
// The identity bridge creates the federated Oxy user if it does not exist,
|
|
400
|
+
// updates it when changed, and mirrors the banner. This connector then stamps
|
|
401
|
+
// its own actor row with the resolved id.
|
|
402
|
+
if (fedActor) {
|
|
403
|
+
try {
|
|
404
|
+
const normalized = {
|
|
405
|
+
network: 'activitypub',
|
|
406
|
+
externalId: actorId,
|
|
407
|
+
handle: acct,
|
|
408
|
+
// For an ordinary AP actor the acct IS the canonical `user@domain` Oxy
|
|
409
|
+
// username and `domain` is its instance host — both verified above. For
|
|
410
|
+
// a BRIDGED actor these two carry the re-labelled network identity
|
|
411
|
+
// instead, while `handle` above stays the protocol address.
|
|
412
|
+
federatedUsername: identityUsername,
|
|
413
|
+
instanceDomain: identityDomain,
|
|
414
|
+
displayName,
|
|
415
|
+
avatarUrl,
|
|
416
|
+
bannerUrl: headerUrl,
|
|
417
|
+
// Sent even when EMPTY, and that is the whole point. oxy-api writes
|
|
418
|
+
// this field only when it receives a string, so omitting it means
|
|
419
|
+
// "keep whatever you already stored" — which is exactly wrong for the
|
|
420
|
+
// two ways a bio legitimately becomes empty: a bridged actor whose
|
|
421
|
+
// bio was nothing but the bridge's boilerplate (stripped above), and
|
|
422
|
+
// any actor who simply deleted theirs upstream. Coalescing the empty
|
|
423
|
+
// string away made both of those unrepresentable, so the stale text
|
|
424
|
+
// survived every later refresh with nothing in the logs.
|
|
425
|
+
bio: identityBio,
|
|
426
|
+
followersCount,
|
|
427
|
+
followingCount,
|
|
428
|
+
postsCount,
|
|
429
|
+
oxyUserId: fedActor.oxyUserId ?? undefined,
|
|
430
|
+
};
|
|
431
|
+
const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
|
|
432
|
+
if (oxyId && fedActor.oxyUserId !== oxyId && fedActor._id != null) {
|
|
433
|
+
await this.config.store.setActorOxyUserId(fedActor._id, oxyId);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch (resolveErr) {
|
|
437
|
+
this.config.logger.warn(`Failed to resolve Oxy user for ${currentUri}:`, resolveErr);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return fedActor;
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
this.config.logger.warn(`Failed to fetch remote actor ${currentUri}:`, err);
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Run the app's {@link DeriveNetworkIdentity} hook and REFUSE any result the
|
|
449
|
+
* identity bridge could not bind.
|
|
450
|
+
*
|
|
451
|
+
* oxy-api binds a federated username to its domain, so a `federatedUsername`
|
|
452
|
+
* that does not end with `@${instanceDomain}` would be rejected downstream — or
|
|
453
|
+
* worse, mint an identity under a domain it does not name. Validating here means
|
|
454
|
+
* no app can produce that shape, and a hook that gets it wrong degrades to the
|
|
455
|
+
* actor's real protocol acct (the pre-hook behaviour) instead of losing the
|
|
456
|
+
* actor. The refusal is logged: it is a bug in the app's rule, not a normal
|
|
457
|
+
* outcome, and it must not pass silently.
|
|
458
|
+
*/
|
|
459
|
+
resolveNetworkIdentity(candidate) {
|
|
460
|
+
const derived = this.config.deriveNetworkIdentity?.(candidate);
|
|
461
|
+
if (!derived)
|
|
462
|
+
return undefined;
|
|
463
|
+
const domain = derived.instanceDomain.trim().toLowerCase();
|
|
464
|
+
const federatedUsername = derived.federatedUsername.trim().toLowerCase();
|
|
465
|
+
// Everything before the FIRST `@` is the local part; the whole value must then
|
|
466
|
+
// be exactly `<local>@<domain>`. Stated as one equality rather than a list of
|
|
467
|
+
// separate shape checks, so a value that is malformed in a way nobody thought
|
|
468
|
+
// of — a second `@`, a missing one, a different separator — fails by default
|
|
469
|
+
// instead of needing its own clause.
|
|
470
|
+
const atIndex = federatedUsername.indexOf('@');
|
|
471
|
+
const localPart = atIndex > 0 ? federatedUsername.slice(0, atIndex) : '';
|
|
472
|
+
if (domain.length === 0
|
|
473
|
+
|| localPart.length === 0
|
|
474
|
+
|| federatedUsername !== `${localPart}@${domain}`) {
|
|
475
|
+
this.config.logger.warn(`[FedSync] refusing network identity for ${candidate.actorUri}: `
|
|
476
|
+
+ `"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`);
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
return { federatedUsername, instanceDomain: domain, bio: derived.bio };
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Tombstone a remote actor that returned a definitive 410 Gone. Marks the stored
|
|
483
|
+
* actor suspended (via the store) and, when it links to an Oxy identity, asks
|
|
484
|
+
* oxy-api to archive it so it drops out of search.
|
|
485
|
+
*
|
|
486
|
+
* Best-effort and fail-soft: neither the store write nor the Oxy archive call is
|
|
487
|
+
* allowed to throw out of the caller. Idempotent.
|
|
488
|
+
*/
|
|
489
|
+
async tombstoneGoneActor(actorUri) {
|
|
490
|
+
try {
|
|
491
|
+
const actor = await this.config.store.tombstoneActor(actorUri);
|
|
492
|
+
if (!actor) {
|
|
493
|
+
this.config.logger.info(`[FedSync] 410 Gone for ${actorUri} — no stored actor row to tombstone`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
this.config.logger.info(`[FedSync] tombstoned gone actor ${actorUri} (suspended)`);
|
|
497
|
+
if (actor.oxyUserId) {
|
|
498
|
+
const outcome = await this.config.identity.reportActorGone(actor.oxyUserId);
|
|
499
|
+
this.config.logger.info(`[FedSync] actor-gone report for ${actorUri} (oxyUserId ${actor.oxyUserId}) → ${outcome}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Fetch the totalItems count from an ActivityPub collection URL. */
|
|
507
|
+
async fetchCollectionCount(url) {
|
|
508
|
+
if (!url)
|
|
509
|
+
return 0;
|
|
510
|
+
try {
|
|
511
|
+
const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
|
|
512
|
+
if (!res.ok)
|
|
513
|
+
return 0;
|
|
514
|
+
const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
|
|
515
|
+
return typeof col.totalItems === 'number' ? col.totalItems : 0;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Get a cached actor or fetch if missing/stale (>24h).
|
|
523
|
+
*
|
|
524
|
+
* Never blocks on remote network I/O when a cached actor already exists: a stale
|
|
525
|
+
* cached actor is returned immediately and a background refresh is enqueued. Only
|
|
526
|
+
* a completely missing actor triggers a blocking fetch.
|
|
527
|
+
*/
|
|
528
|
+
async getOrFetchActor(actorUri) {
|
|
529
|
+
// The domain policy governs BOTH branches below, not just the fetching one.
|
|
530
|
+
// `fetchRemoteActor` refuses a blocked host, but the cache hit above it used to
|
|
531
|
+
// return early — so for any instance we had ever stored an actor for (i.e. every
|
|
532
|
+
// instance that has ever reached us), blocking its domain changed nothing. That
|
|
533
|
+
// made the blocklist inert for exactly the hosts it is added for.
|
|
534
|
+
if (this.isBlockedActorUri(actorUri)) {
|
|
535
|
+
this.config.logger.info(`[FedSync] getOrFetchActor refusing own/blocked domain for ${actorUri}`);
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
const existing = await this.config.store.findActorByUri(actorUri);
|
|
539
|
+
if (existing) {
|
|
540
|
+
const isStale = !existing.lastFetchedAt || Date.now() - existing.lastFetchedAt.getTime() > ACTOR_STALE_MS;
|
|
541
|
+
if (isStale) {
|
|
542
|
+
// Refresh in the background — never block the caller on remote I/O.
|
|
543
|
+
this.refreshActorInBackground(actorUri, existing);
|
|
544
|
+
}
|
|
545
|
+
return existing;
|
|
546
|
+
}
|
|
547
|
+
return this.fetchRemoteActor(actorUri);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Enqueue a fire-and-forget full-actor refresh. Safe to call on a client request
|
|
551
|
+
* path: it returns synchronously and the fetch runs detached. Guards against
|
|
552
|
+
* refresh storms (in-flight dedup + a recency skip unless the profile is
|
|
553
|
+
* incomplete). The avatar refresh is forced only when the actor already exists.
|
|
554
|
+
*/
|
|
555
|
+
refreshActorInBackground(actorUri, existing) {
|
|
556
|
+
if (!this.config.federationEnabled)
|
|
557
|
+
return;
|
|
558
|
+
if (this.inFlightActorRefreshes.has(actorUri))
|
|
559
|
+
return;
|
|
560
|
+
const missingProfile = !existing || !existing.avatarUrl || !existing.headerUrl;
|
|
561
|
+
const lastFetchedMs = existing?.lastFetchedAt?.getTime();
|
|
562
|
+
const refreshedRecently = typeof lastFetchedMs === 'number'
|
|
563
|
+
&& Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
|
|
564
|
+
// Skip if we refreshed recently AND the cached profile is already complete.
|
|
565
|
+
if (refreshedRecently && !missingProfile)
|
|
566
|
+
return;
|
|
567
|
+
// Force avatar re-download only when the actor already exists (refresh).
|
|
568
|
+
const forceAvatarRefresh = Boolean(existing);
|
|
569
|
+
this.inFlightActorRefreshes.add(actorUri);
|
|
570
|
+
void (async () => {
|
|
571
|
+
try {
|
|
572
|
+
await this.fetchRemoteActor(actorUri, forceAvatarRefresh, existing?.acct);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
576
|
+
this.config.logger.warn(`[FedSync] background actor refresh failed for ${actorUri}: ${message}`);
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
this.inFlightActorRefreshes.delete(actorUri);
|
|
580
|
+
}
|
|
581
|
+
})();
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Resolve a remote actor URI to its listable Oxy user id. Returns null when the
|
|
585
|
+
* actor cannot be resolved to an Oxy user — callers must then skip.
|
|
586
|
+
*/
|
|
587
|
+
async resolveActorOxyUserId(actorUri) {
|
|
588
|
+
const actor = await this.getOrFetchActor(actorUri);
|
|
589
|
+
return actor?.oxyUserId ?? null;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Fetch a public key by keyId (used for HTTP signature verification).
|
|
593
|
+
*
|
|
594
|
+
* Deliberately NOT domain-policy gated on the cached branch: this answers "what
|
|
595
|
+
* key signs for this keyId", a question about authenticity, not about whether we
|
|
596
|
+
* federate with the answer. Suspending an instance is enforced where the activity
|
|
597
|
+
* is dispatched (`createInboundDispatcher`), so a blocked instance's signature is
|
|
598
|
+
* still evaluated honestly and its activity is then dropped as policy, rather
|
|
599
|
+
* than being reported as a forged signature. The uncached branch still refuses,
|
|
600
|
+
* because resolving it would mean network I/O toward a blocked host.
|
|
601
|
+
*/
|
|
602
|
+
async fetchPublicKey(keyId) {
|
|
603
|
+
// keyId is typically the actor URI with #main-key appended
|
|
604
|
+
const actorUri = keyId.replace(/#.*$/, '');
|
|
605
|
+
// Check local cache first
|
|
606
|
+
const cached = await this.config.store.findActorByPublicKeyId(keyId);
|
|
607
|
+
if (cached?.publicKeyPem) {
|
|
608
|
+
return { publicKeyPem: cached.publicKeyPem, actorUri: cached.uri };
|
|
609
|
+
}
|
|
610
|
+
// Fetch the actor to get the public key (uses 24h cache)
|
|
611
|
+
const actor = await this.getOrFetchActor(actorUri);
|
|
612
|
+
if (!actor?.publicKeyPem)
|
|
613
|
+
return null;
|
|
614
|
+
return { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Build the remote-actor resolver from an app's storage + identity + transports. */
|
|
618
|
+
export function createActorResolver(config) {
|
|
619
|
+
return new ActorResolver(config);
|
|
620
|
+
}
|