@rmdes/indiekit-endpoint-activitypub 1.1.12 → 1.1.14

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 ADDED
@@ -0,0 +1,267 @@
1
+ # @rmdes/indiekit-endpoint-activitypub
2
+
3
+ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com). Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform.
4
+
5
+ ## Features
6
+
7
+ **Federation**
8
+ - Full ActivityPub actor with WebFinger, NodeInfo, HTTP Signatures, and Object Integrity Proofs (Ed25519)
9
+ - Outbox syndication — posts created via Micropub are automatically delivered to followers
10
+ - Inbox processing — receives follows, likes, boosts, replies, mentions, deletes, and account moves
11
+ - Content negotiation — ActivityPub clients requesting your site get JSON-LD; browsers get HTML
12
+ - Reply delivery — replies are addressed to and delivered directly to the original post's author
13
+ - Shared inbox support with collection sync (FEP-8fcf)
14
+ - Configurable actor type (Person, Service, Organization, Group)
15
+
16
+ **Reader**
17
+ - Timeline view showing posts from followed accounts
18
+ - Notifications for likes, boosts, follows, mentions, and replies
19
+ - Compose form with dual-path posting (quick AP reply or Micropub blog post)
20
+ - Native interactions (like, boost, reply, follow/unfollow from the reader)
21
+ - Remote actor profile pages
22
+ - Content warnings and sensitive content handling
23
+ - Media display (images, video, audio)
24
+ - Configurable timeline retention
25
+
26
+ **Moderation**
27
+ - Mute actors or keywords
28
+ - Block actors (also removes from followers)
29
+ - All moderation actions available from the reader UI
30
+
31
+ **Mastodon Migration**
32
+ - Import following/followers lists from Mastodon CSV exports
33
+ - Set `alsoKnownAs` alias for account Move verification
34
+ - Batch re-follow processor — gradually sends Follow activities to imported accounts
35
+ - Progress tracking with pause/resume controls
36
+
37
+ **Admin UI**
38
+ - Dashboard with follower/following counts and recent activity
39
+ - Profile editor (name, bio, avatar, header, profile links with rel="me" verification)
40
+ - Pinned posts (featured collection)
41
+ - Featured tags (hashtag collection)
42
+ - Activity log (inbound/outbound)
43
+ - Follower and following lists with source tracking
44
+
45
+ ## Requirements
46
+
47
+ - [Indiekit](https://getindiekit.com) v1.0.0-beta.25+
48
+ - Node.js >= 22
49
+ - MongoDB (used by Indiekit)
50
+ - Redis (recommended for production delivery queue; in-process queue available for development)
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ npm install @rmdes/indiekit-endpoint-activitypub
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ Add the plugin to your Indiekit config:
61
+
62
+ ```javascript
63
+ // indiekit.config.js
64
+ export default {
65
+ plugins: [
66
+ "@rmdes/indiekit-endpoint-activitypub",
67
+ ],
68
+ "@rmdes/indiekit-endpoint-activitypub": {
69
+ mountPath: "/activitypub",
70
+ actor: {
71
+ handle: "yourname",
72
+ name: "Your Name",
73
+ summary: "A short bio",
74
+ icon: "https://example.com/avatar.jpg",
75
+ },
76
+ },
77
+ };
78
+ ```
79
+
80
+ ### All Options
81
+
82
+ | Option | Type | Default | Description |
83
+ |---|---|---|---|
84
+ | `mountPath` | string | `"/activitypub"` | URL prefix for all plugin routes |
85
+ | `actor.handle` | string | `"rick"` | Fediverse username (e.g. `@handle@yourdomain.com`) |
86
+ | `actor.name` | string | `""` | Display name (used to seed profile on first run) |
87
+ | `actor.summary` | string | `""` | Bio text (used to seed profile on first run) |
88
+ | `actor.icon` | string | `""` | Avatar URL (used to seed profile on first run) |
89
+ | `checked` | boolean | `true` | Whether the syndicator is checked by default in the post editor |
90
+ | `alsoKnownAs` | string | `""` | Mastodon migration alias URL |
91
+ | `activityRetentionDays` | number | `90` | Days to keep activity log entries (0 = forever) |
92
+ | `storeRawActivities` | boolean | `false` | Store full raw JSON of inbound activities |
93
+ | `redisUrl` | string | `""` | Redis connection URL for delivery queue |
94
+ | `parallelWorkers` | number | `5` | Number of parallel delivery workers (requires Redis) |
95
+ | `actorType` | string | `"Person"` | Actor type: `Person`, `Service`, `Organization`, or `Group` |
96
+ | `timelineRetention` | number | `1000` | Maximum timeline items to keep (0 = unlimited) |
97
+
98
+ ### Redis (Recommended for Production)
99
+
100
+ Without Redis, the plugin uses an in-process message queue. This works for development but won't survive restarts and has limited throughput.
101
+
102
+ ```javascript
103
+ "@rmdes/indiekit-endpoint-activitypub": {
104
+ redisUrl: "redis://localhost:6379",
105
+ parallelWorkers: 5,
106
+ },
107
+ ```
108
+
109
+ ### Nginx Configuration (Reverse Proxy)
110
+
111
+ If you serve a static site alongside Indiekit (e.g. with Eleventy), you need nginx rules to route ActivityPub requests to Indiekit while serving HTML to browsers:
112
+
113
+ ```nginx
114
+ # ActivityPub content negotiation — detect AP clients
115
+ map $http_accept $is_activitypub {
116
+ default 0;
117
+ "~*application/activity\+json" 1;
118
+ "~*application/ld\+json" 1;
119
+ }
120
+
121
+ # Proxy /activitypub to Indiekit
122
+ location /activitypub {
123
+ proxy_pass http://127.0.0.1:8080;
124
+ proxy_set_header Host $host;
125
+ proxy_set_header X-Forwarded-Proto https;
126
+ }
127
+
128
+ # Default: static site, but AP clients get proxied
129
+ location / {
130
+ if ($is_activitypub) {
131
+ proxy_pass http://127.0.0.1:8080;
132
+ }
133
+ try_files $uri $uri/ $uri.html =404;
134
+ }
135
+ ```
136
+
137
+ ## How It Works
138
+
139
+ ### Syndication (Outbound)
140
+
141
+ When you create a post via Micropub, Indiekit's syndication system calls this plugin's syndicator. The plugin:
142
+
143
+ 1. Converts the JF2 post properties to an ActivityStreams 2.0 `Create(Note)` or `Create(Article)` activity
144
+ 2. For replies, resolves the original post's author to include them in CC and deliver directly to their inbox
145
+ 3. Sends the activity to all followers via shared inboxes using Fedify's delivery queue
146
+ 4. Appends a permalink to the content so fediverse clients link back to your canonical post
147
+
148
+ ### Inbox Processing (Inbound)
149
+
150
+ When remote servers send activities to your inbox:
151
+
152
+ - **Follow** → Auto-accepted, stored in `ap_followers`, notification created
153
+ - **Undo(Follow)** → Removed from `ap_followers`
154
+ - **Like** → Logged in activity log, notification created (only for reactions to your own posts)
155
+ - **Announce (Boost)** → Logged + notification (your content) or stored in timeline (followed account)
156
+ - **Create (Note/Article)** → Stored in timeline if from a followed account; notification if it's a reply or mention
157
+ - **Update** → Updates timeline item content or refreshes follower profile data
158
+ - **Delete** → Removes from activity log and timeline
159
+ - **Move** → Updates follower's actor URL
160
+ - **Accept(Follow)** → Marks our follow as accepted
161
+ - **Reject(Follow)** → Marks our follow as rejected
162
+ - **Block** → Removes actor from our followers
163
+
164
+ ### Content Negotiation
165
+
166
+ The plugin mounts a root-level router that intercepts requests from ActivityPub clients (detected by `Accept: application/activity+json` or `application/ld+json`):
167
+
168
+ - Root URL (`/`) → Redirects to the Fedify actor document
169
+ - Post URLs → Looks up the post in MongoDB, converts to AS2 JSON
170
+ - NodeInfo (`/nodeinfo/2.1`) → Delegated to Fedify
171
+
172
+ Regular browser requests pass through unmodified.
173
+
174
+ ### Mastodon Migration
175
+
176
+ The plugin supports migrating from a Mastodon account:
177
+
178
+ 1. **Set alias** — Configure `alsoKnownAs` with your old Mastodon profile URL. This is verified by Mastodon before allowing a Move.
179
+ 2. **Import social graph** — Upload Mastodon's `following_accounts.csv` and `followers.csv` exports. Following entries are resolved via WebFinger and stored locally.
180
+ 3. **Trigger Move** — From Mastodon's settings, initiate a Move to `@handle@yourdomain.com`. Mastodon notifies your followers, and compatible servers auto-refollow.
181
+ 4. **Batch re-follow** — The plugin gradually sends Follow activities to all imported accounts (10 per batch, 30s between batches) so remote servers start delivering content to your inbox.
182
+
183
+ ## Verification
184
+
185
+ After deployment, verify federation is working:
186
+
187
+ ```bash
188
+ # WebFinger discovery
189
+ curl -s "https://yourdomain.com/.well-known/webfinger?resource=acct:handle@yourdomain.com" | jq .
190
+
191
+ # Actor document
192
+ curl -s -H "Accept: application/activity+json" "https://yourdomain.com/" | jq .
193
+
194
+ # NodeInfo
195
+ curl -s "https://yourdomain.com/nodeinfo/2.1" | jq .
196
+ ```
197
+
198
+ Then search for `@handle@yourdomain.com` from any Mastodon instance — your profile should appear.
199
+
200
+ ## Admin UI Pages
201
+
202
+ All admin pages are behind IndieAuth authentication:
203
+
204
+ | Page | Path | Description |
205
+ |---|---|---|
206
+ | Dashboard | `/activitypub` | Overview with follower/following counts, recent activity |
207
+ | Reader | `/activitypub/admin/reader` | Timeline from followed accounts |
208
+ | Notifications | `/activitypub/admin/reader/notifications` | Likes, boosts, follows, mentions, replies |
209
+ | Compose | `/activitypub/admin/reader/compose` | Reply composer (quick AP or Micropub) |
210
+ | Moderation | `/activitypub/admin/reader/moderation` | Muted/blocked accounts and keywords |
211
+ | Profile | `/activitypub/admin/profile` | Edit actor display name, bio, avatar, links |
212
+ | Followers | `/activitypub/admin/followers` | List of accounts following you |
213
+ | Following | `/activitypub/admin/following` | List of accounts you follow |
214
+ | Activity Log | `/activitypub/admin/activities` | Inbound/outbound activity history |
215
+ | Pinned Posts | `/activitypub/admin/featured` | Pin/unpin posts to your featured collection |
216
+ | Featured Tags | `/activitypub/admin/tags` | Add/remove featured hashtags |
217
+ | Migration | `/activitypub/admin/migrate` | Mastodon import wizard |
218
+
219
+ ## MongoDB Collections
220
+
221
+ The plugin creates these collections automatically:
222
+
223
+ | Collection | Description |
224
+ |---|---|
225
+ | `ap_followers` | Accounts following your actor |
226
+ | `ap_following` | Accounts you follow |
227
+ | `ap_activities` | Activity log with automatic TTL cleanup |
228
+ | `ap_keys` | RSA and Ed25519 key pairs for HTTP Signatures |
229
+ | `ap_kv` | Fedify key-value store and batch job state |
230
+ | `ap_profile` | Actor profile (single document) |
231
+ | `ap_featured` | Pinned/featured posts |
232
+ | `ap_featured_tags` | Featured hashtags |
233
+ | `ap_timeline` | Reader timeline items from followed accounts |
234
+ | `ap_notifications` | Interaction notifications |
235
+ | `ap_muted` | Muted actors and keywords |
236
+ | `ap_blocked` | Blocked actors |
237
+ | `ap_interactions` | Per-post like/boost tracking |
238
+
239
+ ## Supported Post Types
240
+
241
+ The JF2-to-ActivityStreams converter handles these Indiekit post types:
242
+
243
+ | Post Type | ActivityStreams |
244
+ |---|---|
245
+ | note, reply, bookmark, jam, rsvp, checkin | `Create(Note)` |
246
+ | article | `Create(Article)` |
247
+ | like | `Like` |
248
+ | repost | `Announce` |
249
+ | photo, video, audio | Attachments on Note/Article |
250
+
251
+ Categories are converted to `Hashtag` tags. Bookmarks include a bookmark emoji and link.
252
+
253
+ ## Known Limitations
254
+
255
+ - **No automated tests** — Manual testing against real fediverse servers
256
+ - **Single actor** — One fediverse identity per Indiekit instance
257
+ - **No Authorized Fetch enforcement** — Disabled due to Fedify's current limitation with authenticated outgoing fetches (causes infinite loops with servers that require it)
258
+ - **No image upload in reader** — Compose form is text-only
259
+ - **In-process queue without Redis** — Activities may be lost on restart
260
+
261
+ ## License
262
+
263
+ MIT
264
+
265
+ ## Author
266
+
267
+ [Ricardo Mendes](https://rmendes.net) ([@rick@rmendes.net](https://rmendes.net))
@@ -0,0 +1,151 @@
1
+ /**
2
+ * OpenGraph link preview cards and AP link interception
3
+ * Styles for link preview cards in the ActivityPub reader
4
+ */
5
+
6
+ /* Link preview container */
7
+ .ap-link-previews {
8
+ margin-top: var(--space-m);
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: var(--space-s);
12
+ }
13
+
14
+ /* Individual link preview card */
15
+ .ap-link-preview {
16
+ display: flex;
17
+ overflow: hidden;
18
+ border-radius: var(--border-radius-small);
19
+ border: 1px solid var(--color-neutral-lighter);
20
+ background-color: var(--color-offset);
21
+ text-decoration: none;
22
+ color: inherit;
23
+ transition: border-color 0.2s ease;
24
+ }
25
+
26
+ .ap-link-preview:hover {
27
+ border-color: var(--color-primary);
28
+ }
29
+
30
+ /* Text content area (left side) */
31
+ .ap-link-preview__text {
32
+ flex: 1;
33
+ padding: var(--space-s);
34
+ min-width: 0; /* Enable text truncation */
35
+ }
36
+
37
+ .ap-link-preview__title {
38
+ font-weight: 600;
39
+ font-size: 0.875rem;
40
+ color: var(--color-on-background);
41
+ margin: 0;
42
+ overflow: hidden;
43
+ text-overflow: ellipsis;
44
+ white-space: nowrap;
45
+ }
46
+
47
+ .ap-link-preview__desc {
48
+ font-size: 0.75rem;
49
+ color: var(--color-on-offset);
50
+ margin: var(--space-xs) 0 0;
51
+ display: -webkit-box;
52
+ -webkit-line-clamp: 2;
53
+ -webkit-box-orient: vertical;
54
+ overflow: hidden;
55
+ }
56
+
57
+ .ap-link-preview__domain {
58
+ font-size: 0.75rem;
59
+ color: var(--color-neutral);
60
+ margin: var(--space-s) 0 0;
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.25rem;
64
+ }
65
+
66
+ .ap-link-preview__favicon {
67
+ width: 1rem;
68
+ height: 1rem;
69
+ display: inline-block;
70
+ }
71
+
72
+ /* Image area (right side) */
73
+ .ap-link-preview__image {
74
+ flex-shrink: 0;
75
+ width: 6rem;
76
+ height: 6rem;
77
+ }
78
+
79
+ .ap-link-preview__image img {
80
+ width: 100%;
81
+ height: 100%;
82
+ object-fit: cover;
83
+ }
84
+
85
+ /* Responsive - larger images on desktop */
86
+ @media (min-width: 640px) {
87
+ .ap-link-preview__image {
88
+ width: 8rem;
89
+ height: 8rem;
90
+ }
91
+
92
+ .ap-link-preview__title {
93
+ font-size: 1rem;
94
+ }
95
+
96
+ .ap-link-preview__desc {
97
+ font-size: 0.875rem;
98
+ }
99
+ }
100
+
101
+ /* Post detail thread view */
102
+ .ap-post-detail__back {
103
+ margin-bottom: var(--space-m);
104
+ }
105
+
106
+ .ap-post-detail__back-link {
107
+ font-size: 0.875rem;
108
+ color: var(--color-primary);
109
+ text-decoration: none;
110
+ }
111
+
112
+ .ap-post-detail__back-link:hover {
113
+ text-decoration: underline;
114
+ }
115
+
116
+ .ap-post-detail__section-title {
117
+ font-size: 0.875rem;
118
+ font-weight: 600;
119
+ color: var(--color-neutral);
120
+ text-transform: uppercase;
121
+ letter-spacing: 0.05em;
122
+ margin: var(--space-l) 0 var(--space-s);
123
+ padding-bottom: var(--space-xs);
124
+ border-bottom: 1px solid var(--color-neutral-lighter);
125
+ }
126
+
127
+ .ap-post-detail__main {
128
+ margin: var(--space-m) 0;
129
+ }
130
+
131
+ .ap-post-detail__parents,
132
+ .ap-post-detail__replies {
133
+ margin: var(--space-m) 0;
134
+ }
135
+
136
+ .ap-post-detail__parent-item,
137
+ .ap-post-detail__reply-item {
138
+ margin-bottom: var(--space-s);
139
+ }
140
+
141
+ /* Thread connector line between parent posts */
142
+ .ap-post-detail__parents .ap-post-detail__parent-item {
143
+ position: relative;
144
+ padding-left: var(--space-m);
145
+ border-left: 2px solid var(--color-neutral-lighter);
146
+ }
147
+
148
+ /* Main post highlight */
149
+ .ap-post-detail__main .ap-card {
150
+ border-left-width: 3px;
151
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Client-side AP link interception for internal navigation
3
+ * Redirects ActivityPub links to internal reader views
4
+ */
5
+
6
+ (function () {
7
+ "use strict";
8
+
9
+ // Fediverse URL patterns that should open internally
10
+ const AP_URL_PATTERN =
11
+ /\/@[\w.-]+\/\d+|\/@[\w.-]+\/statuses\/[\w]+|\/users\/[\w.-]+\/statuses\/\d+|\/objects\/[\w-]+|\/notice\/[\w]+|\/notes\/[\w]+|\/post\/\d+|\/comment\/\d+|\/p\/[\w.-]+\/\d+/;
12
+
13
+ // Get mount path from DOM
14
+ function getMountPath() {
15
+ // Look for data-mount-path on reader container or header
16
+ const container = document.querySelector(
17
+ "[data-mount-path]",
18
+ );
19
+ return container ? container.dataset.mountPath : "/activitypub";
20
+ }
21
+
22
+ // Check if a link should be intercepted
23
+ function shouldInterceptLink(link) {
24
+ const href = link.getAttribute("href");
25
+ if (!href) return null;
26
+
27
+ const classes = link.className || "";
28
+
29
+ // Mention links → profile view
30
+ if (classes.includes("mention")) {
31
+ return { type: "profile", url: href };
32
+ }
33
+
34
+ // AP object URL patterns → post detail view
35
+ if (AP_URL_PATTERN.test(href)) {
36
+ return { type: "post", url: href };
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ // Handle link click
43
+ function handleLinkClick(event) {
44
+ const link = event.target.closest("a");
45
+ if (!link) return;
46
+
47
+ // Only intercept links inside post content
48
+ const contentDiv = link.closest(".ap-card__content");
49
+ if (!contentDiv) return;
50
+
51
+ const interception = shouldInterceptLink(link);
52
+ if (!interception) return;
53
+
54
+ // Prevent default navigation
55
+ event.preventDefault();
56
+
57
+ const mountPath = getMountPath();
58
+ const encodedUrl = encodeURIComponent(interception.url);
59
+
60
+ if (interception.type === "profile") {
61
+ window.location.href = `${mountPath}/admin/reader/profile?url=${encodedUrl}`;
62
+ } else if (interception.type === "post") {
63
+ window.location.href = `${mountPath}/admin/reader/post?url=${encodedUrl}`;
64
+ }
65
+ }
66
+
67
+ // Initialize on DOM ready
68
+ function init() {
69
+ // Use event delegation on timeline container
70
+ const timeline = document.querySelector(".ap-timeline");
71
+ if (timeline) {
72
+ timeline.addEventListener("click", handleLinkClick);
73
+ }
74
+
75
+ // Also set up on post detail view
76
+ const postDetail = document.querySelector(".ap-post-detail");
77
+ if (postDetail) {
78
+ postDetail.addEventListener("click", handleLinkClick);
79
+ }
80
+ }
81
+
82
+ // Run on DOMContentLoaded or immediately if already loaded
83
+ if (document.readyState === "loading") {
84
+ document.addEventListener("DOMContentLoaded", init);
85
+ } else {
86
+ init();
87
+ }
88
+ })();
package/index.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  remoteProfileController,
18
18
  followController,
19
19
  unfollowController,
20
+ postDetailController,
20
21
  } from "./lib/controllers/reader.js";
21
22
  import {
22
23
  likeController,
@@ -195,6 +196,7 @@ export default class ActivityPubEndpoint {
195
196
  router.post("/admin/reader/boost", boostController(mp, this));
196
197
  router.post("/admin/reader/unboost", unboostController(mp, this));
197
198
  router.get("/admin/reader/profile", remoteProfileController(mp, this));
199
+ router.get("/admin/reader/post", postDetailController(mp, this));
198
200
  router.post("/admin/reader/follow", followController(mp, this));
199
201
  router.post("/admin/reader/unfollow", unfollowController(mp, this));
200
202
  router.get("/admin/reader/moderation", moderationController(mp));