@rmdes/indiekit-endpoint-activitypub 3.9.3 → 3.10.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 +22 -10
- package/index.js +32 -0
- package/lib/controllers/authorize-interaction.js +23 -9
- package/lib/federation-bridge.js +0 -6
- package/lib/federation-setup.js +94 -1
- package/lib/init-indexes.js +6 -0
- package/lib/mastodon/routes/statuses.js +59 -124
- package/lib/storage/tombstones.js +52 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @rmdes/indiekit-endpoint-activitypub
|
|
2
2
|
|
|
3
|
-
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.
|
|
3
|
+
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.1. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -88,13 +88,17 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
88
88
|
- URL auto-linkification and @mention extraction in posted content
|
|
89
89
|
- Thread context (ancestors + descendants)
|
|
90
90
|
- Remote profile resolution via Fedify WebFinger with follower/following/post counts from AP collections
|
|
91
|
-
- Account stats enrichment —
|
|
91
|
+
- Account stats enrichment — cached account data applied immediately; uncached accounts resolved in background
|
|
92
92
|
- Favourite, boost, bookmark interactions federated via Fedify AP activities
|
|
93
93
|
- Notifications with type filtering
|
|
94
94
|
- Search across accounts, statuses, and hashtags with remote resolution
|
|
95
95
|
- Domain blocks API
|
|
96
96
|
- Timeline backfill from posts collection on startup (bookmarks, likes, reposts get synthesized content)
|
|
97
97
|
- In-memory account stats cache (500 entries, 1h TTL) for performance
|
|
98
|
+
- OAuth2 scope enforcement — read/write scope validation on all API routes
|
|
99
|
+
- Rate limiting — configurable limits on API, auth, and app registration endpoints
|
|
100
|
+
- Access token expiry (1 hour) with refresh token rotation (90 days)
|
|
101
|
+
- PKCE (S256) and CSRF protection on authorization flow
|
|
98
102
|
|
|
99
103
|
**Admin UI**
|
|
100
104
|
- Dashboard with follower/following counts and recent activity
|
|
@@ -105,10 +109,18 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
105
109
|
- Follower and following lists with source tracking
|
|
106
110
|
- Federation management page with moderation overview (blocked servers, blocked accounts, muted)
|
|
107
111
|
|
|
112
|
+
**Standards Compliance**
|
|
113
|
+
- FEP-5feb: Search Indexing Consent — actor advertises `indexable` and `discoverable` properties
|
|
114
|
+
- FEP-f1d5/0151: Enhanced NodeInfo 2.1 — rich metadata including software repository, node name, staff accounts
|
|
115
|
+
- FEP-4f05: Soft Delete with Tombstone — deleted posts return 410 with Tombstone JSON-LD including `formerType` and timestamps
|
|
116
|
+
- FEP-3b86: Activity Intents — WebFinger links for Follow, Create, Like, Announce intents with authorize_interaction routing
|
|
117
|
+
- FEP-8fcf: Collection Synchronization — outbound follower digest headers via Fedify `syncCollection`
|
|
118
|
+
- FEP-044f: Quote Posts — rendered as embedded cards (via Fedify's `quoteUrl` support)
|
|
119
|
+
|
|
108
120
|
## Requirements
|
|
109
121
|
|
|
110
122
|
- [Indiekit](https://getindiekit.com) v1.0.0-beta.25+
|
|
111
|
-
- [Fedify](https://fedify.dev) 2.
|
|
123
|
+
- [Fedify](https://fedify.dev) 2.1+ (bundled as dependency)
|
|
112
124
|
- Node.js >= 22
|
|
113
125
|
- MongoDB (used by Indiekit)
|
|
114
126
|
- Redis (recommended for production delivery queue; in-process queue available for development)
|
|
@@ -318,6 +330,10 @@ The plugin creates these collections automatically:
|
|
|
318
330
|
| `ap_blocked_servers` | Blocked server domains (instance-level blocks) |
|
|
319
331
|
| `ap_key_freshness` | Tracks when remote actor keys were last verified |
|
|
320
332
|
| `ap_inbox_queue` | Persistent async inbox processing queue |
|
|
333
|
+
| `ap_tombstones` | Tombstone records for soft-deleted posts (FEP-4f05) |
|
|
334
|
+
| `ap_oauth_apps` | Mastodon API client app registrations |
|
|
335
|
+
| `ap_oauth_tokens` | OAuth2 authorization codes and access tokens |
|
|
336
|
+
| `ap_markers` | Read position markers for Mastodon API clients |
|
|
321
337
|
|
|
322
338
|
## Supported Post Types
|
|
323
339
|
|
|
@@ -335,7 +351,7 @@ Categories are converted to `Hashtag` tags. Bookmarks include a bookmark emoji a
|
|
|
335
351
|
|
|
336
352
|
## Fedify Workarounds and Implementation Notes
|
|
337
353
|
|
|
338
|
-
This plugin uses [Fedify](https://fedify.dev) 2.
|
|
354
|
+
This plugin uses [Fedify](https://fedify.dev) 2.1 but carries several workarounds for issues in Fedify or its Express integration. These are documented here so they can be revisited when Fedify upgrades.
|
|
339
355
|
|
|
340
356
|
### Custom Express Bridge (instead of `@fedify/express`)
|
|
341
357
|
|
|
@@ -357,14 +373,11 @@ Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently
|
|
|
357
373
|
|
|
358
374
|
**Revisit when:** Fedify adds an option to preserve arrays during JSON-LD serialization, or Mastodon fixes their array check.
|
|
359
375
|
|
|
360
|
-
### Endpoints `as:Endpoints` Type Stripping
|
|
376
|
+
### Endpoints `as:Endpoints` Type Stripping — REMOVED
|
|
361
377
|
|
|
362
|
-
**File:** `lib/federation-bridge.js` (in `sendFedifyResponse()`)
|
|
363
378
|
**Upstream issue:** [fedify#576](https://github.com/fedify-dev/fedify/issues/576) — FIXED in Fedify 2.1.0
|
|
364
379
|
|
|
365
|
-
Fedify
|
|
366
|
-
|
|
367
|
-
**Remove when:** Upgrading to Fedify ≥ 2.1.0.
|
|
380
|
+
This workaround has been removed. Fedify 2.1.0 now omits the invalid `"type": "as:Endpoints"` from serialized actor JSON.
|
|
368
381
|
|
|
369
382
|
### PropertyValue Attachment Type (Known Issue)
|
|
370
383
|
|
|
@@ -411,7 +424,6 @@ This is not a bug — Fedify requires explicit opt-in for signed fetches. But it
|
|
|
411
424
|
- **Single actor** — One fediverse identity per Indiekit instance
|
|
412
425
|
- **No Authorized Fetch enforcement** — `.authorize()` disabled on actor dispatcher (see workarounds above)
|
|
413
426
|
- **No image upload in reader** — Compose form is text-only
|
|
414
|
-
- **No custom emoji rendering** — Custom emoji shortcodes display as text
|
|
415
427
|
- **In-process queue without Redis** — Activities may be lost on restart
|
|
416
428
|
|
|
417
429
|
## Acknowledgements
|
package/index.js
CHANGED
|
@@ -435,6 +435,20 @@ export default class ActivityPubEndpoint {
|
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
if (!post || post.properties?.deleted) {
|
|
438
|
+
// FEP-4f05: Serve Tombstone for deleted posts
|
|
439
|
+
const { getTombstone } = await import("./lib/storage/tombstones.js");
|
|
440
|
+
const tombstone = await getTombstone(self._collections, requestUrl);
|
|
441
|
+
if (tombstone) {
|
|
442
|
+
res.status(410).set("Content-Type", "application/activity+json").json({
|
|
443
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
444
|
+
type: "Tombstone",
|
|
445
|
+
id: requestUrl,
|
|
446
|
+
formerType: tombstone.formerType,
|
|
447
|
+
published: tombstone.published || undefined,
|
|
448
|
+
deleted: tombstone.deleted,
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
438
452
|
return next();
|
|
439
453
|
}
|
|
440
454
|
|
|
@@ -811,6 +825,21 @@ export default class ActivityPubEndpoint {
|
|
|
811
825
|
* @param {string} url - Full URL of the deleted post
|
|
812
826
|
*/
|
|
813
827
|
async delete(url) {
|
|
828
|
+
// Record tombstone for FEP-4f05
|
|
829
|
+
try {
|
|
830
|
+
const { addTombstone } = await import("./lib/storage/tombstones.js");
|
|
831
|
+
const postsCol = this._collections.posts;
|
|
832
|
+
const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
|
|
833
|
+
await addTombstone(this._collections, {
|
|
834
|
+
url,
|
|
835
|
+
formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
|
|
836
|
+
published: post?.properties?.published || null,
|
|
837
|
+
deleted: new Date().toISOString(),
|
|
838
|
+
});
|
|
839
|
+
} catch (error) {
|
|
840
|
+
console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
814
843
|
await this.broadcastDelete(url).catch((err) =>
|
|
815
844
|
console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
|
|
816
845
|
);
|
|
@@ -927,6 +956,8 @@ export default class ActivityPubEndpoint {
|
|
|
927
956
|
Indiekit.addCollection("ap_oauth_apps");
|
|
928
957
|
Indiekit.addCollection("ap_oauth_tokens");
|
|
929
958
|
Indiekit.addCollection("ap_markers");
|
|
959
|
+
// Tombstones for soft-deleted posts (FEP-4f05)
|
|
960
|
+
Indiekit.addCollection("ap_tombstones");
|
|
930
961
|
|
|
931
962
|
// Store collection references (posts resolved lazily)
|
|
932
963
|
const indiekitCollections = Indiekit.collections;
|
|
@@ -964,6 +995,7 @@ export default class ActivityPubEndpoint {
|
|
|
964
995
|
ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"),
|
|
965
996
|
ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
|
|
966
997
|
ap_markers: indiekitCollections.get("ap_markers"),
|
|
998
|
+
ap_tombstones: indiekitCollections.get("ap_tombstones"),
|
|
967
999
|
get posts() {
|
|
968
1000
|
return indiekitCollections.get("posts");
|
|
969
1001
|
},
|
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
* Authorize Interaction controller — handles the remote follow / authorize
|
|
3
3
|
* interaction flow for ActivityPub federation.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* subscribe template
|
|
5
|
+
* Supports:
|
|
6
|
+
* - OStatus subscribe template (legacy remote follow via ?uri=...)
|
|
7
|
+
* - FEP-3b86 Activity Intents (via ?uri=...&intent=follow|create|like|announce)
|
|
7
8
|
*
|
|
8
9
|
* Flow:
|
|
9
10
|
* 1. Missing uri → render error page
|
|
10
11
|
* 2. Unauthenticated → redirect to login, then back here
|
|
11
|
-
* 3. Authenticated →
|
|
12
|
+
* 3. Authenticated → route to appropriate page based on intent
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
export function authorizeInteractionController(plugin) {
|
|
15
16
|
return async (req, res) => {
|
|
16
17
|
const uri = req.query.uri || req.query.acct;
|
|
18
|
+
const intent = req.query.intent || "";
|
|
19
|
+
|
|
17
20
|
if (!uri) {
|
|
18
21
|
return res.status(400).render("activitypub-authorize-interaction", {
|
|
19
22
|
title: "Authorize Interaction",
|
|
@@ -29,17 +32,28 @@ export function authorizeInteractionController(plugin) {
|
|
|
29
32
|
// then back to this page after auth
|
|
30
33
|
const session = req.session;
|
|
31
34
|
if (!session?.access_token) {
|
|
32
|
-
const
|
|
35
|
+
const params = `uri=${encodeURIComponent(uri)}${intent ? `&intent=${intent}` : ""}`;
|
|
36
|
+
const returnUrl = `${plugin.options.mountPath}/authorize_interaction?${params}`;
|
|
33
37
|
return res.redirect(
|
|
34
38
|
`/session/login?redirect=${encodeURIComponent(returnUrl)}`,
|
|
35
39
|
);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
// which already has follow/unfollow/like/boost functionality
|
|
42
|
+
const mp = plugin.options.mountPath;
|
|
40
43
|
const encodedUrl = encodeURIComponent(resource);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
+
|
|
45
|
+
// Route based on intent (FEP-3b86)
|
|
46
|
+
switch (intent) {
|
|
47
|
+
case "follow":
|
|
48
|
+
return res.redirect(`${mp}/admin/reader/profile?url=${encodedUrl}`);
|
|
49
|
+
case "create":
|
|
50
|
+
return res.redirect(`${mp}/admin/reader/compose?replyTo=${encodedUrl}`);
|
|
51
|
+
case "like":
|
|
52
|
+
case "announce":
|
|
53
|
+
return res.redirect(`${mp}/admin/reader/post?url=${encodedUrl}`);
|
|
54
|
+
default:
|
|
55
|
+
// Default: resolve to remote profile page
|
|
56
|
+
return res.redirect(`${mp}/admin/reader/profile?url=${encodedUrl}`);
|
|
57
|
+
}
|
|
44
58
|
};
|
|
45
59
|
}
|
package/lib/federation-bridge.js
CHANGED
|
@@ -89,12 +89,6 @@ 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
|
-
}
|
|
98
92
|
const patched = JSON.stringify(json);
|
|
99
93
|
res.setHeader("content-length", Buffer.byteLength(patched));
|
|
100
94
|
res.end(patched);
|
package/lib/federation-setup.js
CHANGED
|
@@ -286,10 +286,48 @@ export function setupFederation(options) {
|
|
|
286
286
|
// Add OStatus subscribe template so remote servers (WordPress AP, Misskey, etc.)
|
|
287
287
|
// can redirect users to our authorize_interaction page for remote follow.
|
|
288
288
|
federation.setWebFingerLinksDispatcher((_ctx, _resource) => {
|
|
289
|
+
const interactionBase = `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction`;
|
|
289
290
|
return [
|
|
291
|
+
// OStatus subscribe template (legacy remote follow)
|
|
290
292
|
{
|
|
291
293
|
rel: "http://ostatus.org/schema/1.0/subscribe",
|
|
292
|
-
template: `${
|
|
294
|
+
template: `${interactionBase}?uri={uri}`,
|
|
295
|
+
},
|
|
296
|
+
// FEP-3b86 Activity Intents — Follow
|
|
297
|
+
{
|
|
298
|
+
rel: "https://w3id.org/fep/3b86",
|
|
299
|
+
template: `${interactionBase}?uri={uri}&intent=follow`,
|
|
300
|
+
properties: {
|
|
301
|
+
"https://w3id.org/fep/3b86#intent":
|
|
302
|
+
"https://www.w3.org/ns/activitystreams#Follow",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
// FEP-3b86 Activity Intents — Create (reply)
|
|
306
|
+
{
|
|
307
|
+
rel: "https://w3id.org/fep/3b86",
|
|
308
|
+
template: `${interactionBase}?uri={uri}&intent=create`,
|
|
309
|
+
properties: {
|
|
310
|
+
"https://w3id.org/fep/3b86#intent":
|
|
311
|
+
"https://www.w3.org/ns/activitystreams#Create",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
// FEP-3b86 Activity Intents — Like
|
|
315
|
+
{
|
|
316
|
+
rel: "https://w3id.org/fep/3b86",
|
|
317
|
+
template: `${interactionBase}?uri={uri}&intent=like`,
|
|
318
|
+
properties: {
|
|
319
|
+
"https://w3id.org/fep/3b86#intent":
|
|
320
|
+
"https://www.w3.org/ns/activitystreams#Like",
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
// FEP-3b86 Activity Intents — Announce (boost)
|
|
324
|
+
{
|
|
325
|
+
rel: "https://w3id.org/fep/3b86",
|
|
326
|
+
template: `${interactionBase}?uri={uri}&intent=announce`,
|
|
327
|
+
properties: {
|
|
328
|
+
"https://w3id.org/fep/3b86#intent":
|
|
329
|
+
"https://www.w3.org/ns/activitystreams#Announce",
|
|
330
|
+
},
|
|
293
331
|
},
|
|
294
332
|
];
|
|
295
333
|
});
|
|
@@ -305,6 +343,43 @@ export function setupFederation(options) {
|
|
|
305
343
|
storeRawActivities,
|
|
306
344
|
});
|
|
307
345
|
|
|
346
|
+
// Handle Delete activities from actors whose signing keys are gone.
|
|
347
|
+
// When an account is deleted, the remote server sends Delete but the
|
|
348
|
+
// actor's key endpoint returns 404/410, so signature verification fails.
|
|
349
|
+
// Fedify 2.1.0 lets us inspect these instead of auto-rejecting.
|
|
350
|
+
inboxChain
|
|
351
|
+
.onUnverifiedActivity(async (_ctx, activity, reason) => {
|
|
352
|
+
// Handle Delete activities from actors whose signing keys are gone.
|
|
353
|
+
// When an account is deleted, the remote server sends Delete but the
|
|
354
|
+
// actor's key endpoint returns 404/410, so signature verification fails.
|
|
355
|
+
// Fedify 2.1.0 lets us inspect these instead of auto-rejecting.
|
|
356
|
+
if (reason.type === "keyFetchError") {
|
|
357
|
+
const status = reason.result?.status;
|
|
358
|
+
if (status === 404 || status === 410) {
|
|
359
|
+
const actorId = activity.actorId?.href;
|
|
360
|
+
if (actorId) {
|
|
361
|
+
const activityType = activity.constructor?.name || "";
|
|
362
|
+
if (activityType === "Delete") {
|
|
363
|
+
console.info(
|
|
364
|
+
`[ActivityPub] Processing unverified Delete from ${actorId} (key ${status})`,
|
|
365
|
+
);
|
|
366
|
+
try {
|
|
367
|
+
await collections.ap_followers.deleteOne({ actorUrl: actorId });
|
|
368
|
+
await collections.ap_timeline.deleteMany({ "author.url": actorId });
|
|
369
|
+
await collections.ap_notifications.deleteMany({ actorUrl: actorId });
|
|
370
|
+
console.info(`[ActivityPub] Cleaned up data for deleted actor ${actorId}`);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
console.warn(`[ActivityPub] Cleanup for ${actorId} failed: ${error.message}`);
|
|
373
|
+
}
|
|
374
|
+
return new Response(null, { status: 202 });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// All other unverified activities: return null for default 401
|
|
380
|
+
return null;
|
|
381
|
+
});
|
|
382
|
+
|
|
308
383
|
// Enable authenticated fetches for the shared inbox.
|
|
309
384
|
// Without this, Fedify can't verify incoming HTTP Signatures from servers
|
|
310
385
|
// that require authorized fetch (e.g. hachyderm.io returns 401 on unsigned GETs).
|
|
@@ -337,17 +412,33 @@ export function setupFederation(options) {
|
|
|
337
412
|
? await collections.posts.countDocuments()
|
|
338
413
|
: 0;
|
|
339
414
|
|
|
415
|
+
const profile = await getProfile(collections);
|
|
416
|
+
|
|
340
417
|
return {
|
|
341
418
|
software: {
|
|
342
419
|
name: "indiekit",
|
|
343
420
|
version: softwareVersion,
|
|
421
|
+
repository: new URL("https://github.com/getindiekit/indiekit"),
|
|
422
|
+
homepage: new URL("https://getindiekit.com"),
|
|
344
423
|
},
|
|
345
424
|
protocols: ["activitypub"],
|
|
425
|
+
services: {
|
|
426
|
+
inbound: [],
|
|
427
|
+
outbound: [],
|
|
428
|
+
},
|
|
429
|
+
openRegistrations: false,
|
|
346
430
|
usage: {
|
|
347
431
|
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
|
|
348
432
|
localPosts: postsCount,
|
|
349
433
|
localComments: 0,
|
|
350
434
|
},
|
|
435
|
+
metadata: {
|
|
436
|
+
nodeName: profile.name || handle,
|
|
437
|
+
nodeDescription: profile.summary || "",
|
|
438
|
+
staffAccounts: [
|
|
439
|
+
`${publicationUrl}${mountPath.replace(/^\//, "")}/users/${handle}`,
|
|
440
|
+
],
|
|
441
|
+
},
|
|
351
442
|
};
|
|
352
443
|
});
|
|
353
444
|
|
|
@@ -740,6 +831,8 @@ export async function buildPersonActor(
|
|
|
740
831
|
featuredTags: ctx.getFeaturedTagsUri(identifier),
|
|
741
832
|
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
|
|
742
833
|
manuallyApprovesFollowers: profile.manuallyApprovesFollowers || false,
|
|
834
|
+
indexable: true,
|
|
835
|
+
discoverable: true,
|
|
743
836
|
};
|
|
744
837
|
|
|
745
838
|
if (profile.summary) {
|
package/lib/init-indexes.js
CHANGED
|
@@ -244,6 +244,12 @@ export function createIndexes(collections, options) {
|
|
|
244
244
|
{ userId: 1, timeline: 1 },
|
|
245
245
|
{ unique: true, background: true },
|
|
246
246
|
);
|
|
247
|
+
|
|
248
|
+
// Tombstone indexes (FEP-4f05)
|
|
249
|
+
collections.ap_tombstones?.createIndex(
|
|
250
|
+
{ url: 1 },
|
|
251
|
+
{ unique: true, background: true },
|
|
252
|
+
);
|
|
247
253
|
} catch {
|
|
248
254
|
// Index creation failed — collections not yet available.
|
|
249
255
|
// Indexes already exist from previous startups; non-fatal.
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
boostPost, unboostPost,
|
|
22
22
|
bookmarkPost, unbookmarkPost,
|
|
23
23
|
} from "../helpers/interactions.js";
|
|
24
|
-
import { addTimelineItem } from "../../storage/timeline.js";
|
|
25
24
|
import { tokenRequired } from "../middleware/token-required.js";
|
|
26
25
|
import { scopeRequired } from "../middleware/scope-required.js";
|
|
27
26
|
|
|
@@ -166,130 +165,105 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|
|
166
165
|
}
|
|
167
166
|
}
|
|
168
167
|
|
|
169
|
-
// Build JF2 properties for the Micropub pipeline
|
|
168
|
+
// Build JF2 properties for the Micropub pipeline.
|
|
169
|
+
// Provide both text and html — linkify URLs since Micropub's markdown-it
|
|
170
|
+
// doesn't have linkify enabled. Mentions are preserved as plain text;
|
|
171
|
+
// the AP syndicator resolves them via WebFinger for federation delivery.
|
|
172
|
+
const contentText = statusText || "";
|
|
173
|
+
const contentHtml = contentText
|
|
174
|
+
.replace(/&/g, "&")
|
|
175
|
+
.replace(/</g, "<")
|
|
176
|
+
.replace(/>/g, ">")
|
|
177
|
+
.replace(/(https?:\/\/[^\s<>&"')\]]+)/g, '<a href="$1">$1</a>')
|
|
178
|
+
.replace(/\n/g, "<br>");
|
|
179
|
+
|
|
170
180
|
const jf2 = {
|
|
171
181
|
type: "entry",
|
|
172
|
-
content:
|
|
182
|
+
content: { text: contentText, html: `<p>${contentHtml}</p>` },
|
|
173
183
|
};
|
|
174
184
|
|
|
175
185
|
if (inReplyTo) {
|
|
176
186
|
jf2["in-reply-to"] = inReplyTo;
|
|
177
187
|
}
|
|
178
188
|
|
|
179
|
-
if (
|
|
180
|
-
jf2.
|
|
189
|
+
if (visibility && visibility !== "public") {
|
|
190
|
+
jf2.visibility = visibility;
|
|
181
191
|
}
|
|
182
192
|
|
|
183
|
-
|
|
193
|
+
// Use content-warning (not summary) to match native reader behavior
|
|
194
|
+
if (spoilerText) {
|
|
195
|
+
jf2["content-warning"] = spoilerText;
|
|
184
196
|
jf2.sensitive = "true";
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
if (visibility && visibility !== "public") {
|
|
188
|
-
jf2.visibility = visibility;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
199
|
if (language) {
|
|
192
200
|
jf2["mp-language"] = language;
|
|
193
201
|
}
|
|
194
202
|
|
|
195
|
-
// Syndicate to AP
|
|
196
|
-
// Never cross-post to Bluesky (conversations stay in their protocol).
|
|
197
|
-
// The publication URL is the AP syndicator's uid.
|
|
203
|
+
// Syndicate to AP — posts from Mastodon clients belong to the fediverse
|
|
198
204
|
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
|
199
205
|
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
|
|
200
206
|
|
|
201
|
-
// Create post via Micropub pipeline (same functions
|
|
202
|
-
// postData.create() handles: normalization, post type detection, path rendering,
|
|
203
|
-
// mp-syndicate-to validated against configured syndicators, MongoDB posts collection
|
|
207
|
+
// Create post via Micropub pipeline (same internal functions)
|
|
204
208
|
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
|
205
209
|
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
|
|
206
210
|
|
|
207
211
|
const data = await postData.create(application, publication, jf2);
|
|
208
|
-
// postContent.create() handles: template rendering, file creation in store
|
|
209
212
|
await postContent.create(publication, data);
|
|
210
213
|
|
|
211
214
|
const postUrl = data.properties.url;
|
|
212
215
|
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
|
|
213
216
|
|
|
214
|
-
//
|
|
217
|
+
// Return a minimal status to the Mastodon client.
|
|
218
|
+
// No timeline entry is created here — the post will appear in the timeline
|
|
219
|
+
// after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
|
|
215
220
|
const profile = await collections.ap_profile.findOne({});
|
|
216
221
|
const handle = pluginOptions.handle || "user";
|
|
217
|
-
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
|
|
218
|
-
|
|
219
|
-
// Extract hashtags from status text and merge with any Micropub categories
|
|
220
|
-
const categories = data.properties.category || [];
|
|
221
|
-
const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
|
|
222
|
-
if (inlineHashtags) {
|
|
223
|
-
const existing = new Set(categories.map((c) => c.toLowerCase()));
|
|
224
|
-
for (const match of inlineHashtags) {
|
|
225
|
-
const tag = match.trim().slice(1).toLowerCase();
|
|
226
|
-
if (!existing.has(tag)) {
|
|
227
|
-
existing.add(tag);
|
|
228
|
-
categories.push(tag);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Resolve relative media URLs to absolute
|
|
234
|
-
const resolveMedia = (items) => {
|
|
235
|
-
if (!items || !items.length) return [];
|
|
236
|
-
return items.map((item) => {
|
|
237
|
-
if (typeof item === "string") {
|
|
238
|
-
return item.startsWith("http") ? item : `${publicationUrl.replace(/\/$/, "")}/${item.replace(/^\//, "")}`;
|
|
239
|
-
}
|
|
240
|
-
if (item?.url && !item.url.startsWith("http")) {
|
|
241
|
-
return { ...item, url: `${publicationUrl.replace(/\/$/, "")}/${item.url.replace(/^\//, "")}` };
|
|
242
|
-
}
|
|
243
|
-
return item;
|
|
244
|
-
});
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
// Process content: linkify URLs and extract @mentions
|
|
248
|
-
const rawContent = data.properties.content || { text: statusText || "", html: "" };
|
|
249
|
-
const processedContent = processStatusContent(rawContent, statusText || "");
|
|
250
|
-
const mentions = extractMentions(statusText || "");
|
|
251
222
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
223
|
+
res.json({
|
|
224
|
+
id: String(Date.now()),
|
|
225
|
+
created_at: new Date().toISOString(),
|
|
226
|
+
content: `<p>${contentHtml}</p>`,
|
|
255
227
|
url: postUrl,
|
|
256
|
-
|
|
257
|
-
content: processedContent,
|
|
258
|
-
summary: spoilerText || "",
|
|
259
|
-
sensitive: sensitive === true || sensitive === "true",
|
|
228
|
+
uri: postUrl,
|
|
260
229
|
visibility: visibility || "public",
|
|
230
|
+
sensitive: sensitive === true || sensitive === "true",
|
|
231
|
+
spoiler_text: spoilerText || "",
|
|
232
|
+
in_reply_to_id: inReplyToId || null,
|
|
233
|
+
in_reply_to_account_id: null,
|
|
261
234
|
language: language || null,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
235
|
+
replies_count: 0,
|
|
236
|
+
reblogs_count: 0,
|
|
237
|
+
favourites_count: 0,
|
|
238
|
+
favourited: false,
|
|
239
|
+
reblogged: false,
|
|
240
|
+
bookmarked: false,
|
|
241
|
+
account: {
|
|
242
|
+
id: "owner",
|
|
243
|
+
username: handle,
|
|
244
|
+
acct: handle,
|
|
245
|
+
display_name: profile?.name || handle,
|
|
267
246
|
url: profile?.url || publicationUrl,
|
|
268
|
-
|
|
269
|
-
|
|
247
|
+
avatar: profile?.icon || "",
|
|
248
|
+
avatar_static: profile?.icon || "",
|
|
249
|
+
header: "",
|
|
250
|
+
header_static: "",
|
|
251
|
+
followers_count: 0,
|
|
252
|
+
following_count: 0,
|
|
253
|
+
statuses_count: 0,
|
|
270
254
|
emojis: [],
|
|
271
|
-
|
|
255
|
+
fields: [],
|
|
272
256
|
},
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
257
|
+
media_attachments: [],
|
|
258
|
+
mentions: extractMentions(contentText).map(m => ({
|
|
259
|
+
id: "0",
|
|
260
|
+
username: m.name.split("@")[1] || m.name,
|
|
261
|
+
acct: m.name.replace(/^@/, ""),
|
|
262
|
+
url: m.url,
|
|
263
|
+
})),
|
|
264
|
+
tags: [],
|
|
280
265
|
emojis: [],
|
|
281
266
|
});
|
|
282
|
-
|
|
283
|
-
// Serialize and return
|
|
284
|
-
const serialized = serializeStatus(timelineItem, {
|
|
285
|
-
baseUrl,
|
|
286
|
-
favouritedIds: new Set(),
|
|
287
|
-
rebloggedIds: new Set(),
|
|
288
|
-
bookmarkedIds: new Set(),
|
|
289
|
-
pinnedIds: new Set(),
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
res.json(serialized);
|
|
293
267
|
} catch (error) {
|
|
294
268
|
next(error);
|
|
295
269
|
}
|
|
@@ -604,45 +578,6 @@ async function loadItemInteractions(collections, item) {
|
|
|
604
578
|
return { favouritedIds, rebloggedIds, bookmarkedIds };
|
|
605
579
|
}
|
|
606
580
|
|
|
607
|
-
/**
|
|
608
|
-
* Process status content: linkify bare URLs and convert @mentions to links.
|
|
609
|
-
*
|
|
610
|
-
* Mastodon clients send plain text — the server is responsible for
|
|
611
|
-
* converting URLs and mentions into HTML links.
|
|
612
|
-
*
|
|
613
|
-
* @param {object} content - { text, html } from Micropub pipeline
|
|
614
|
-
* @param {string} rawText - Original status text from client
|
|
615
|
-
* @returns {object} { text, html } with linkified content
|
|
616
|
-
*/
|
|
617
|
-
function processStatusContent(content, rawText) {
|
|
618
|
-
let html = content.html || content.text || rawText || "";
|
|
619
|
-
|
|
620
|
-
// If the HTML is just plain text wrapped in <p>, process it
|
|
621
|
-
// Don't touch HTML that already has links (from Micropub rendering)
|
|
622
|
-
if (!html.includes("<a ")) {
|
|
623
|
-
// Linkify bare URLs (http/https)
|
|
624
|
-
html = html.replace(
|
|
625
|
-
/(https?:\/\/[^\s<>"')\]]+)/g,
|
|
626
|
-
'<a href="$1" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
|
|
627
|
-
);
|
|
628
|
-
|
|
629
|
-
// Convert @user@domain mentions to profile links
|
|
630
|
-
html = html.replace(
|
|
631
|
-
/(?:^|\s)(@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g,
|
|
632
|
-
(match, full, username, domain) =>
|
|
633
|
-
match.replace(
|
|
634
|
-
full,
|
|
635
|
-
`<span class="h-card"><a href="https://${domain}/@${username}" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@${username}@${domain}</a></span>`,
|
|
636
|
-
),
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
return {
|
|
641
|
-
text: content.text || rawText || "",
|
|
642
|
-
html,
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
|
|
646
581
|
/**
|
|
647
582
|
* Extract @user@domain mentions from text into mention objects.
|
|
648
583
|
*
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tombstone storage for soft-deleted posts (FEP-4f05).
|
|
3
|
+
* When a post is deleted, a tombstone record is created so remote servers
|
|
4
|
+
* fetching the URL get a proper Tombstone response instead of 404.
|
|
5
|
+
* @module storage/tombstones
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Record a tombstone for a deleted post.
|
|
10
|
+
* @param {object} collections - MongoDB collections
|
|
11
|
+
* @param {object} data - { url, formerType, published, deleted }
|
|
12
|
+
*/
|
|
13
|
+
export async function addTombstone(collections, { url, formerType, published, deleted }) {
|
|
14
|
+
const { ap_tombstones } = collections;
|
|
15
|
+
if (!ap_tombstones) return;
|
|
16
|
+
|
|
17
|
+
await ap_tombstones.updateOne(
|
|
18
|
+
{ url },
|
|
19
|
+
{
|
|
20
|
+
$set: {
|
|
21
|
+
url,
|
|
22
|
+
formerType: formerType || "Note",
|
|
23
|
+
published: published || null,
|
|
24
|
+
deleted: deleted || new Date().toISOString(),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{ upsert: true },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Remove a tombstone (post re-published).
|
|
33
|
+
* @param {object} collections - MongoDB collections
|
|
34
|
+
* @param {string} url - Post URL
|
|
35
|
+
*/
|
|
36
|
+
export async function removeTombstone(collections, url) {
|
|
37
|
+
const { ap_tombstones } = collections;
|
|
38
|
+
if (!ap_tombstones) return;
|
|
39
|
+
await ap_tombstones.deleteOne({ url });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Look up a tombstone by URL.
|
|
44
|
+
* @param {object} collections - MongoDB collections
|
|
45
|
+
* @param {string} url - Post URL
|
|
46
|
+
* @returns {Promise<object|null>} Tombstone record or null
|
|
47
|
+
*/
|
|
48
|
+
export async function getTombstone(collections, url) {
|
|
49
|
+
const { ap_tombstones } = collections;
|
|
50
|
+
if (!ap_tombstones) return null;
|
|
51
|
+
return ap_tombstones.findOne({ url });
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
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",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@fedify/debugger": "^2.
|
|
41
|
-
"@fedify/fedify": "^2.
|
|
42
|
-
"@fedify/redis": "^2.
|
|
40
|
+
"@fedify/debugger": "^2.1.0",
|
|
41
|
+
"@fedify/fedify": "^2.1.0",
|
|
42
|
+
"@fedify/redis": "^2.1.0",
|
|
43
43
|
"@js-temporal/polyfill": "^0.5.0",
|
|
44
44
|
"express": "^5.0.0",
|
|
45
45
|
"express-rate-limit": "^7.5.1",
|