@rmdes/indiekit-endpoint-activitypub 3.8.3 → 3.8.5
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/index.js +88 -28
- package/lib/controllers/interactions-boost.js +5 -0
- package/lib/controllers/interactions-like.js +10 -0
- package/lib/direct-follow.js +163 -0
- package/lib/mastodon/helpers/interactions.js +18 -6
- package/lib/mastodon/routes/statuses.js +1 -0
- package/lib/resolve-author.js +46 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -5,6 +5,11 @@ import { createMastodonRouter } from "./lib/mastodon/router.js";
|
|
|
5
5
|
import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
|
|
6
6
|
import { initRedisCache } from "./lib/redis-cache.js";
|
|
7
7
|
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
|
|
8
|
+
import {
|
|
9
|
+
needsDirectFollow,
|
|
10
|
+
sendDirectFollow,
|
|
11
|
+
sendDirectUnfollow,
|
|
12
|
+
} from "./lib/direct-follow.js";
|
|
8
13
|
import {
|
|
9
14
|
createFedifyMiddleware,
|
|
10
15
|
} from "./lib/federation-bridge.js";
|
|
@@ -231,13 +236,6 @@ export default class ActivityPubEndpoint {
|
|
|
231
236
|
// authenticated `routes` getter, not the federation layer.
|
|
232
237
|
if (req.path.startsWith("/admin")) return next();
|
|
233
238
|
|
|
234
|
-
// Diagnostic: log inbox POSTs to detect federation stalls
|
|
235
|
-
if (req.method === "POST" && req.path.includes("inbox")) {
|
|
236
|
-
const ua = req.get("user-agent") || "unknown";
|
|
237
|
-
const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0;
|
|
238
|
-
console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
239
|
// Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD
|
|
242
240
|
// (it only returns true for explicit application/activity+json etc.).
|
|
243
241
|
// Remote servers fetching actor URLs for HTTP Signature verification
|
|
@@ -729,6 +727,32 @@ export default class ActivityPubEndpoint {
|
|
|
729
727
|
* @param {string} [actorInfo.photo] - Actor avatar URL
|
|
730
728
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
731
729
|
*/
|
|
730
|
+
/**
|
|
731
|
+
* Load the RSA private key from ap_keys for direct HTTP Signature signing.
|
|
732
|
+
* @returns {Promise<CryptoKey|null>}
|
|
733
|
+
*/
|
|
734
|
+
async _loadRsaPrivateKey() {
|
|
735
|
+
try {
|
|
736
|
+
const keyDoc = await this._collections.ap_keys.findOne({
|
|
737
|
+
privateKeyPem: { $exists: true },
|
|
738
|
+
});
|
|
739
|
+
if (!keyDoc?.privateKeyPem) return null;
|
|
740
|
+
const pemBody = keyDoc.privateKeyPem
|
|
741
|
+
.replace(/-----[^-]+-----/g, "")
|
|
742
|
+
.replace(/\s/g, "");
|
|
743
|
+
return await crypto.subtle.importKey(
|
|
744
|
+
"pkcs8",
|
|
745
|
+
Buffer.from(pemBody, "base64"),
|
|
746
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
747
|
+
true,
|
|
748
|
+
["sign"],
|
|
749
|
+
);
|
|
750
|
+
} catch (error) {
|
|
751
|
+
console.error("[ActivityPub] Failed to load RSA key:", error.message);
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
732
756
|
async followActor(actorUrl, actorInfo = {}) {
|
|
733
757
|
if (!this._federation) {
|
|
734
758
|
return { ok: false, error: "Federation not initialized" };
|
|
@@ -755,14 +779,33 @@ export default class ActivityPubEndpoint {
|
|
|
755
779
|
}
|
|
756
780
|
|
|
757
781
|
// Send Follow activity
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
782
|
+
if (needsDirectFollow(actorUrl)) {
|
|
783
|
+
// tags.pub rejects Fedify's LD Signature context (identity/v1).
|
|
784
|
+
// Send a minimal signed Follow directly, bypassing the outbox pipeline.
|
|
785
|
+
// See: https://github.com/social-web-foundation/tags.pub/issues/10
|
|
786
|
+
const rsaKey = await this._loadRsaPrivateKey();
|
|
787
|
+
if (!rsaKey) {
|
|
788
|
+
return { ok: false, error: "No RSA key available for direct follow" };
|
|
789
|
+
}
|
|
790
|
+
const result = await sendDirectFollow({
|
|
791
|
+
actorUri: ctx.getActorUri(handle).href,
|
|
792
|
+
targetActorUrl: actorUrl,
|
|
793
|
+
inboxUrl: remoteActor.inboxId?.href,
|
|
794
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
795
|
+
privateKey: rsaKey,
|
|
796
|
+
});
|
|
797
|
+
if (!result.ok) {
|
|
798
|
+
return { ok: false, error: result.error };
|
|
799
|
+
}
|
|
800
|
+
} else {
|
|
801
|
+
const follow = new Follow({
|
|
802
|
+
actor: ctx.getActorUri(handle),
|
|
803
|
+
object: new URL(actorUrl),
|
|
804
|
+
});
|
|
805
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
|
|
806
|
+
orderingKey: actorUrl,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
766
809
|
|
|
767
810
|
// Store in ap_following
|
|
768
811
|
const name =
|
|
@@ -866,19 +909,35 @@ export default class ActivityPubEndpoint {
|
|
|
866
909
|
return { ok: true };
|
|
867
910
|
}
|
|
868
911
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
912
|
+
if (needsDirectFollow(actorUrl)) {
|
|
913
|
+
// tags.pub rejects Fedify's LD Signature context (identity/v1).
|
|
914
|
+
// See: https://github.com/social-web-foundation/tags.pub/issues/10
|
|
915
|
+
const rsaKey = await this._loadRsaPrivateKey();
|
|
916
|
+
if (rsaKey) {
|
|
917
|
+
const result = await sendDirectUnfollow({
|
|
918
|
+
actorUri: ctx.getActorUri(handle).href,
|
|
919
|
+
targetActorUrl: actorUrl,
|
|
920
|
+
inboxUrl: remoteActor.inboxId?.href,
|
|
921
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
922
|
+
privateKey: rsaKey,
|
|
923
|
+
});
|
|
924
|
+
if (!result.ok) {
|
|
925
|
+
console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
} else {
|
|
929
|
+
const follow = new Follow({
|
|
930
|
+
actor: ctx.getActorUri(handle),
|
|
931
|
+
object: new URL(actorUrl),
|
|
932
|
+
});
|
|
933
|
+
const undo = new Undo({
|
|
934
|
+
actor: ctx.getActorUri(handle),
|
|
935
|
+
object: follow,
|
|
936
|
+
});
|
|
937
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
|
|
938
|
+
orderingKey: actorUrl,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
882
941
|
await this._collections.ap_following.deleteOne({ actorUrl });
|
|
883
942
|
|
|
884
943
|
console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
|
|
@@ -1530,6 +1589,7 @@ export default class ActivityPubEndpoint {
|
|
|
1530
1589
|
federation: this._federation,
|
|
1531
1590
|
followActor: (url, info) => pluginRef.followActor(url, info),
|
|
1532
1591
|
unfollowActor: (url) => pluginRef.unfollowActor(url),
|
|
1592
|
+
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
|
1533
1593
|
},
|
|
1534
1594
|
});
|
|
1535
1595
|
Indiekit.addEndpoint({
|
|
@@ -72,11 +72,16 @@ export function boostController(mountPath, plugin) {
|
|
|
72
72
|
identifier: handle,
|
|
73
73
|
});
|
|
74
74
|
const { application } = request.app.locals;
|
|
75
|
+
const rsaKey = await plugin._loadRsaPrivateKey();
|
|
75
76
|
const recipient = await resolveAuthor(
|
|
76
77
|
url,
|
|
77
78
|
ctx,
|
|
78
79
|
documentLoader,
|
|
79
80
|
application?.collections,
|
|
81
|
+
{
|
|
82
|
+
privateKey: rsaKey,
|
|
83
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
84
|
+
},
|
|
80
85
|
);
|
|
81
86
|
|
|
82
87
|
if (recipient) {
|
|
@@ -49,11 +49,16 @@ export function likeController(mountPath, plugin) {
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
const { application } = request.app.locals;
|
|
52
|
+
const rsaKey = await plugin._loadRsaPrivateKey();
|
|
52
53
|
const recipient = await resolveAuthor(
|
|
53
54
|
url,
|
|
54
55
|
ctx,
|
|
55
56
|
documentLoader,
|
|
56
57
|
application?.collections,
|
|
58
|
+
{
|
|
59
|
+
privateKey: rsaKey,
|
|
60
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
61
|
+
},
|
|
57
62
|
);
|
|
58
63
|
|
|
59
64
|
if (!recipient) {
|
|
@@ -170,11 +175,16 @@ export function unlikeController(mountPath, plugin) {
|
|
|
170
175
|
identifier: handle,
|
|
171
176
|
});
|
|
172
177
|
|
|
178
|
+
const rsaKey2 = await plugin._loadRsaPrivateKey();
|
|
173
179
|
const recipient = await resolveAuthor(
|
|
174
180
|
url,
|
|
175
181
|
ctx,
|
|
176
182
|
documentLoader,
|
|
177
183
|
application?.collections,
|
|
184
|
+
{
|
|
185
|
+
privateKey: rsaKey2,
|
|
186
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
187
|
+
},
|
|
178
188
|
);
|
|
179
189
|
|
|
180
190
|
if (!recipient) {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct Follow/Undo(Follow) for servers that reject Fedify's LD Signatures.
|
|
3
|
+
*
|
|
4
|
+
* tags.pub's activitypub-bot uses the `activitystrea.ms` AS2 parser, which
|
|
5
|
+
* rejects the `https://w3id.org/identity/v1` JSON-LD context that Fedify 2.0
|
|
6
|
+
* adds for RsaSignature2017. This module sends Follow/Undo(Follow) activities
|
|
7
|
+
* with a minimal body (no LD Sig, no Data Integrity Proof) signed with
|
|
8
|
+
* draft-cavage HTTP Signatures.
|
|
9
|
+
*
|
|
10
|
+
* Upstream issue: https://github.com/social-web-foundation/tags.pub/issues/10
|
|
11
|
+
*
|
|
12
|
+
* @module direct-follow
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
|
|
17
|
+
/** Hostnames that need direct follow (bypass Fedify outbox pipeline) */
|
|
18
|
+
const DIRECT_FOLLOW_HOSTS = new Set(["tags.pub"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if an actor URL requires direct follow delivery.
|
|
22
|
+
* @param {string} actorUrl
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
export function needsDirectFollow(actorUrl) {
|
|
26
|
+
try {
|
|
27
|
+
return DIRECT_FOLLOW_HOSTS.has(new URL(actorUrl).hostname);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Send a Follow activity directly with draft-cavage HTTP Signatures.
|
|
35
|
+
* @param {object} options
|
|
36
|
+
* @param {string} options.actorUri - Our actor URI
|
|
37
|
+
* @param {string} options.targetActorUrl - Remote actor URL to follow
|
|
38
|
+
* @param {string} options.inboxUrl - Remote actor's inbox URL
|
|
39
|
+
* @param {string} options.keyId - Our key ID (e.g. ...#main-key)
|
|
40
|
+
* @param {CryptoKey} options.privateKey - RSA private key for signing
|
|
41
|
+
* @returns {Promise<{ok: boolean, status?: number, error?: string}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function sendDirectFollow({
|
|
44
|
+
actorUri,
|
|
45
|
+
targetActorUrl,
|
|
46
|
+
inboxUrl,
|
|
47
|
+
keyId,
|
|
48
|
+
privateKey,
|
|
49
|
+
}) {
|
|
50
|
+
const body = JSON.stringify({
|
|
51
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
52
|
+
type: "Follow",
|
|
53
|
+
actor: actorUri,
|
|
54
|
+
object: targetActorUrl,
|
|
55
|
+
id: `${actorUri.replace(/\/$/, "")}/#Follow/${crypto.randomUUID()}`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return _signAndSend(inboxUrl, body, keyId, privateKey);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send an Undo(Follow) activity directly with draft-cavage HTTP Signatures.
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* @param {string} options.actorUri - Our actor URI
|
|
65
|
+
* @param {string} options.targetActorUrl - Remote actor URL to unfollow
|
|
66
|
+
* @param {string} options.inboxUrl - Remote actor's inbox URL
|
|
67
|
+
* @param {string} options.keyId - Our key ID (e.g. ...#main-key)
|
|
68
|
+
* @param {CryptoKey} options.privateKey - RSA private key for signing
|
|
69
|
+
* @returns {Promise<{ok: boolean, status?: number, error?: string}>}
|
|
70
|
+
*/
|
|
71
|
+
export async function sendDirectUnfollow({
|
|
72
|
+
actorUri,
|
|
73
|
+
targetActorUrl,
|
|
74
|
+
inboxUrl,
|
|
75
|
+
keyId,
|
|
76
|
+
privateKey,
|
|
77
|
+
}) {
|
|
78
|
+
const body = JSON.stringify({
|
|
79
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
80
|
+
type: "Undo",
|
|
81
|
+
actor: actorUri,
|
|
82
|
+
object: {
|
|
83
|
+
type: "Follow",
|
|
84
|
+
actor: actorUri,
|
|
85
|
+
object: targetActorUrl,
|
|
86
|
+
},
|
|
87
|
+
id: `${actorUri.replace(/\/$/, "")}/#Undo/${crypto.randomUUID()}`,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return _signAndSend(inboxUrl, body, keyId, privateKey);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sign a POST request with draft-cavage HTTP Signatures and send it.
|
|
95
|
+
* @private
|
|
96
|
+
*/
|
|
97
|
+
async function _signAndSend(inboxUrl, body, keyId, privateKey) {
|
|
98
|
+
const url = new URL(inboxUrl);
|
|
99
|
+
const date = new Date().toUTCString();
|
|
100
|
+
|
|
101
|
+
// Compute SHA-256 digest of the body
|
|
102
|
+
const digestRaw = await crypto.subtle.digest(
|
|
103
|
+
"SHA-256",
|
|
104
|
+
new TextEncoder().encode(body),
|
|
105
|
+
);
|
|
106
|
+
const digest = "SHA-256=" + Buffer.from(digestRaw).toString("base64");
|
|
107
|
+
|
|
108
|
+
// Build draft-cavage signing string
|
|
109
|
+
const signingString = [
|
|
110
|
+
`(request-target): post ${url.pathname}`,
|
|
111
|
+
`host: ${url.host}`,
|
|
112
|
+
`date: ${date}`,
|
|
113
|
+
`digest: ${digest}`,
|
|
114
|
+
].join("\n");
|
|
115
|
+
|
|
116
|
+
// Sign with RSA-SHA256
|
|
117
|
+
const signature = await crypto.subtle.sign(
|
|
118
|
+
"RSASSA-PKCS1-v1_5",
|
|
119
|
+
privateKey,
|
|
120
|
+
new TextEncoder().encode(signingString),
|
|
121
|
+
);
|
|
122
|
+
const signatureB64 = Buffer.from(signature).toString("base64");
|
|
123
|
+
|
|
124
|
+
const signatureHeader = [
|
|
125
|
+
`keyId="${keyId}"`,
|
|
126
|
+
`algorithm="rsa-sha256"`,
|
|
127
|
+
`headers="(request-target) host date digest"`,
|
|
128
|
+
`signature="${signatureB64}"`,
|
|
129
|
+
].join(",");
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(inboxUrl, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
"Content-Type": "application/activity+json",
|
|
136
|
+
Date: date,
|
|
137
|
+
Digest: digest,
|
|
138
|
+
Host: url.host,
|
|
139
|
+
Signature: signatureHeader,
|
|
140
|
+
},
|
|
141
|
+
body,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (response.ok) {
|
|
145
|
+
return { ok: true, status: response.status };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const errorBody = await response.text().catch(() => "");
|
|
149
|
+
let detail = errorBody;
|
|
150
|
+
try {
|
|
151
|
+
detail = JSON.parse(errorBody).detail || errorBody;
|
|
152
|
+
} catch {
|
|
153
|
+
// not JSON
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
status: response.status,
|
|
158
|
+
error: `${response.status} ${response.statusText}: ${detail}`,
|
|
159
|
+
};
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return { ok: false, error: error.message };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -22,7 +22,7 @@ import { resolveAuthor } from "../../resolve-author.js";
|
|
|
22
22
|
* @param {object} params.interactions - ap_interactions collection
|
|
23
23
|
* @returns {Promise<{ activityId: string }>}
|
|
24
24
|
*/
|
|
25
|
-
export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
25
|
+
export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
|
|
26
26
|
const { Like } = await import("@fedify/fedify/vocab");
|
|
27
27
|
const ctx = federation.createContext(
|
|
28
28
|
new URL(publicationUrl),
|
|
@@ -30,7 +30,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
|
|
30
30
|
);
|
|
31
31
|
|
|
32
32
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
33
|
-
const
|
|
33
|
+
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
|
|
34
|
+
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
|
|
35
|
+
privateKey: rsaKey,
|
|
36
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
37
|
+
});
|
|
34
38
|
|
|
35
39
|
const uuid = crypto.randomUUID();
|
|
36
40
|
const baseUrl = publicationUrl.replace(/\/$/, "");
|
|
@@ -79,7 +83,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
|
|
79
83
|
* @param {object} params.interactions - ap_interactions collection
|
|
80
84
|
* @returns {Promise<void>}
|
|
81
85
|
*/
|
|
82
|
-
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
86
|
+
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
|
|
83
87
|
const existing = interactions
|
|
84
88
|
? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
|
|
85
89
|
: null;
|
|
@@ -95,7 +99,11 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
|
|
|
95
99
|
);
|
|
96
100
|
|
|
97
101
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
98
|
-
const
|
|
102
|
+
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
|
|
103
|
+
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
|
|
104
|
+
privateKey: rsaKey,
|
|
105
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
106
|
+
});
|
|
99
107
|
|
|
100
108
|
if (recipient) {
|
|
101
109
|
const like = new Like({
|
|
@@ -131,7 +139,7 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
|
|
|
131
139
|
* @param {object} params.interactions - ap_interactions collection
|
|
132
140
|
* @returns {Promise<{ activityId: string }>}
|
|
133
141
|
*/
|
|
134
|
-
export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
|
142
|
+
export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
|
|
135
143
|
const { Announce } = await import("@fedify/fedify/vocab");
|
|
136
144
|
const ctx = federation.createContext(
|
|
137
145
|
new URL(publicationUrl),
|
|
@@ -162,7 +170,11 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
|
|
|
162
170
|
|
|
163
171
|
// Also send directly to the original post author
|
|
164
172
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
|
165
|
-
const
|
|
173
|
+
const rsaKey = loadRsaKey ? await loadRsaKey() : null;
|
|
174
|
+
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
|
|
175
|
+
privateKey: rsaKey,
|
|
176
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
177
|
+
});
|
|
166
178
|
if (recipient) {
|
|
167
179
|
try {
|
|
168
180
|
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
|
package/lib/resolve-author.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Strategies (tried in order):
|
|
8
8
|
* 1. lookupObject on post URL → getAttributedTo
|
|
9
|
+
* 1b. Raw signed fetch fallback (for servers like wafrn that return
|
|
10
|
+
* non-standard JSON-LD that Fedify can't parse)
|
|
9
11
|
* 2. Timeline/notification DB lookup → lookupObject on stored author URL
|
|
10
12
|
* 3. Extract author URL from post URL pattern → lookupObject
|
|
11
13
|
*/
|
|
@@ -60,6 +62,9 @@ export function extractAuthorUrl(postUrl) {
|
|
|
60
62
|
* @param {object} ctx - Fedify context
|
|
61
63
|
* @param {object} documentLoader - Authenticated document loader
|
|
62
64
|
* @param {object} [collections] - Optional MongoDB collections map (application.collections)
|
|
65
|
+
* @param {object} [options] - Additional options
|
|
66
|
+
* @param {CryptoKey} [options.privateKey] - RSA private key for raw signed fetch fallback
|
|
67
|
+
* @param {string} [options.keyId] - Key ID for HTTP Signature (e.g. "...#main-key")
|
|
63
68
|
* @returns {Promise<object|null>} - Fedify Actor object or null
|
|
64
69
|
*/
|
|
65
70
|
export async function resolveAuthor(
|
|
@@ -67,6 +72,7 @@ export async function resolveAuthor(
|
|
|
67
72
|
ctx,
|
|
68
73
|
documentLoader,
|
|
69
74
|
collections,
|
|
75
|
+
options = {},
|
|
70
76
|
) {
|
|
71
77
|
// Strategy 1: Look up remote post via Fedify (signed request)
|
|
72
78
|
try {
|
|
@@ -90,6 +96,46 @@ export async function resolveAuthor(
|
|
|
90
96
|
);
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
// Strategy 1b: Raw signed fetch fallback
|
|
100
|
+
// Some servers (e.g. wafrn) return AP JSON without @context, which Fedify's
|
|
101
|
+
// JSON-LD processor rejects. A raw fetch can still extract attributedTo/actor.
|
|
102
|
+
if (options.privateKey && options.keyId) {
|
|
103
|
+
try {
|
|
104
|
+
const { signRequest } = await import("@fedify/fedify/sig");
|
|
105
|
+
const request = new Request(postUrl, {
|
|
106
|
+
method: "GET",
|
|
107
|
+
headers: { Accept: "application/activity+json" },
|
|
108
|
+
});
|
|
109
|
+
const signed = await signRequest(request, options.privateKey, new URL(options.keyId), {
|
|
110
|
+
spec: "draft-cavage-http-signatures-12",
|
|
111
|
+
});
|
|
112
|
+
const res = await fetch(signed, { redirect: "follow" });
|
|
113
|
+
if (res.ok) {
|
|
114
|
+
const contentType = res.headers.get("content-type") || "";
|
|
115
|
+
if (contentType.includes("json")) {
|
|
116
|
+
const json = await res.json();
|
|
117
|
+
const authorUrl = json.attributedTo || json.actor;
|
|
118
|
+
if (authorUrl && typeof authorUrl === "string") {
|
|
119
|
+
const actor = await lookupWithSecurity(ctx, new URL(authorUrl), {
|
|
120
|
+
documentLoader,
|
|
121
|
+
});
|
|
122
|
+
if (actor) {
|
|
123
|
+
console.info(
|
|
124
|
+
`[ActivityPub] Resolved author via raw fetch for ${postUrl} → ${authorUrl}`,
|
|
125
|
+
);
|
|
126
|
+
return actor;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn(
|
|
133
|
+
`[ActivityPub] Raw fetch fallback failed for ${postUrl}:`,
|
|
134
|
+
error.message,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
93
139
|
// Strategy 2: Use author URL from timeline or notifications
|
|
94
140
|
if (collections) {
|
|
95
141
|
const ap_timeline = collections.get("ap_timeline");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.5",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|