@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.
- package/index.js +5 -7
- package/lib/controllers/reader/channel.js +41 -3
- package/lib/controllers/reader/index.js +0 -15
- package/lib/polling/scheduler.js +4 -4
- package/lib/storage/channels.js +1 -37
- package/lib/storage/items-read-state.js +30 -59
- package/lib/storage/items-retention.js +208 -99
- package/lib/utils/blogroll-notify.js +3 -3
- package/lib/utils/constants.js +7 -0
- package/lib/utils/jf2.js +0 -109
- package/lib/utils/sanitize.js +1 -2
- package/lib/utils/validation.js +25 -0
- package/lib/webmention/processor.js +2 -2
- package/lib/websub/handler.js +2 -2
- package/locales/en.json +11 -27
- package/package.json +1 -1
- package/views/partials/item-card.njk +0 -6
- package/views/settings.njk +37 -0
- package/lib/activitypub/outbox-fetcher.js +0 -267
- package/lib/controllers/reader/actor.js +0 -142
- package/lib/search/indexer.js +0 -90
- package/lib/storage/items-search.js +0 -34
- package/views/actor.njk +0 -188
|
@@ -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") }}
|
package/views/settings.njk
CHANGED
|
@@ -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
|
-
}
|
package/lib/search/indexer.js
DELETED
|
@@ -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
|
-
}
|