@rmdes/indiekit-endpoint-microsub 1.0.61 → 1.0.64

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.
@@ -153,12 +153,6 @@
153
153
 
154
154
  {# Inline actions (Aperture pattern) #}
155
155
  <div class="ms-item-actions">
156
- {% if item._source and item._source.type === "activitypub" and item.author and item.author.url %}
157
- <a href="{{ baseUrl }}/actor?url={{ item.author.url | urlencode }}" class="ms-item-actions__button" title="View actor profile">
158
- {{ icon("mention") }}
159
- <span class="-!-visually-hidden">Actor profile</span>
160
- </a>
161
- {% endif %}
162
156
  {% if item.url %}
163
157
  <a href="{{ item.url }}" class="ms-item-actions__button" target="_blank" rel="noopener" title="View original">
164
158
  {{ icon("syndicate") }}
@@ -49,6 +49,43 @@
49
49
  value: channel.settings.excludeRegex
50
50
  }) }}
51
51
 
52
+ {% if channel.uid !== "notifications" %}
53
+ <fieldset class="ms-retention-settings">
54
+ <legend>{{ __("microsub.settings.retentionTitle") }}</legend>
55
+ <p class="hint">{{ __("microsub.settings.retentionHelp") }}</p>
56
+
57
+ {{ input({
58
+ id: "maxItems",
59
+ name: "maxItems",
60
+ type: "number",
61
+ label: __("microsub.settings.maxItems"),
62
+ hint: __("microsub.settings.maxItemsHelp", { default: retentionDefaults.maxItems }),
63
+ attributes: { min: 10, max: 100000, placeholder: retentionDefaults.maxItems },
64
+ value: channel.settings.maxItems
65
+ }) }}
66
+
67
+ {{ input({
68
+ id: "maxItemsPerFeed",
69
+ name: "maxItemsPerFeed",
70
+ type: "number",
71
+ label: __("microsub.settings.maxItemsPerFeed"),
72
+ hint: __("microsub.settings.maxItemsPerFeedHelp", { default: retentionDefaults.maxItemsPerFeed }),
73
+ attributes: { min: 1, max: 10000, placeholder: retentionDefaults.maxItemsPerFeed },
74
+ value: channel.settings.maxItemsPerFeed
75
+ }) }}
76
+
77
+ {{ input({
78
+ id: "maxUnreadAgeDays",
79
+ name: "maxUnreadAgeDays",
80
+ type: "number",
81
+ label: __("microsub.settings.maxUnreadAgeDays"),
82
+ hint: __("microsub.settings.maxUnreadAgeDaysHelp", { default: retentionDefaults.maxUnreadAgeDays }),
83
+ attributes: { min: 1, max: 3650, placeholder: retentionDefaults.maxUnreadAgeDays },
84
+ value: channel.settings.maxUnreadAgeDays
85
+ }) }}
86
+ </fieldset>
87
+ {% endif %}
88
+
52
89
  <div class="button-group">
53
90
  {{ button({
54
91
  text: __("microsub.settings.save")
@@ -1,267 +0,0 @@
1
- /**
2
- * Fetch a remote ActivityPub actor's outbox for on-demand reading.
3
- * Returns ephemeral jf2 items — nothing is stored in MongoDB.
4
- *
5
- * @module activitypub/outbox-fetcher
6
- */
7
-
8
- import sanitizeHtml from "sanitize-html";
9
-
10
- import { isPrivateUrl } from "../media/proxy.js";
11
- import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
12
-
13
- const AP_ACCEPT =
14
- 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
15
- const FETCH_TIMEOUT = 10_000;
16
- const USER_AGENT = "Indiekit/1.0 (Microsub reader)";
17
-
18
- /**
19
- * Fetch a remote actor's profile and recent posts from their outbox.
20
- *
21
- * @param {string} actorUrl - Full URL of the AP actor
22
- * @param {object} [options]
23
- * @param {number} [options.limit=20] - Max items to return
24
- * @returns {Promise<{ actor: object, items: Array }>}
25
- */
26
- export async function fetchActorOutbox(actorUrl, options = {}) {
27
- const limit = options.limit || 20;
28
-
29
- // 1. Fetch actor profile
30
- const actor = await fetchJson(actorUrl);
31
- if (!actor || !actor.outbox) {
32
- throw new Error("Could not resolve actor or outbox URL");
33
- }
34
-
35
- const actorInfo = {
36
- name:
37
- actor.name ||
38
- actor.preferredUsername ||
39
- new URL(actorUrl).pathname.split("/").pop(),
40
- url: actor.url || actor.id || actorUrl,
41
- photo: actor.icon?.url || actor.icon || "",
42
- summary: stripHtml(actor.summary || ""),
43
- handle: actor.preferredUsername || "",
44
- followersCount: 0,
45
- followingCount: 0,
46
- };
47
-
48
- // Resolve follower/following counts if available
49
- if (typeof actor.followers === "string") {
50
- try {
51
- const followersCollection = await fetchJson(actor.followers);
52
- actorInfo.followersCount = followersCollection?.totalItems || 0;
53
- } catch {
54
- /* ignore */
55
- }
56
- }
57
- if (typeof actor.following === "string") {
58
- try {
59
- const followingCollection = await fetchJson(actor.following);
60
- actorInfo.followingCount = followingCollection?.totalItems || 0;
61
- } catch {
62
- /* ignore */
63
- }
64
- }
65
-
66
- // 2. Fetch outbox (OrderedCollection)
67
- const outboxUrl =
68
- typeof actor.outbox === "string" ? actor.outbox : actor.outbox?.id;
69
- const outbox = await fetchJson(outboxUrl);
70
- if (!outbox) {
71
- return { actor: actorInfo, items: [] };
72
- }
73
-
74
- // 3. Get items — may be inline or on a first page
75
- let activities = [];
76
-
77
- if (outbox.orderedItems?.length > 0) {
78
- activities = outbox.orderedItems;
79
- } else if (outbox.first) {
80
- const firstPageUrl =
81
- typeof outbox.first === "string" ? outbox.first : outbox.first?.id;
82
- if (firstPageUrl) {
83
- const firstPage = await fetchJson(firstPageUrl);
84
- activities = firstPage?.orderedItems || firstPage?.items || [];
85
- }
86
- }
87
-
88
- // 4. Convert Create activities to jf2 items
89
- const items = [];
90
- for (const activity of activities) {
91
- if (items.length >= limit) break;
92
-
93
- const item = activityToJf2(activity, actorInfo);
94
- if (item) items.push(item);
95
- }
96
-
97
- return { actor: actorInfo, items };
98
- }
99
-
100
- /**
101
- * Convert a single AP activity (or bare object) to jf2 format.
102
- * @param {object} activity - AP activity or object
103
- * @param {object} actorInfo - Actor profile info
104
- * @returns {object|null} jf2 item or null if not displayable
105
- */
106
- function activityToJf2(activity, actorInfo) {
107
- // Unwrap Create/Announce — the displayable content is the inner object
108
- let object = activity;
109
- const activityType = activity.type;
110
-
111
- if (activityType === "Create" || activityType === "Announce") {
112
- object = activity.object;
113
- if (!object || typeof object === "string") return null; // Unresolved reference
114
- }
115
-
116
- // Skip non-content types (Follow, Like, etc.)
117
- const contentTypes = new Set([
118
- "Note",
119
- "Article",
120
- "Page",
121
- "Video",
122
- "Audio",
123
- "Image",
124
- "Event",
125
- "Question",
126
- ]);
127
- if (!contentTypes.has(object.type)) return null;
128
-
129
- const rawHtml = object.content || "";
130
- const contentHtml = rawHtml ? sanitizeHtml(rawHtml, SANITIZE_OPTIONS) : "";
131
- const contentText = stripHtml(rawHtml);
132
-
133
- const jf2 = {
134
- type: "entry",
135
- url: object.url || object.id || "",
136
- uid: object.id || object.url || "",
137
- name: object.name || undefined,
138
- content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
139
- summary: object.summary ? stripHtml(object.summary) : undefined,
140
- published: object.published || activity.published || undefined,
141
- author: {
142
- name: actorInfo.name,
143
- url: actorInfo.url,
144
- photo: actorInfo.photo,
145
- },
146
- category: extractTags(object.tag),
147
- photo: extractMedia(object.attachment, "image"),
148
- video: extractMedia(object.attachment, "video"),
149
- audio: extractMedia(object.attachment, "audio"),
150
- _source: { type: "activitypub", actorUrl: actorInfo.url },
151
- };
152
-
153
- // Boost attribution
154
- if (activityType === "Announce" && activity.actor) {
155
- jf2._boostedBy = actorInfo;
156
- // The inner object may have its own author
157
- if (object.attributedTo) {
158
- const attributedUrl =
159
- typeof object.attributedTo === "string"
160
- ? object.attributedTo
161
- : object.attributedTo?.id || object.attributedTo?.url;
162
- if (attributedUrl) {
163
- jf2.author = {
164
- name:
165
- object.attributedTo?.name ||
166
- object.attributedTo?.preferredUsername ||
167
- attributedUrl,
168
- url: attributedUrl,
169
- photo: object.attributedTo?.icon?.url || "",
170
- };
171
- }
172
- }
173
- }
174
-
175
- if (object.inReplyTo) {
176
- const replyUrl =
177
- typeof object.inReplyTo === "string"
178
- ? object.inReplyTo
179
- : object.inReplyTo?.id;
180
- if (replyUrl) jf2["in-reply-to"] = [replyUrl];
181
- }
182
-
183
- return jf2;
184
- }
185
-
186
- /**
187
- * Extract hashtags from AP tag array.
188
- * @param {Array} tags - AP tag objects
189
- * @returns {Array<string>}
190
- */
191
- function extractTags(tags) {
192
- if (!Array.isArray(tags)) return [];
193
- return tags
194
- .filter((t) => t.type === "Hashtag" || t.type === "Tag")
195
- .map((t) => (t.name || "").replace(/^#/, ""))
196
- .filter(Boolean);
197
- }
198
-
199
- /**
200
- * Extract media URLs from AP attachment array.
201
- * @param {Array} attachments - AP attachment objects
202
- * @param {string} mediaPrefix - "image", "video", or "audio"
203
- * @returns {Array<string>}
204
- */
205
- function extractMedia(attachments, mediaPrefix) {
206
- if (!Array.isArray(attachments)) return [];
207
- return attachments
208
- .filter((a) => (a.mediaType || "").startsWith(`${mediaPrefix}/`))
209
- .map((a) => a.url || a.href || "")
210
- .filter(Boolean);
211
- }
212
-
213
- /**
214
- * Fetch a URL as ActivityPub JSON.
215
- * @param {string} url
216
- * @returns {Promise<object|null>}
217
- */
218
- async function fetchJson(url) {
219
- if (!url) return null;
220
-
221
- // SSRF protection — block private/internal IPs (including DNS rebinding)
222
- if (await isPrivateUrl(url)) {
223
- console.warn(`[Microsub] AP fetch blocked private URL: ${url}`);
224
- return null;
225
- }
226
-
227
- const controller = new AbortController();
228
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
229
-
230
- try {
231
- const response = await fetch(url, {
232
- headers: {
233
- Accept: AP_ACCEPT,
234
- "User-Agent": USER_AGENT,
235
- },
236
- signal: controller.signal,
237
- redirect: "follow",
238
- });
239
-
240
- if (!response.ok) {
241
- console.warn(
242
- `[Microsub] AP fetch failed: ${response.status} for ${url}`,
243
- );
244
- return null;
245
- }
246
-
247
- return await response.json();
248
- } catch (error) {
249
- if (error.name === "AbortError") {
250
- console.warn(`[Microsub] AP fetch timeout for ${url}`);
251
- } else {
252
- console.warn(`[Microsub] AP fetch error for ${url}: ${error.message}`);
253
- }
254
- return null;
255
- } finally {
256
- clearTimeout(timeout);
257
- }
258
- }
259
-
260
- /**
261
- * Strip HTML tags for plain text.
262
- * @param {string} html
263
- * @returns {string}
264
- */
265
- function stripHtml(html) {
266
- return (html || "").replace(/<[^>]*>/g, "").trim();
267
- }
@@ -1,142 +0,0 @@
1
- /**
2
- * ActivityPub actor profiles
3
- * @module controllers/reader/actor
4
- */
5
-
6
- import { fetchActorOutbox } from "../../activitypub/outbox-fetcher.js";
7
-
8
- const ACTOR_OUTBOX_LIMIT = 30;
9
-
10
- /**
11
- * Find the ActivityPub plugin instance from installed plugins.
12
- * @param {object} request - Express request
13
- * @returns {object|undefined} The AP plugin instance
14
- */
15
- function getApPlugin(request) {
16
- const installedPlugins = request.app.locals.installedPlugins;
17
- if (!installedPlugins) return undefined;
18
- return [...installedPlugins].find(
19
- (p) => p.name === "ActivityPub endpoint",
20
- );
21
- }
22
-
23
- /**
24
- * Actor profile — fetch and display a remote AP actor's recent posts
25
- * @param {object} request - Express request
26
- * @param {object} response - Express response
27
- */
28
- export async function actorProfile(request, response) {
29
- const actorUrl = request.query.url;
30
- if (!actorUrl) {
31
- return response.status(400).render("404");
32
- }
33
-
34
- // Check if we already follow this actor
35
- const { application } = request.app.locals;
36
- const apFollowing = application?.collections?.get("ap_following");
37
- let isFollowing = false;
38
- if (apFollowing) {
39
- const existing = await apFollowing.findOne({ actorUrl });
40
- isFollowing = !!existing;
41
- }
42
-
43
- // Check if AP plugin is available (for follow button visibility)
44
- const apPlugin = getApPlugin(request);
45
- const canFollow = !!apPlugin;
46
-
47
- try {
48
- const { actor, items } = await fetchActorOutbox(actorUrl, { limit: ACTOR_OUTBOX_LIMIT });
49
-
50
- response.render("actor", {
51
- title: actor.name || "Actor",
52
- actor,
53
- items,
54
- actorUrl,
55
- isFollowing,
56
- canFollow,
57
- baseUrl: request.baseUrl,
58
- readerBaseUrl: request.baseUrl,
59
- activeView: "channels",
60
- breadcrumbs: [
61
- { text: "Reader", href: request.baseUrl },
62
- { text: actor.name || "Actor" },
63
- ],
64
- });
65
- } catch (error) {
66
- console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
67
- response.render("actor", {
68
- title: "Actor",
69
- actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
70
- items: [],
71
- actorUrl,
72
- isFollowing,
73
- canFollow,
74
- baseUrl: request.baseUrl,
75
- readerBaseUrl: request.baseUrl,
76
- activeView: "channels",
77
- error: "Could not fetch this actor's profile. They may have restricted access.",
78
- breadcrumbs: [
79
- { text: "Reader", href: request.baseUrl },
80
- { text: "Actor" },
81
- ],
82
- });
83
- }
84
- }
85
-
86
- /**
87
- * Follow an ActivityPub actor
88
- * @param {object} request - Express request
89
- * @param {object} response - Express response
90
- */
91
- export async function followActorAction(request, response) {
92
- const { actorUrl, actorName } = request.body;
93
- if (!actorUrl) {
94
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
95
- }
96
-
97
- const apPlugin = getApPlugin(request);
98
- if (!apPlugin) {
99
- console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
100
- return response.redirect(
101
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
102
- );
103
- }
104
-
105
- const result = await apPlugin.followActor(actorUrl, { name: actorName });
106
- if (!result.ok) {
107
- console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
108
- }
109
-
110
- return response.redirect(
111
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
112
- );
113
- }
114
-
115
- /**
116
- * Unfollow an ActivityPub actor
117
- * @param {object} request - Express request
118
- * @param {object} response - Express response
119
- */
120
- export async function unfollowActorAction(request, response) {
121
- const { actorUrl } = request.body;
122
- if (!actorUrl) {
123
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
124
- }
125
-
126
- const apPlugin = getApPlugin(request);
127
- if (!apPlugin) {
128
- console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
129
- return response.redirect(
130
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
131
- );
132
- }
133
-
134
- const result = await apPlugin.unfollowActor(actorUrl);
135
- if (!result.ok) {
136
- console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
137
- }
138
-
139
- return response.redirect(
140
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
141
- );
142
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * Search indexer for MongoDB text search
3
- * @module search/indexer
4
- */
5
-
6
- /**
7
- * Create text indexes for microsub items
8
- * @param {object} application - Indiekit application
9
- * @returns {Promise<void>}
10
- */
11
- export async function createSearchIndexes(application) {
12
- const itemsCollection = application.collections.get("microsub_items");
13
-
14
- // Create compound text index for full-text search
15
- await itemsCollection.createIndex(
16
- {
17
- name: "text",
18
- "content.text": "text",
19
- "content.html": "text",
20
- summary: "text",
21
- "author.name": "text",
22
- },
23
- {
24
- name: "text_search",
25
- weights: {
26
- name: 10,
27
- "content.text": 5,
28
- summary: 3,
29
- "author.name": 2,
30
- },
31
- default_language: "english",
32
- background: true,
33
- },
34
- );
35
-
36
- // Create index for channel + published for efficient timeline queries
37
- await itemsCollection.createIndex(
38
- { channelId: 1, published: -1 },
39
- { name: "channel_timeline" },
40
- );
41
-
42
- // Create index for deduplication
43
- await itemsCollection.createIndex(
44
- { channelId: 1, uid: 1 },
45
- { name: "channel_uid", unique: true },
46
- );
47
-
48
- // Create index for feed-based queries
49
- await itemsCollection.createIndex({ feedId: 1 }, { name: "feed_items" });
50
- }
51
-
52
- /**
53
- * Rebuild search indexes (drops and recreates)
54
- * @param {object} application - Indiekit application
55
- * @returns {Promise<void>}
56
- */
57
- export async function rebuildSearchIndexes(application) {
58
- const itemsCollection = application.collections.get("microsub_items");
59
-
60
- // Drop existing text index
61
- try {
62
- await itemsCollection.dropIndex("text_search");
63
- } catch {
64
- // Index may not exist
65
- }
66
-
67
- // Recreate indexes
68
- await createSearchIndexes(application);
69
- }
70
-
71
- /**
72
- * Get search index stats
73
- * @param {object} application - Indiekit application
74
- * @returns {Promise<object>} Index statistics
75
- */
76
- export async function getSearchIndexStats(application) {
77
- const itemsCollection = application.collections.get("microsub_items");
78
-
79
- const indexes = await itemsCollection.indexes();
80
- const stats = await itemsCollection.stats();
81
-
82
- return {
83
- indexes: indexes.map((index) => ({
84
- name: index.name,
85
- key: index.key,
86
- })),
87
- totalDocuments: stats.count,
88
- size: stats.size,
89
- };
90
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Timeline item search
3
- * @module storage/items-search
4
- */
5
-
6
- import { ObjectId } from "mongodb";
7
-
8
- import { getCollection, transformToJf2 } from "./items.js";
9
-
10
- /**
11
- * Search items by text
12
- * @param {object} application - Indiekit application
13
- * @param {ObjectId|string} channelId - Channel ObjectId
14
- * @param {string} query - Search query
15
- * @param {number} [limit] - Max results
16
- * @returns {Promise<Array>} Array of matching items
17
- */
18
- export async function searchItems(application, channelId, query, limit = 20) {
19
- const collection = getCollection(application);
20
- const objectId =
21
- typeof channelId === "string" ? new ObjectId(channelId) : channelId;
22
-
23
- // Use MongoDB text index for efficient full-text search
24
- const items = await collection
25
- .find({
26
- channelId: objectId,
27
- $text: { $search: query },
28
- })
29
- .sort({ score: { $meta: "textScore" } })
30
- .limit(limit)
31
- .toArray();
32
-
33
- return items.map((item) => transformToJf2(item));
34
- }