@rmdes/indiekit-endpoint-activitypub 2.15.0 → 2.15.2
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 +62 -2
- package/assets/reader.css +6 -0
- package/lib/controllers/post-detail.js +71 -35
- package/lib/federation-bridge.js +6 -0
- package/lib/storage/timeline.js +22 -2
- package/package.json +1 -1
- package/views/partials/ap-item-card.njk +3 -0
package/README.md
CHANGED
|
@@ -12,6 +12,28 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
12
12
|
- Reply delivery — replies are addressed to and delivered directly to the original post's author
|
|
13
13
|
- Shared inbox support with collection sync (FEP-8fcf)
|
|
14
14
|
- Configurable actor type (Person, Service, Organization, Group)
|
|
15
|
+
- Manual follow approval — review and accept/reject follow requests before they take effect
|
|
16
|
+
- Direct messages — private conversations stored separately from the public timeline
|
|
17
|
+
|
|
18
|
+
**Federation Resilience** *(v2.14.0+)*
|
|
19
|
+
- Async inbox queue — inbound activities are persisted to MongoDB before processing, ensuring no data loss on crashes
|
|
20
|
+
- Server blocking — block entire remote servers by domain, rejecting all inbound activities from blocked instances
|
|
21
|
+
- Key freshness tracking — tracks when remote actor keys were last verified, skipping redundant re-fetches
|
|
22
|
+
- Redis-cached actor lookups — caches actor resolution results to reduce network round-trips
|
|
23
|
+
- Delivery strike tracking on `ap_followers` — counts consecutive delivery failures per follower
|
|
24
|
+
- FEP-fe34 security — verifies `proof.created` timestamps to reject replayed activities
|
|
25
|
+
|
|
26
|
+
**Outbox Failure Handling** *(v2.15.0+, inspired by [Hollo](https://github.com/fedify-dev/hollo))*
|
|
27
|
+
- **410 Gone** — immediate full cleanup: removes the follower, their timeline items, and their notifications
|
|
28
|
+
- **404 Not Found** — strike system: 3 consecutive failures over 7+ days triggers the same full cleanup
|
|
29
|
+
- Strike auto-reset — when an actor sends us any activity, their delivery failure count resets to zero
|
|
30
|
+
- Prevents orphaned data from accumulating over time while tolerating temporary server outages
|
|
31
|
+
|
|
32
|
+
**Reply Intelligence** *(v2.15.0+, inspired by [Hollo](https://github.com/fedify-dev/hollo))*
|
|
33
|
+
- Recursive reply chain fetching — when a reply arrives, fetches parent posts up to 5 levels deep for thread context
|
|
34
|
+
- Ancestor posts stored with `isContext: true` flag for thread view without cluttering the main timeline
|
|
35
|
+
- Reply forwarding to followers — when someone replies to our posts, the reply is forwarded to our followers so they see the full conversation
|
|
36
|
+
- Write-time visibility classification — computes `public`/`unlisted`/`private`/`direct` from `to`/`cc` fields at ingest time
|
|
15
37
|
|
|
16
38
|
**Reader**
|
|
17
39
|
- Timeline view showing posts from followed accounts with tab filtering (notes, articles, replies, boosts, media)
|
|
@@ -36,6 +58,8 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
36
58
|
**Moderation**
|
|
37
59
|
- Mute actors or keywords
|
|
38
60
|
- Block actors (also removes from followers)
|
|
61
|
+
- Block entire servers by domain
|
|
62
|
+
- Report remote actors to their home instance (Flag activity)
|
|
39
63
|
- All moderation actions available from the reader UI
|
|
40
64
|
|
|
41
65
|
**Mastodon Migration**
|
|
@@ -186,6 +210,7 @@ When remote servers send activities to your inbox:
|
|
|
186
210
|
- **Accept(Follow)** → Marks our follow as accepted
|
|
187
211
|
- **Reject(Follow)** → Marks our follow as rejected
|
|
188
212
|
- **Block** → Removes actor from our followers
|
|
213
|
+
- **Flag** → Outbound report sent to remote actor's instance
|
|
189
214
|
|
|
190
215
|
### Content Negotiation
|
|
191
216
|
|
|
@@ -254,7 +279,7 @@ The plugin creates these collections automatically:
|
|
|
254
279
|
|
|
255
280
|
| Collection | Description |
|
|
256
281
|
|---|---|
|
|
257
|
-
| `ap_followers` | Accounts following your actor |
|
|
282
|
+
| `ap_followers` | Accounts following your actor (includes delivery failure strike tracking) |
|
|
258
283
|
| `ap_following` | Accounts you follow |
|
|
259
284
|
| `ap_activities` | Activity log with automatic TTL cleanup |
|
|
260
285
|
| `ap_keys` | RSA and Ed25519 key pairs for HTTP Signatures |
|
|
@@ -262,11 +287,19 @@ The plugin creates these collections automatically:
|
|
|
262
287
|
| `ap_profile` | Actor profile (single document) |
|
|
263
288
|
| `ap_featured` | Pinned/featured posts |
|
|
264
289
|
| `ap_featured_tags` | Featured hashtags |
|
|
265
|
-
| `ap_timeline` | Reader timeline items
|
|
290
|
+
| `ap_timeline` | Reader timeline items (includes `visibility` and `isContext` fields) |
|
|
266
291
|
| `ap_notifications` | Interaction notifications |
|
|
267
292
|
| `ap_muted` | Muted actors and keywords |
|
|
268
293
|
| `ap_blocked` | Blocked actors |
|
|
269
294
|
| `ap_interactions` | Per-post like/boost tracking |
|
|
295
|
+
| `ap_messages` | Direct messages / private conversations |
|
|
296
|
+
| `ap_followed_tags` | Hashtags you follow for timeline filtering |
|
|
297
|
+
| `ap_explore_tabs` | Saved Mastodon instances for the explore view |
|
|
298
|
+
| `ap_reports` | Outbound reports (Flag activities) sent to remote instances |
|
|
299
|
+
| `ap_pending_follows` | Follow requests awaiting manual approval |
|
|
300
|
+
| `ap_blocked_servers` | Blocked server domains (instance-level blocks) |
|
|
301
|
+
| `ap_key_freshness` | Tracks when remote actor keys were last verified |
|
|
302
|
+
| `ap_inbox_queue` | Persistent async inbox processing queue |
|
|
270
303
|
|
|
271
304
|
## Supported Post Types
|
|
272
305
|
|
|
@@ -306,6 +339,23 @@ Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently
|
|
|
306
339
|
|
|
307
340
|
**Revisit when:** Fedify adds an option to preserve arrays during JSON-LD serialization, or Mastodon fixes their array check.
|
|
308
341
|
|
|
342
|
+
### Endpoints `as:Endpoints` Type Stripping
|
|
343
|
+
|
|
344
|
+
**File:** `lib/federation-bridge.js` (in `sendFedifyResponse()`)
|
|
345
|
+
**Upstream issue:** [fedify#576](https://github.com/fedify-dev/fedify/issues/576) — FIXED in Fedify 2.1.0
|
|
346
|
+
|
|
347
|
+
Fedify serializes the `endpoints` object with `"type": "as:Endpoints"`, which is not a valid ActivityStreams type. browser.pub rejects this. The bridge strips the `type` field from the `endpoints` object before sending.
|
|
348
|
+
|
|
349
|
+
**Remove when:** Upgrading to Fedify ≥ 2.1.0.
|
|
350
|
+
|
|
351
|
+
### PropertyValue Attachment Type (Known Issue)
|
|
352
|
+
|
|
353
|
+
**Upstream issue:** [fedify#629](https://github.com/fedify-dev/fedify/issues/629) — OPEN
|
|
354
|
+
|
|
355
|
+
Fedify serializes `PropertyValue` attachments (used by Mastodon for profile metadata fields) with `"type": "PropertyValue"`, a schema.org type that is not a valid AS2 Object or Link. browser.pub rejects `/attachment` as invalid. However, every Mastodon-compatible server emits `PropertyValue` — removing it would break profile field display across the fediverse.
|
|
356
|
+
|
|
357
|
+
**No workaround applied.** This is a de facto fediverse standard despite not being in the AS2 vocabulary.
|
|
358
|
+
|
|
309
359
|
### `.authorize()` Not Chained on Actor Dispatcher
|
|
310
360
|
|
|
311
361
|
**File:** `lib/federation-setup.js` (line ~254)
|
|
@@ -346,6 +396,16 @@ This is not a bug — Fedify requires explicit opt-in for signed fetches. But it
|
|
|
346
396
|
- **No custom emoji rendering** — Custom emoji shortcodes display as text
|
|
347
397
|
- **In-process queue without Redis** — Activities may be lost on restart
|
|
348
398
|
|
|
399
|
+
## Acknowledgements
|
|
400
|
+
|
|
401
|
+
This plugin builds on the excellent [Fedify](https://fedify.dev) framework by [Hong Minhee](https://github.com/dahlia). Fedify provides the core ActivityPub federation layer — HTTP Signatures, content negotiation, message queues, and the vocabulary types that make all of this possible.
|
|
402
|
+
|
|
403
|
+
Several federation patterns in this plugin were inspired by studying other open-source ActivityPub implementations:
|
|
404
|
+
|
|
405
|
+
- **[Hollo](https://github.com/fedify-dev/hollo)** (by the Fedify author) — A single-user Fedify-based ActivityPub server that served as the primary reference implementation. The outbox permanent failure handling (410 cleanup and 404 strike system), recursive reply chain fetching, reply forwarding to followers, and write-time visibility classification in v2.15.0 are all adapted from Hollo's patterns for a MongoDB/single-user context.
|
|
406
|
+
|
|
407
|
+
- **[Wafrn](https://github.com/gabboman/wafrn)** — A federated social network whose ActivityPub implementation informed the operational resilience patterns added in v2.14.0. Server blocking, key freshness tracking, async inbox processing with persistent queues, and the general approach to federation hardening were inspired by studying Wafrn's production codebase.
|
|
408
|
+
|
|
349
409
|
## License
|
|
350
410
|
|
|
351
411
|
MIT
|
package/assets/reader.css
CHANGED
|
@@ -62,51 +62,87 @@ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxD
|
|
|
62
62
|
return parents;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// Load replies
|
|
66
|
-
async function
|
|
67
|
-
|
|
65
|
+
// Load local replies from ap_timeline (items where inReplyTo matches this post)
|
|
66
|
+
async function loadLocalReplies(timelineCol, postUrl, postUid, maxReplies = 20) {
|
|
67
|
+
if (!timelineCol) return [];
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!repliesCollection) return replies;
|
|
69
|
+
const matchUrls = [postUrl, postUid].filter(Boolean);
|
|
70
|
+
if (matchUrls.length === 0) return [];
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
72
|
+
const localReplies = await timelineCol
|
|
73
|
+
.find({ inReplyTo: { $in: matchUrls } })
|
|
74
|
+
.sort({ published: 1 })
|
|
75
|
+
.limit(maxReplies)
|
|
76
|
+
.toArray();
|
|
79
77
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
78
|
+
return localReplies;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Load replies collection (best-effort) — merges local + remote
|
|
82
|
+
async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 20) {
|
|
83
|
+
const postUrl = object?.id?.href || object?.url?.href;
|
|
84
|
+
|
|
85
|
+
// Start with local replies already in our timeline (from organic inbox delivery
|
|
86
|
+
// or reply chain fetching). These are fast and free — no network requests.
|
|
87
|
+
const seenUrls = new Set();
|
|
88
|
+
const replies = await loadLocalReplies(timelineCol, postUrl, postUrl, maxReplies);
|
|
89
|
+
for (const r of replies) {
|
|
90
|
+
if (r.uid) seenUrls.add(r.uid);
|
|
91
|
+
if (r.url) seenUrls.add(r.url);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Supplement with remote replies collection (may contain items we don't have locally)
|
|
95
|
+
if (object && replies.length < maxReplies) {
|
|
96
|
+
try {
|
|
97
|
+
const repliesCollection = await object.getReplies({ documentLoader });
|
|
98
|
+
if (repliesCollection) {
|
|
99
|
+
let items = [];
|
|
100
|
+
try {
|
|
101
|
+
items = await repliesCollection.getItems({ documentLoader });
|
|
102
|
+
} catch {
|
|
103
|
+
// Remote fetch failed — continue with local replies only
|
|
97
104
|
}
|
|
98
105
|
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
for (const replyItem of items.slice(0, maxReplies - replies.length)) {
|
|
107
|
+
try {
|
|
108
|
+
const replyUrl = replyItem.id?.href || replyItem.url?.href;
|
|
109
|
+
if (!replyUrl || seenUrls.has(replyUrl)) continue;
|
|
110
|
+
seenUrls.add(replyUrl);
|
|
111
|
+
|
|
112
|
+
// Check timeline first
|
|
113
|
+
let reply = timelineCol
|
|
114
|
+
? await timelineCol.findOne({
|
|
115
|
+
$or: [{ uid: replyUrl }, { url: replyUrl }],
|
|
116
|
+
})
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
if (!reply) {
|
|
120
|
+
// Extract from the item we already have
|
|
121
|
+
if (replyItem instanceof Note || replyItem instanceof Article) {
|
|
122
|
+
reply = await extractObjectData(replyItem);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (reply) {
|
|
127
|
+
replies.push(reply);
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
continue; // Skip failed replies
|
|
131
|
+
}
|
|
101
132
|
}
|
|
102
|
-
} catch {
|
|
103
|
-
continue; // Skip failed replies
|
|
104
133
|
}
|
|
134
|
+
} catch {
|
|
135
|
+
// getReplies() failed or not available
|
|
105
136
|
}
|
|
106
|
-
} catch {
|
|
107
|
-
// getReplies() failed or not available
|
|
108
137
|
}
|
|
109
138
|
|
|
139
|
+
// Sort all replies chronologically
|
|
140
|
+
replies.sort((a, b) => {
|
|
141
|
+
const dateA = a.published || "";
|
|
142
|
+
const dateB = b.published || "";
|
|
143
|
+
return dateA < dateB ? -1 : dateA > dateB ? 1 : 0;
|
|
144
|
+
});
|
|
145
|
+
|
|
110
146
|
return replies;
|
|
111
147
|
}
|
|
112
148
|
|
package/lib/federation-bridge.js
CHANGED
|
@@ -89,6 +89,12 @@ async function sendFedifyResponse(res, response, request) {
|
|
|
89
89
|
if (json.attachment && !Array.isArray(json.attachment)) {
|
|
90
90
|
json.attachment = [json.attachment];
|
|
91
91
|
}
|
|
92
|
+
// WORKAROUND: Fedify serializes endpoints with "type": "as:Endpoints"
|
|
93
|
+
// which is not a valid AS2 type. The endpoints object should be a plain
|
|
94
|
+
// object with just sharedInbox/proxyUrl etc. Strip the invalid type.
|
|
95
|
+
if (json.endpoints && json.endpoints.type) {
|
|
96
|
+
delete json.endpoints.type;
|
|
97
|
+
}
|
|
92
98
|
const patched = JSON.stringify(json);
|
|
93
99
|
res.setHeader("content-length", Buffer.byteLength(patched));
|
|
94
100
|
res.end(patched);
|
package/lib/storage/timeline.js
CHANGED
|
@@ -73,6 +73,18 @@ export async function getTimelineItems(collections, options = {}) {
|
|
|
73
73
|
|
|
74
74
|
const query = {};
|
|
75
75
|
|
|
76
|
+
// Exclude context-only items (ancestors fetched for thread reconstruction)
|
|
77
|
+
// unless explicitly requested via options.includeContext
|
|
78
|
+
if (!options.includeContext) {
|
|
79
|
+
query.isContext = { $ne: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Exclude private/direct posts from the main timeline feed —
|
|
83
|
+
// these belong in messages/notifications, not the public reader
|
|
84
|
+
if (!options.includePrivate) {
|
|
85
|
+
query.visibility = { $nin: ["private", "direct"] };
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
// Type filter
|
|
77
89
|
if (options.type) {
|
|
78
90
|
query.type = options.type;
|
|
@@ -252,7 +264,11 @@ export async function countNewItems(collections, after, options = {}) {
|
|
|
252
264
|
const { ap_timeline } = collections;
|
|
253
265
|
if (!after || Number.isNaN(new Date(after).getTime())) return 0;
|
|
254
266
|
|
|
255
|
-
const query = {
|
|
267
|
+
const query = {
|
|
268
|
+
published: { $gt: after },
|
|
269
|
+
isContext: { $ne: true },
|
|
270
|
+
visibility: { $nin: ["private", "direct"] },
|
|
271
|
+
};
|
|
256
272
|
if (options.type) query.type = options.type;
|
|
257
273
|
if (options.excludeReplies) {
|
|
258
274
|
query.$or = [
|
|
@@ -289,5 +305,9 @@ export async function markItemsRead(collections, uids) {
|
|
|
289
305
|
*/
|
|
290
306
|
export async function countUnreadItems(collections) {
|
|
291
307
|
const { ap_timeline } = collections;
|
|
292
|
-
return await ap_timeline.countDocuments({
|
|
308
|
+
return await ap_timeline.countDocuments({
|
|
309
|
+
read: { $ne: true },
|
|
310
|
+
isContext: { $ne: true },
|
|
311
|
+
visibility: { $nin: ["private", "direct"] },
|
|
312
|
+
});
|
|
293
313
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.2",
|
|
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",
|
|
@@ -65,6 +65,9 @@
|
|
|
65
65
|
</time>
|
|
66
66
|
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
|
|
67
67
|
</a>
|
|
68
|
+
{% if item.visibility and item.visibility != "public" %}
|
|
69
|
+
<span class="ap-card__visibility ap-card__visibility--{{ item.visibility }}" title="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span>
|
|
70
|
+
{% endif %}
|
|
68
71
|
{% endif %}
|
|
69
72
|
</header>
|
|
70
73
|
|