@rmdes/indiekit-endpoint-activitypub 3.8.3 → 3.8.4
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 +87 -28
- package/lib/direct-follow.js +163 -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
|
+
false,
|
|
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}`);
|
|
@@ -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
|
+
}
|
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.4",
|
|
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",
|