@rmdes/indiekit-endpoint-microsub 1.0.31 → 1.0.33
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/assets/styles.css +78 -0
- package/index.js +12 -0
- package/lib/activitypub/outbox-fetcher.js +255 -0
- package/lib/controllers/follow.js +11 -0
- package/lib/controllers/reader.js +146 -2
- package/lib/feeds/capabilities.js +204 -0
- package/lib/polling/processor.js +40 -0
- package/lib/storage/channels.js +38 -2
- package/lib/storage/items.js +92 -43
- package/package.json +1 -1
- package/views/actor.njk +179 -0
- package/views/partials/item-card.njk +25 -1
package/assets/styles.css
CHANGED
|
@@ -936,3 +936,81 @@
|
|
|
936
936
|
.feed-edit__action p {
|
|
937
937
|
margin-bottom: var(--space-s);
|
|
938
938
|
}
|
|
939
|
+
|
|
940
|
+
/* ==========================================================================
|
|
941
|
+
Actor Profile
|
|
942
|
+
========================================================================== */
|
|
943
|
+
|
|
944
|
+
.actor-profile {
|
|
945
|
+
background: var(--color-offset);
|
|
946
|
+
border-radius: var(--border-radius);
|
|
947
|
+
margin-bottom: var(--space-m);
|
|
948
|
+
padding: var(--space-m);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
.actor-profile__header {
|
|
952
|
+
align-items: flex-start;
|
|
953
|
+
display: flex;
|
|
954
|
+
gap: var(--space-m);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.actor-profile__avatar {
|
|
958
|
+
border-radius: 50%;
|
|
959
|
+
flex-shrink: 0;
|
|
960
|
+
object-fit: cover;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
.actor-profile__info {
|
|
964
|
+
flex: 1;
|
|
965
|
+
min-width: 0;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.actor-profile__name {
|
|
969
|
+
font-size: 1.25em;
|
|
970
|
+
margin: 0 0 2px;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
.actor-profile__handle {
|
|
974
|
+
color: var(--color-text-muted, #666);
|
|
975
|
+
font-size: 0.9em;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.actor-profile__summary {
|
|
979
|
+
font-size: 0.9em;
|
|
980
|
+
margin: var(--space-xs) 0 0;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.actor-profile__stats {
|
|
984
|
+
color: var(--color-text-muted, #666);
|
|
985
|
+
display: flex;
|
|
986
|
+
font-size: 0.85em;
|
|
987
|
+
gap: var(--space-m);
|
|
988
|
+
margin-top: var(--space-xs);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.actor-profile__actions {
|
|
992
|
+
display: flex;
|
|
993
|
+
gap: var(--space-s);
|
|
994
|
+
margin-top: var(--space-s);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/* ==========================================================================
|
|
998
|
+
AP Badge
|
|
999
|
+
========================================================================== */
|
|
1000
|
+
|
|
1001
|
+
.item-card__badge {
|
|
1002
|
+
border-radius: 3px;
|
|
1003
|
+
display: inline-block;
|
|
1004
|
+
font-size: 0.7em;
|
|
1005
|
+
font-weight: 600;
|
|
1006
|
+
letter-spacing: 0.02em;
|
|
1007
|
+
line-height: 1;
|
|
1008
|
+
padding: 2px 4px;
|
|
1009
|
+
text-transform: uppercase;
|
|
1010
|
+
vertical-align: middle;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.item-card__badge--ap {
|
|
1014
|
+
background: #7c3aed20;
|
|
1015
|
+
color: #7c3aed;
|
|
1016
|
+
}
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { opmlController } from "./lib/controllers/opml.js";
|
|
|
8
8
|
import { readerController } from "./lib/controllers/reader.js";
|
|
9
9
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
10
10
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
11
|
+
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
|
|
11
12
|
import { cleanupAllReadItems, createIndexes } from "./lib/storage/items.js";
|
|
12
13
|
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
13
14
|
import { websubHandler } from "./lib/websub/handler.js";
|
|
@@ -126,6 +127,9 @@ export default class MicrosubEndpoint {
|
|
|
126
127
|
readerRouter.get("/search", readerController.searchPage);
|
|
127
128
|
readerRouter.post("/search", readerController.searchFeeds);
|
|
128
129
|
readerRouter.post("/subscribe", readerController.subscribe);
|
|
130
|
+
readerRouter.get("/actor", readerController.actorProfile);
|
|
131
|
+
readerRouter.post("/actor/follow", readerController.followActorAction);
|
|
132
|
+
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
|
|
129
133
|
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
|
130
134
|
readerRouter.get("/opml", opmlController.exportOpml);
|
|
131
135
|
router.use("/reader", readerRouter);
|
|
@@ -184,6 +188,14 @@ export default class MicrosubEndpoint {
|
|
|
184
188
|
console.info("[Microsub] Database available, starting scheduler");
|
|
185
189
|
startScheduler(indiekit);
|
|
186
190
|
|
|
191
|
+
// Ensure system channels exist
|
|
192
|
+
ensureActivityPubChannel(indiekit).catch((error) => {
|
|
193
|
+
console.warn(
|
|
194
|
+
"[Microsub] ActivityPub channel creation failed:",
|
|
195
|
+
error.message,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
187
199
|
// Create indexes for optimal performance (runs in background)
|
|
188
200
|
createIndexes(indiekit).catch((error) => {
|
|
189
201
|
console.warn("[Microsub] Index creation failed:", error.message);
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
const AP_ACCEPT =
|
|
9
|
+
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
10
|
+
const FETCH_TIMEOUT = 10_000;
|
|
11
|
+
const USER_AGENT = "Indiekit/1.0 (Microsub reader)";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetch a remote actor's profile and recent posts from their outbox.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} actorUrl - Full URL of the AP actor
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {number} [options.limit=20] - Max items to return
|
|
19
|
+
* @returns {Promise<{ actor: object, items: Array }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchActorOutbox(actorUrl, options = {}) {
|
|
22
|
+
const limit = options.limit || 20;
|
|
23
|
+
|
|
24
|
+
// 1. Fetch actor profile
|
|
25
|
+
const actor = await fetchJson(actorUrl);
|
|
26
|
+
if (!actor || !actor.outbox) {
|
|
27
|
+
throw new Error("Could not resolve actor or outbox URL");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const actorInfo = {
|
|
31
|
+
name:
|
|
32
|
+
actor.name ||
|
|
33
|
+
actor.preferredUsername ||
|
|
34
|
+
new URL(actorUrl).pathname.split("/").pop(),
|
|
35
|
+
url: actor.url || actor.id || actorUrl,
|
|
36
|
+
photo: actor.icon?.url || actor.icon || "",
|
|
37
|
+
summary: stripHtml(actor.summary || ""),
|
|
38
|
+
handle: actor.preferredUsername || "",
|
|
39
|
+
followersCount: 0,
|
|
40
|
+
followingCount: 0,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Resolve follower/following counts if available
|
|
44
|
+
if (typeof actor.followers === "string") {
|
|
45
|
+
try {
|
|
46
|
+
const followersCollection = await fetchJson(actor.followers);
|
|
47
|
+
actorInfo.followersCount = followersCollection?.totalItems || 0;
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (typeof actor.following === "string") {
|
|
53
|
+
try {
|
|
54
|
+
const followingCollection = await fetchJson(actor.following);
|
|
55
|
+
actorInfo.followingCount = followingCollection?.totalItems || 0;
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Fetch outbox (OrderedCollection)
|
|
62
|
+
const outboxUrl =
|
|
63
|
+
typeof actor.outbox === "string" ? actor.outbox : actor.outbox?.id;
|
|
64
|
+
const outbox = await fetchJson(outboxUrl);
|
|
65
|
+
if (!outbox) {
|
|
66
|
+
return { actor: actorInfo, items: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Get items — may be inline or on a first page
|
|
70
|
+
let activities = [];
|
|
71
|
+
|
|
72
|
+
if (outbox.orderedItems?.length > 0) {
|
|
73
|
+
activities = outbox.orderedItems;
|
|
74
|
+
} else if (outbox.first) {
|
|
75
|
+
const firstPageUrl =
|
|
76
|
+
typeof outbox.first === "string" ? outbox.first : outbox.first?.id;
|
|
77
|
+
if (firstPageUrl) {
|
|
78
|
+
const firstPage = await fetchJson(firstPageUrl);
|
|
79
|
+
activities = firstPage?.orderedItems || firstPage?.items || [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Convert Create activities to jf2 items
|
|
84
|
+
const items = [];
|
|
85
|
+
for (const activity of activities) {
|
|
86
|
+
if (items.length >= limit) break;
|
|
87
|
+
|
|
88
|
+
const item = activityToJf2(activity, actorInfo);
|
|
89
|
+
if (item) items.push(item);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { actor: actorInfo, items };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert a single AP activity (or bare object) to jf2 format.
|
|
97
|
+
* @param {object} activity - AP activity or object
|
|
98
|
+
* @param {object} actorInfo - Actor profile info
|
|
99
|
+
* @returns {object|null} jf2 item or null if not displayable
|
|
100
|
+
*/
|
|
101
|
+
function activityToJf2(activity, actorInfo) {
|
|
102
|
+
// Unwrap Create/Announce — the displayable content is the inner object
|
|
103
|
+
let object = activity;
|
|
104
|
+
const activityType = activity.type;
|
|
105
|
+
|
|
106
|
+
if (activityType === "Create" || activityType === "Announce") {
|
|
107
|
+
object = activity.object;
|
|
108
|
+
if (!object || typeof object === "string") return null; // Unresolved reference
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Skip non-content types (Follow, Like, etc.)
|
|
112
|
+
const contentTypes = new Set([
|
|
113
|
+
"Note",
|
|
114
|
+
"Article",
|
|
115
|
+
"Page",
|
|
116
|
+
"Video",
|
|
117
|
+
"Audio",
|
|
118
|
+
"Image",
|
|
119
|
+
"Event",
|
|
120
|
+
"Question",
|
|
121
|
+
]);
|
|
122
|
+
if (!contentTypes.has(object.type)) return null;
|
|
123
|
+
|
|
124
|
+
const contentHtml = object.content || "";
|
|
125
|
+
const contentText = stripHtml(contentHtml);
|
|
126
|
+
|
|
127
|
+
const jf2 = {
|
|
128
|
+
type: "entry",
|
|
129
|
+
url: object.url || object.id || "",
|
|
130
|
+
uid: object.id || object.url || "",
|
|
131
|
+
name: object.name || undefined,
|
|
132
|
+
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
|
|
133
|
+
summary: object.summary ? stripHtml(object.summary) : undefined,
|
|
134
|
+
published: object.published || activity.published || undefined,
|
|
135
|
+
author: {
|
|
136
|
+
name: actorInfo.name,
|
|
137
|
+
url: actorInfo.url,
|
|
138
|
+
photo: actorInfo.photo,
|
|
139
|
+
},
|
|
140
|
+
category: extractTags(object.tag),
|
|
141
|
+
photo: extractMedia(object.attachment, "image"),
|
|
142
|
+
video: extractMedia(object.attachment, "video"),
|
|
143
|
+
audio: extractMedia(object.attachment, "audio"),
|
|
144
|
+
_source: { type: "activitypub", actorUrl: actorInfo.url },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Boost attribution
|
|
148
|
+
if (activityType === "Announce" && activity.actor) {
|
|
149
|
+
jf2._boostedBy = actorInfo;
|
|
150
|
+
// The inner object may have its own author
|
|
151
|
+
if (object.attributedTo) {
|
|
152
|
+
const attributedUrl =
|
|
153
|
+
typeof object.attributedTo === "string"
|
|
154
|
+
? object.attributedTo
|
|
155
|
+
: object.attributedTo?.id || object.attributedTo?.url;
|
|
156
|
+
if (attributedUrl) {
|
|
157
|
+
jf2.author = {
|
|
158
|
+
name:
|
|
159
|
+
object.attributedTo?.name ||
|
|
160
|
+
object.attributedTo?.preferredUsername ||
|
|
161
|
+
attributedUrl,
|
|
162
|
+
url: attributedUrl,
|
|
163
|
+
photo: object.attributedTo?.icon?.url || "",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (object.inReplyTo) {
|
|
170
|
+
const replyUrl =
|
|
171
|
+
typeof object.inReplyTo === "string"
|
|
172
|
+
? object.inReplyTo
|
|
173
|
+
: object.inReplyTo?.id;
|
|
174
|
+
if (replyUrl) jf2["in-reply-to"] = [replyUrl];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return jf2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract hashtags from AP tag array.
|
|
182
|
+
* @param {Array} tags - AP tag objects
|
|
183
|
+
* @returns {Array<string>}
|
|
184
|
+
*/
|
|
185
|
+
function extractTags(tags) {
|
|
186
|
+
if (!Array.isArray(tags)) return [];
|
|
187
|
+
return tags
|
|
188
|
+
.filter((t) => t.type === "Hashtag" || t.type === "Tag")
|
|
189
|
+
.map((t) => (t.name || "").replace(/^#/, ""))
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Extract media URLs from AP attachment array.
|
|
195
|
+
* @param {Array} attachments - AP attachment objects
|
|
196
|
+
* @param {string} mediaPrefix - "image", "video", or "audio"
|
|
197
|
+
* @returns {Array<string>}
|
|
198
|
+
*/
|
|
199
|
+
function extractMedia(attachments, mediaPrefix) {
|
|
200
|
+
if (!Array.isArray(attachments)) return [];
|
|
201
|
+
return attachments
|
|
202
|
+
.filter((a) => (a.mediaType || "").startsWith(`${mediaPrefix}/`))
|
|
203
|
+
.map((a) => a.url || a.href || "")
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fetch a URL as ActivityPub JSON.
|
|
209
|
+
* @param {string} url
|
|
210
|
+
* @returns {Promise<object|null>}
|
|
211
|
+
*/
|
|
212
|
+
async function fetchJson(url) {
|
|
213
|
+
if (!url) return null;
|
|
214
|
+
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetch(url, {
|
|
220
|
+
headers: {
|
|
221
|
+
Accept: AP_ACCEPT,
|
|
222
|
+
"User-Agent": USER_AGENT,
|
|
223
|
+
},
|
|
224
|
+
signal: controller.signal,
|
|
225
|
+
redirect: "follow",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
console.warn(
|
|
230
|
+
`[Microsub] AP fetch failed: ${response.status} for ${url}`,
|
|
231
|
+
);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return await response.json();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.name === "AbortError") {
|
|
238
|
+
console.warn(`[Microsub] AP fetch timeout for ${url}`);
|
|
239
|
+
} else {
|
|
240
|
+
console.warn(`[Microsub] AP fetch error for ${url}: ${error.message}`);
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(timeout);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Strip HTML tags for plain text.
|
|
250
|
+
* @param {string} html
|
|
251
|
+
* @returns {string}
|
|
252
|
+
*/
|
|
253
|
+
function stripHtml(html) {
|
|
254
|
+
return (html || "").replace(/<[^>]*>/g, "").trim();
|
|
255
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { IndiekitError } from "@indiekit/error";
|
|
7
7
|
|
|
8
|
+
import { detectCapabilities } from "../feeds/capabilities.js";
|
|
8
9
|
import { refreshFeedNow } from "../polling/scheduler.js";
|
|
9
10
|
import { getChannel } from "../storage/channels.js";
|
|
10
11
|
import {
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
deleteFeed,
|
|
13
14
|
getFeedByUrl,
|
|
14
15
|
getFeedsForChannel,
|
|
16
|
+
updateFeed,
|
|
15
17
|
} from "../storage/feeds.js";
|
|
16
18
|
import { getUserId } from "../utils/auth.js";
|
|
17
19
|
import { notifyBlogroll } from "../utils/blogroll-notify.js";
|
|
@@ -79,6 +81,15 @@ export async function follow(request, response) {
|
|
|
79
81
|
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
80
82
|
});
|
|
81
83
|
|
|
84
|
+
// Detect source capabilities (fire-and-forget)
|
|
85
|
+
detectCapabilities(url).then((capabilities) => {
|
|
86
|
+
updateFeed(application, feed._id, { capabilities }).catch((error) => {
|
|
87
|
+
console.error(`[Microsub] Capability storage error:`, error.message);
|
|
88
|
+
});
|
|
89
|
+
}).catch((error) => {
|
|
90
|
+
console.error(`[Microsub] Capability detection error for ${url}:`, error.message);
|
|
91
|
+
});
|
|
92
|
+
|
|
82
93
|
// Notify blogroll plugin (fire-and-forget)
|
|
83
94
|
notifyBlogroll(application, "follow", {
|
|
84
95
|
url,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
markItemsRead,
|
|
29
29
|
countReadItems,
|
|
30
30
|
} from "../storage/items.js";
|
|
31
|
+
import { fetchActorOutbox } from "../activitypub/outbox-fetcher.js";
|
|
31
32
|
import { getUserId } from "../utils/auth.js";
|
|
32
33
|
import {
|
|
33
34
|
validateChannelName,
|
|
@@ -207,8 +208,8 @@ export async function deleteChannelAction(request, response) {
|
|
|
207
208
|
const userId = getUserId(request);
|
|
208
209
|
const { uid } = request.params;
|
|
209
210
|
|
|
210
|
-
// Don't allow deleting
|
|
211
|
-
if (uid === "notifications") {
|
|
211
|
+
// Don't allow deleting system channels
|
|
212
|
+
if (uid === "notifications" || uid === "activitypub") {
|
|
212
213
|
return response.redirect(`${request.baseUrl}/channels`);
|
|
213
214
|
}
|
|
214
215
|
|
|
@@ -346,6 +347,20 @@ function ensureString(value) {
|
|
|
346
347
|
return String(value);
|
|
347
348
|
}
|
|
348
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Detect the protocol of a URL for auto-syndication targeting
|
|
352
|
+
* @param {string} url - URL to classify
|
|
353
|
+
* @returns {string} "atmosphere" | "fediverse" | "web"
|
|
354
|
+
*/
|
|
355
|
+
function detectProtocol(url) {
|
|
356
|
+
if (!url || typeof url !== "string") return "web";
|
|
357
|
+
const lower = url.toLowerCase();
|
|
358
|
+
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
|
|
359
|
+
if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
|
|
360
|
+
lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse";
|
|
361
|
+
return "web";
|
|
362
|
+
}
|
|
363
|
+
|
|
349
364
|
/**
|
|
350
365
|
* Fetch syndication targets from Micropub config
|
|
351
366
|
* @param {object} application - Indiekit application
|
|
@@ -405,6 +420,20 @@ export async function compose(request, response) {
|
|
|
405
420
|
? await getSyndicationTargets(application, token)
|
|
406
421
|
: [];
|
|
407
422
|
|
|
423
|
+
// Auto-select syndication target based on interaction URL protocol
|
|
424
|
+
const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
|
|
425
|
+
if (interactionUrl && syndicationTargets.length > 0) {
|
|
426
|
+
const protocol = detectProtocol(interactionUrl);
|
|
427
|
+
for (const target of syndicationTargets) {
|
|
428
|
+
const targetId = (target.uid || target.name || "").toLowerCase();
|
|
429
|
+
if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
|
|
430
|
+
target.checked = true;
|
|
431
|
+
} else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
|
|
432
|
+
target.checked = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
408
437
|
response.render("compose", {
|
|
409
438
|
title: request.__("microsub.compose.title"),
|
|
410
439
|
replyTo: ensureString(replyTo || reply),
|
|
@@ -909,6 +938,118 @@ export async function refreshFeed(request, response) {
|
|
|
909
938
|
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
910
939
|
}
|
|
911
940
|
|
|
941
|
+
/**
|
|
942
|
+
* Actor profile — fetch and display a remote AP actor's recent posts
|
|
943
|
+
* @param {object} request - Express request
|
|
944
|
+
* @param {object} response - Express response
|
|
945
|
+
*/
|
|
946
|
+
/**
|
|
947
|
+
* Find the ActivityPub plugin instance from installed plugins.
|
|
948
|
+
* @param {object} request - Express request
|
|
949
|
+
* @returns {object|undefined} The AP plugin instance
|
|
950
|
+
*/
|
|
951
|
+
function getApPlugin(request) {
|
|
952
|
+
const installedPlugins = request.app.locals.installedPlugins;
|
|
953
|
+
if (!installedPlugins) return undefined;
|
|
954
|
+
return [...installedPlugins].find(
|
|
955
|
+
(p) => p.name === "ActivityPub endpoint",
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export async function actorProfile(request, response) {
|
|
960
|
+
const actorUrl = request.query.url;
|
|
961
|
+
if (!actorUrl) {
|
|
962
|
+
return response.status(400).render("404");
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Check if we already follow this actor
|
|
966
|
+
const { application } = request.app.locals;
|
|
967
|
+
const apFollowing = application?.collections?.get("ap_following");
|
|
968
|
+
let isFollowing = false;
|
|
969
|
+
if (apFollowing) {
|
|
970
|
+
const existing = await apFollowing.findOne({ actorUrl });
|
|
971
|
+
isFollowing = !!existing;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Check if AP plugin is available (for follow button visibility)
|
|
975
|
+
const apPlugin = getApPlugin(request);
|
|
976
|
+
const canFollow = !!apPlugin;
|
|
977
|
+
|
|
978
|
+
try {
|
|
979
|
+
const { actor, items } = await fetchActorOutbox(actorUrl, { limit: 30 });
|
|
980
|
+
|
|
981
|
+
response.render("actor", {
|
|
982
|
+
title: actor.name || "Actor",
|
|
983
|
+
actor,
|
|
984
|
+
items,
|
|
985
|
+
actorUrl,
|
|
986
|
+
isFollowing,
|
|
987
|
+
canFollow,
|
|
988
|
+
baseUrl: request.baseUrl,
|
|
989
|
+
});
|
|
990
|
+
} catch (error) {
|
|
991
|
+
console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
|
|
992
|
+
response.render("actor", {
|
|
993
|
+
title: "Actor",
|
|
994
|
+
actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
|
|
995
|
+
items: [],
|
|
996
|
+
actorUrl,
|
|
997
|
+
isFollowing,
|
|
998
|
+
canFollow,
|
|
999
|
+
baseUrl: request.baseUrl,
|
|
1000
|
+
error: "Could not fetch this actor's profile. They may have restricted access.",
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
export async function followActorAction(request, response) {
|
|
1006
|
+
const { actorUrl, actorName } = request.body;
|
|
1007
|
+
if (!actorUrl) {
|
|
1008
|
+
return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const apPlugin = getApPlugin(request);
|
|
1012
|
+
if (!apPlugin) {
|
|
1013
|
+
console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
|
|
1014
|
+
return response.redirect(
|
|
1015
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const result = await apPlugin.followActor(actorUrl, { name: actorName });
|
|
1020
|
+
if (!result.ok) {
|
|
1021
|
+
console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return response.redirect(
|
|
1025
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export async function unfollowActorAction(request, response) {
|
|
1030
|
+
const { actorUrl } = request.body;
|
|
1031
|
+
if (!actorUrl) {
|
|
1032
|
+
return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const apPlugin = getApPlugin(request);
|
|
1036
|
+
if (!apPlugin) {
|
|
1037
|
+
console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
|
|
1038
|
+
return response.redirect(
|
|
1039
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const result = await apPlugin.unfollowActor(actorUrl);
|
|
1044
|
+
if (!result.ok) {
|
|
1045
|
+
console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return response.redirect(
|
|
1049
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
912
1053
|
export const readerController = {
|
|
913
1054
|
index,
|
|
914
1055
|
channels,
|
|
@@ -933,4 +1074,7 @@ export const readerController = {
|
|
|
933
1074
|
searchPage,
|
|
934
1075
|
searchFeeds,
|
|
935
1076
|
subscribe,
|
|
1077
|
+
actorProfile,
|
|
1078
|
+
followActorAction,
|
|
1079
|
+
unfollowActorAction,
|
|
936
1080
|
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source capability detection
|
|
3
|
+
* Detects what a feed source supports (webmention, micropub, platform API)
|
|
4
|
+
* @module feeds/capabilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Known Fediverse domain patterns
|
|
9
|
+
*/
|
|
10
|
+
const FEDIVERSE_PATTERNS = [
|
|
11
|
+
"mastodon.",
|
|
12
|
+
"mstdn.",
|
|
13
|
+
"fosstodon.",
|
|
14
|
+
"pleroma.",
|
|
15
|
+
"misskey.",
|
|
16
|
+
"pixelfed.",
|
|
17
|
+
"fediverse",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect the capabilities of a feed source
|
|
22
|
+
* @param {string} feedUrl - The feed URL
|
|
23
|
+
* @param {string} [siteUrl] - Optional site homepage URL (if different from feed)
|
|
24
|
+
* @returns {Promise<object>} Capability profile
|
|
25
|
+
*/
|
|
26
|
+
export async function detectCapabilities(feedUrl, siteUrl) {
|
|
27
|
+
const result = {
|
|
28
|
+
source_type: "publication",
|
|
29
|
+
webmention: null,
|
|
30
|
+
micropub: null,
|
|
31
|
+
platform_api: null,
|
|
32
|
+
author_mode: "single",
|
|
33
|
+
interactions: [],
|
|
34
|
+
detected_at: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// 1. Pattern-match feed URL for known platforms
|
|
39
|
+
const platformMatch = matchPlatform(feedUrl);
|
|
40
|
+
if (platformMatch) {
|
|
41
|
+
result.source_type = platformMatch.type;
|
|
42
|
+
result.platform_api = platformMatch.api;
|
|
43
|
+
result.interactions = platformMatch.interactions;
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Fetch site homepage and check for rel links
|
|
48
|
+
const homepageUrl = siteUrl || deriveHomepage(feedUrl);
|
|
49
|
+
if (homepageUrl) {
|
|
50
|
+
const endpoints = await discoverEndpoints(homepageUrl);
|
|
51
|
+
result.webmention = endpoints.webmention;
|
|
52
|
+
result.micropub = endpoints.micropub;
|
|
53
|
+
|
|
54
|
+
if (endpoints.webmention && endpoints.micropub) {
|
|
55
|
+
result.source_type = "indieweb";
|
|
56
|
+
result.interactions = ["reply", "like", "repost", "bookmark"];
|
|
57
|
+
} else if (endpoints.webmention) {
|
|
58
|
+
result.source_type = "web";
|
|
59
|
+
result.interactions = ["reply"];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(
|
|
64
|
+
`[Microsub] Capability detection failed for ${feedUrl}:`,
|
|
65
|
+
error.message,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pattern-match a feed URL against known platforms
|
|
74
|
+
* @param {string} url - Feed URL
|
|
75
|
+
* @returns {object|null} Platform match or null
|
|
76
|
+
*/
|
|
77
|
+
function matchPlatform(url) {
|
|
78
|
+
const lower = url.toLowerCase();
|
|
79
|
+
|
|
80
|
+
// Bluesky
|
|
81
|
+
if (lower.includes("bsky.app") || lower.includes("bluesky")) {
|
|
82
|
+
return {
|
|
83
|
+
type: "bluesky",
|
|
84
|
+
api: { type: "atproto", authed: false },
|
|
85
|
+
interactions: ["reply", "like", "repost"],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Mastodon / Fediverse RSS (e.g., mastodon.social/@user.rss)
|
|
90
|
+
if (FEDIVERSE_PATTERNS.some((pattern) => lower.includes(pattern))) {
|
|
91
|
+
return {
|
|
92
|
+
type: "mastodon",
|
|
93
|
+
api: { type: "activitypub", authed: false },
|
|
94
|
+
interactions: ["reply", "like", "repost"],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// WordPress (common RSS patterns)
|
|
99
|
+
if (lower.includes("/wp-json/") || lower.includes("/feed/")) {
|
|
100
|
+
// Could be WordPress but also others — don't match too broadly
|
|
101
|
+
// Only match the /wp-json/ pattern which is WordPress-specific
|
|
102
|
+
if (lower.includes("/wp-json/")) {
|
|
103
|
+
return {
|
|
104
|
+
type: "wordpress",
|
|
105
|
+
api: { type: "wp-rest", authed: false },
|
|
106
|
+
interactions: ["reply"],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Derive a homepage URL from a feed URL
|
|
116
|
+
* @param {string} feedUrl - Feed URL
|
|
117
|
+
* @returns {string|null} Homepage URL
|
|
118
|
+
*/
|
|
119
|
+
function deriveHomepage(feedUrl) {
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(feedUrl);
|
|
122
|
+
return `${url.protocol}//${url.host}/`;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Discover webmention and micropub endpoints from a URL
|
|
130
|
+
* @param {string} url - URL to check for endpoint links
|
|
131
|
+
* @returns {Promise<object>} Discovered endpoints
|
|
132
|
+
*/
|
|
133
|
+
async function discoverEndpoints(url) {
|
|
134
|
+
const endpoints = {
|
|
135
|
+
webmention: null,
|
|
136
|
+
micropub: null,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(url, {
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
headers: {
|
|
146
|
+
Accept: "text/html",
|
|
147
|
+
"User-Agent": "Microsub/1.0 (+https://indieweb.org/Microsub)",
|
|
148
|
+
},
|
|
149
|
+
redirect: "follow",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) return endpoints;
|
|
153
|
+
|
|
154
|
+
// Check Link headers first
|
|
155
|
+
const linkHeader = response.headers.get("link");
|
|
156
|
+
if (linkHeader) {
|
|
157
|
+
const wmMatch = linkHeader.match(
|
|
158
|
+
/<([^>]+)>;\s*rel="?webmention"?/i,
|
|
159
|
+
);
|
|
160
|
+
if (wmMatch) endpoints.webmention = wmMatch[1];
|
|
161
|
+
|
|
162
|
+
const mpMatch = linkHeader.match(
|
|
163
|
+
/<([^>]+)>;\s*rel="?micropub"?/i,
|
|
164
|
+
);
|
|
165
|
+
if (mpMatch) endpoints.micropub = mpMatch[1];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If not found in headers, check HTML
|
|
169
|
+
if (!endpoints.webmention || !endpoints.micropub) {
|
|
170
|
+
const html = await response.text();
|
|
171
|
+
|
|
172
|
+
if (!endpoints.webmention) {
|
|
173
|
+
const wmHtml = html.match(
|
|
174
|
+
/<link[^>]+rel="?webmention"?[^>]+href="([^"]+)"/i,
|
|
175
|
+
) ||
|
|
176
|
+
html.match(
|
|
177
|
+
/<link[^>]+href="([^"]+)"[^>]+rel="?webmention"?/i,
|
|
178
|
+
);
|
|
179
|
+
if (wmHtml) endpoints.webmention = wmHtml[1];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!endpoints.micropub) {
|
|
183
|
+
const mpHtml = html.match(
|
|
184
|
+
/<link[^>]+rel="?micropub"?[^>]+href="([^"]+)"/i,
|
|
185
|
+
) ||
|
|
186
|
+
html.match(
|
|
187
|
+
/<link[^>]+href="([^"]+)"[^>]+rel="?micropub"?/i,
|
|
188
|
+
);
|
|
189
|
+
if (mpHtml) endpoints.micropub = mpHtml[1];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.name !== "AbortError") {
|
|
194
|
+
console.debug(
|
|
195
|
+
`[Microsub] Endpoint discovery failed for ${url}:`,
|
|
196
|
+
error.message,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return endpoints;
|
|
204
|
+
}
|
package/lib/polling/processor.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
7
|
+
import { detectCapabilities } from "../feeds/capabilities.js";
|
|
7
8
|
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
|
8
9
|
import { getChannel } from "../storage/channels.js";
|
|
9
10
|
import {
|
|
11
|
+
updateFeed,
|
|
10
12
|
updateFeedAfterFetch,
|
|
11
13
|
updateFeedStatus,
|
|
12
14
|
updateFeedWebsub,
|
|
@@ -82,6 +84,15 @@ export async function processFeed(application, feed) {
|
|
|
82
84
|
item._source.name = feed.title || parsed.name;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Attach source_type from feed capabilities (for protocol indicators)
|
|
88
|
+
// Falls back to URL-based inference when capabilities haven't been detected yet
|
|
89
|
+
item._source = item._source || {};
|
|
90
|
+
if (feed.capabilities?.source_type) {
|
|
91
|
+
item._source.source_type = feed.capabilities.source_type;
|
|
92
|
+
} else {
|
|
93
|
+
item._source.source_type = inferSourceType(feed.url);
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
// Store the item
|
|
86
97
|
const stored = await addItem(application, {
|
|
87
98
|
channelId: feed.channelId,
|
|
@@ -177,6 +188,20 @@ export async function processFeed(application, feed) {
|
|
|
177
188
|
success: true,
|
|
178
189
|
itemCount: parsed.items?.length || 0,
|
|
179
190
|
});
|
|
191
|
+
|
|
192
|
+
// Detect source capabilities on first successful fetch (if not yet detected)
|
|
193
|
+
if (!feed.capabilities) {
|
|
194
|
+
detectCapabilities(feed.url)
|
|
195
|
+
.then((capabilities) => {
|
|
196
|
+
updateFeed(application, feed._id, { capabilities }).catch(() => {});
|
|
197
|
+
})
|
|
198
|
+
.catch((error) => {
|
|
199
|
+
console.debug(
|
|
200
|
+
`[Microsub] Capability detection skipped for ${feed.url}:`,
|
|
201
|
+
error.message,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
180
205
|
} catch (error) {
|
|
181
206
|
result.error = error.message;
|
|
182
207
|
|
|
@@ -208,6 +233,21 @@ export async function processFeed(application, feed) {
|
|
|
208
233
|
return result;
|
|
209
234
|
}
|
|
210
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Infer source type from feed URL when capabilities haven't been detected yet
|
|
238
|
+
* @param {string} url - Feed URL
|
|
239
|
+
* @returns {string} Source type
|
|
240
|
+
*/
|
|
241
|
+
function inferSourceType(url) {
|
|
242
|
+
if (!url) return "web";
|
|
243
|
+
const lower = url.toLowerCase();
|
|
244
|
+
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "bluesky";
|
|
245
|
+
if (lower.includes("mastodon.") || lower.includes("mstdn.") ||
|
|
246
|
+
lower.includes("fosstodon.") || lower.includes("pleroma.") ||
|
|
247
|
+
lower.includes("misskey.") || lower.includes("pixelfed.")) return "mastodon";
|
|
248
|
+
return "web";
|
|
249
|
+
}
|
|
250
|
+
|
|
211
251
|
/**
|
|
212
252
|
* Check if an item passes channel filters
|
|
213
253
|
* @param {object} item - Feed item
|
package/lib/storage/channels.js
CHANGED
|
@@ -207,8 +207,8 @@ export async function deleteChannel(application, uid, userId) {
|
|
|
207
207
|
const query = { uid };
|
|
208
208
|
if (userId) query.userId = userId;
|
|
209
209
|
|
|
210
|
-
// Don't allow deleting
|
|
211
|
-
if (uid === "notifications") {
|
|
210
|
+
// Don't allow deleting system channels
|
|
211
|
+
if (uid === "notifications" || uid === "activitypub") {
|
|
212
212
|
return false;
|
|
213
213
|
}
|
|
214
214
|
|
|
@@ -306,3 +306,39 @@ export async function ensureNotificationsChannel(application, userId) {
|
|
|
306
306
|
await collection.insertOne(channel);
|
|
307
307
|
return channel;
|
|
308
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Ensure ActivityPub channel exists
|
|
312
|
+
* @param {object} application - Indiekit application
|
|
313
|
+
* @param {string} [userId] - User ID
|
|
314
|
+
* @returns {Promise<object>} ActivityPub channel
|
|
315
|
+
*/
|
|
316
|
+
export async function ensureActivityPubChannel(application, userId) {
|
|
317
|
+
const collection = getCollection(application);
|
|
318
|
+
|
|
319
|
+
const existing = await collection.findOne({
|
|
320
|
+
uid: "activitypub",
|
|
321
|
+
...(userId && { userId }),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (existing) {
|
|
325
|
+
return existing;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const channel = {
|
|
329
|
+
uid: "activitypub",
|
|
330
|
+
name: "Fediverse",
|
|
331
|
+
userId,
|
|
332
|
+
source: "activitypub",
|
|
333
|
+
order: -0.5, // After notifications (-1), before user channels (0+)
|
|
334
|
+
settings: {
|
|
335
|
+
excludeTypes: [],
|
|
336
|
+
excludeRegex: undefined,
|
|
337
|
+
},
|
|
338
|
+
createdAt: new Date().toISOString(),
|
|
339
|
+
updatedAt: new Date().toISOString(),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
await collection.insertOne(channel);
|
|
343
|
+
return channel;
|
|
344
|
+
}
|
package/lib/storage/items.js
CHANGED
|
@@ -298,6 +298,10 @@ const MAX_FULL_READ_ITEMS = 200;
|
|
|
298
298
|
* Cleanup old read items by stripping content but preserving dedup skeletons.
|
|
299
299
|
* This prevents the vicious cycle where deleted read items get re-ingested as
|
|
300
300
|
* unread by the poller because the dedup record (channelId + uid) was destroyed.
|
|
301
|
+
*
|
|
302
|
+
* AP items (feedId: null) are hard-deleted instead of stripped, since no poller
|
|
303
|
+
* re-ingests them — they arrive via inbox push and don't need dedup skeletons.
|
|
304
|
+
*
|
|
301
305
|
* @param {object} collection - MongoDB collection
|
|
302
306
|
* @param {ObjectId} channelObjectId - Channel ObjectId
|
|
303
307
|
* @param {string} userId - User ID
|
|
@@ -310,22 +314,44 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
|
310
314
|
|
|
311
315
|
if (readCount > MAX_FULL_READ_ITEMS) {
|
|
312
316
|
// Find old read items beyond the retention limit
|
|
313
|
-
const
|
|
317
|
+
const itemsToCleanup = await collection
|
|
314
318
|
.find({
|
|
315
319
|
channelId: channelObjectId,
|
|
316
320
|
readBy: userId,
|
|
317
|
-
_stripped: { $ne: true },
|
|
321
|
+
_stripped: { $ne: true },
|
|
318
322
|
})
|
|
319
323
|
.sort({ published: -1, _id: -1 })
|
|
320
324
|
.skip(MAX_FULL_READ_ITEMS)
|
|
321
|
-
.project({ _id: 1 })
|
|
325
|
+
.project({ _id: 1, feedId: 1 })
|
|
322
326
|
.toArray();
|
|
323
327
|
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
if (itemsToCleanup.length === 0) return;
|
|
329
|
+
|
|
330
|
+
// Separate AP items (feedId: null) from RSS items (feedId: ObjectId)
|
|
331
|
+
const apItemIds = [];
|
|
332
|
+
const rssItemIds = [];
|
|
333
|
+
for (const item of itemsToCleanup) {
|
|
334
|
+
if (item.feedId) {
|
|
335
|
+
rssItemIds.push(item._id);
|
|
336
|
+
} else {
|
|
337
|
+
apItemIds.push(item._id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Hard-delete AP items — no poller to re-ingest, skeletons are useless
|
|
342
|
+
if (apItemIds.length > 0) {
|
|
343
|
+
const deleted = await collection.deleteMany({
|
|
344
|
+
_id: { $in: apItemIds },
|
|
345
|
+
});
|
|
346
|
+
console.info(
|
|
347
|
+
`[Microsub] Deleted ${deleted.deletedCount} old AP read items`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Strip RSS items to dedup skeletons — poller would re-ingest if deleted
|
|
352
|
+
if (rssItemIds.length > 0) {
|
|
353
|
+
const stripped = await collection.updateMany(
|
|
354
|
+
{ _id: { $in: rssItemIds } },
|
|
329
355
|
{
|
|
330
356
|
$set: { _stripped: true },
|
|
331
357
|
$unset: {
|
|
@@ -346,7 +372,7 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
|
346
372
|
},
|
|
347
373
|
);
|
|
348
374
|
console.info(
|
|
349
|
-
`[Microsub] Stripped
|
|
375
|
+
`[Microsub] Stripped ${stripped.modifiedCount} old RSS read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
|
|
350
376
|
);
|
|
351
377
|
}
|
|
352
378
|
}
|
|
@@ -354,16 +380,16 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
|
354
380
|
|
|
355
381
|
/**
|
|
356
382
|
* Cleanup all read items across all channels (startup cleanup).
|
|
357
|
-
*
|
|
383
|
+
* RSS items are stripped to dedup skeletons; AP items are hard-deleted.
|
|
358
384
|
* @param {object} application - Indiekit application
|
|
359
|
-
* @returns {Promise<number>} Total number of items
|
|
385
|
+
* @returns {Promise<number>} Total number of items cleaned up
|
|
360
386
|
*/
|
|
361
387
|
export async function cleanupAllReadItems(application) {
|
|
362
388
|
const collection = getCollection(application);
|
|
363
389
|
const channelsCollection = application.collections.get("microsub_channels");
|
|
364
390
|
|
|
365
391
|
const channels = await channelsCollection.find({}).toArray();
|
|
366
|
-
let
|
|
392
|
+
let totalCleaned = 0;
|
|
367
393
|
|
|
368
394
|
for (const channel of channels) {
|
|
369
395
|
const readByUsers = await collection.distinct("readBy", {
|
|
@@ -381,7 +407,7 @@ export async function cleanupAllReadItems(application) {
|
|
|
381
407
|
});
|
|
382
408
|
|
|
383
409
|
if (readCount > MAX_FULL_READ_ITEMS) {
|
|
384
|
-
const
|
|
410
|
+
const itemsToCleanup = await collection
|
|
385
411
|
.find({
|
|
386
412
|
channelId: channel._id,
|
|
387
413
|
readBy: userId,
|
|
@@ -389,48 +415,71 @@ export async function cleanupAllReadItems(application) {
|
|
|
389
415
|
})
|
|
390
416
|
.sort({ published: -1, _id: -1 })
|
|
391
417
|
.skip(MAX_FULL_READ_ITEMS)
|
|
392
|
-
.project({ _id: 1 })
|
|
418
|
+
.project({ _id: 1, feedId: 1 })
|
|
393
419
|
.toArray();
|
|
394
420
|
|
|
395
|
-
if (
|
|
396
|
-
const
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
{
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
421
|
+
if (itemsToCleanup.length > 0) {
|
|
422
|
+
const apItemIds = [];
|
|
423
|
+
const rssItemIds = [];
|
|
424
|
+
for (const item of itemsToCleanup) {
|
|
425
|
+
if (item.feedId) {
|
|
426
|
+
rssItemIds.push(item._id);
|
|
427
|
+
} else {
|
|
428
|
+
apItemIds.push(item._id);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Hard-delete AP items
|
|
433
|
+
if (apItemIds.length > 0) {
|
|
434
|
+
const deleted = await collection.deleteMany({
|
|
435
|
+
_id: { $in: apItemIds },
|
|
436
|
+
});
|
|
437
|
+
totalCleaned += deleted.deletedCount;
|
|
438
|
+
console.info(
|
|
439
|
+
`[Microsub] Startup cleanup: deleted ${deleted.deletedCount} AP items from channel "${channel.name}"`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Strip RSS items to skeletons
|
|
444
|
+
if (rssItemIds.length > 0) {
|
|
445
|
+
const stripped = await collection.updateMany(
|
|
446
|
+
{ _id: { $in: rssItemIds } },
|
|
447
|
+
{
|
|
448
|
+
$set: { _stripped: true },
|
|
449
|
+
$unset: {
|
|
450
|
+
name: "",
|
|
451
|
+
content: "",
|
|
452
|
+
summary: "",
|
|
453
|
+
author: "",
|
|
454
|
+
category: "",
|
|
455
|
+
photo: "",
|
|
456
|
+
video: "",
|
|
457
|
+
audio: "",
|
|
458
|
+
likeOf: "",
|
|
459
|
+
repostOf: "",
|
|
460
|
+
bookmarkOf: "",
|
|
461
|
+
inReplyTo: "",
|
|
462
|
+
source: "",
|
|
463
|
+
},
|
|
415
464
|
},
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
465
|
+
);
|
|
466
|
+
totalCleaned += stripped.modifiedCount;
|
|
467
|
+
console.info(
|
|
468
|
+
`[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} RSS items from channel "${channel.name}"`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
422
471
|
}
|
|
423
472
|
}
|
|
424
473
|
}
|
|
425
474
|
}
|
|
426
475
|
|
|
427
|
-
if (
|
|
476
|
+
if (totalCleaned > 0) {
|
|
428
477
|
console.info(
|
|
429
|
-
`[Microsub] Startup cleanup complete: ${
|
|
478
|
+
`[Microsub] Startup cleanup complete: ${totalCleaned} total items cleaned`,
|
|
430
479
|
);
|
|
431
480
|
}
|
|
432
481
|
|
|
433
|
-
return
|
|
482
|
+
return totalCleaned;
|
|
434
483
|
}
|
|
435
484
|
|
|
436
485
|
export async function markItemsRead(application, channelId, entryIds, userId) {
|
package/package.json
CHANGED
package/views/actor.njk
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
{% extends "layouts/reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block reader %}
|
|
4
|
+
<div class="channel">
|
|
5
|
+
<header class="channel__header">
|
|
6
|
+
<a href="{{ baseUrl }}/channels/activitypub" class="back-link">
|
|
7
|
+
{{ icon("previous") }} Fediverse
|
|
8
|
+
</a>
|
|
9
|
+
</header>
|
|
10
|
+
|
|
11
|
+
{# Actor profile card #}
|
|
12
|
+
<div class="actor-profile">
|
|
13
|
+
<div class="actor-profile__header">
|
|
14
|
+
{% if actor.photo %}
|
|
15
|
+
<img src="{{ actor.photo }}"
|
|
16
|
+
alt=""
|
|
17
|
+
class="actor-profile__avatar"
|
|
18
|
+
width="80"
|
|
19
|
+
height="80"
|
|
20
|
+
onerror="this.style.display='none'">
|
|
21
|
+
{% endif %}
|
|
22
|
+
<div class="actor-profile__info">
|
|
23
|
+
<h2 class="actor-profile__name">{{ actor.name }}</h2>
|
|
24
|
+
{% if actor.handle %}
|
|
25
|
+
<span class="actor-profile__handle">@{{ actor.handle }}</span>
|
|
26
|
+
{% endif %}
|
|
27
|
+
{% if actor.summary %}
|
|
28
|
+
<p class="actor-profile__summary">{{ actor.summary }}</p>
|
|
29
|
+
{% endif %}
|
|
30
|
+
<div class="actor-profile__stats">
|
|
31
|
+
{% if actor.followersCount %}
|
|
32
|
+
<span>{{ actor.followersCount }} followers</span>
|
|
33
|
+
{% endif %}
|
|
34
|
+
{% if actor.followingCount %}
|
|
35
|
+
<span>{{ actor.followingCount }} following</span>
|
|
36
|
+
{% endif %}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="actor-profile__actions">
|
|
41
|
+
<a href="{{ actor.url }}" class="button button--secondary button--small" target="_blank" rel="noopener">
|
|
42
|
+
{{ icon("external") }} View profile
|
|
43
|
+
</a>
|
|
44
|
+
{% if canFollow %}
|
|
45
|
+
{% if isFollowing %}
|
|
46
|
+
<form action="{{ baseUrl }}/actor/unfollow" method="POST" style="display: inline;">
|
|
47
|
+
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
48
|
+
<button type="submit" class="button button--secondary button--small">
|
|
49
|
+
{{ icon("checkboxChecked") }} Following
|
|
50
|
+
</button>
|
|
51
|
+
</form>
|
|
52
|
+
{% else %}
|
|
53
|
+
<form action="{{ baseUrl }}/actor/follow" method="POST" style="display: inline;">
|
|
54
|
+
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
55
|
+
<input type="hidden" name="actorName" value="{{ actor.name }}">
|
|
56
|
+
<button type="submit" class="button button--primary button--small">
|
|
57
|
+
{{ icon("syndicate") }} Follow
|
|
58
|
+
</button>
|
|
59
|
+
</form>
|
|
60
|
+
{% endif %}
|
|
61
|
+
{% endif %}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{% if error %}
|
|
66
|
+
<div class="reader__empty">
|
|
67
|
+
{{ icon("warning") }}
|
|
68
|
+
<p>{{ error }}</p>
|
|
69
|
+
</div>
|
|
70
|
+
{% elif items.length > 0 %}
|
|
71
|
+
<div class="timeline" id="timeline">
|
|
72
|
+
{% for item in items %}
|
|
73
|
+
<article class="item-card">
|
|
74
|
+
{# Author #}
|
|
75
|
+
{% if item.author %}
|
|
76
|
+
<div class="item-card__author" style="padding: 12px 16px 0;">
|
|
77
|
+
{% if item.author.photo %}
|
|
78
|
+
<img src="{{ item.author.photo }}"
|
|
79
|
+
alt=""
|
|
80
|
+
class="item-card__author-photo"
|
|
81
|
+
width="40"
|
|
82
|
+
height="40"
|
|
83
|
+
loading="lazy"
|
|
84
|
+
onerror="this.style.display='none'">
|
|
85
|
+
{% endif %}
|
|
86
|
+
<div class="item-card__author-info">
|
|
87
|
+
<span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
|
88
|
+
{% if item.author.url %}
|
|
89
|
+
<span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
90
|
+
{% endif %}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
{% endif %}
|
|
94
|
+
|
|
95
|
+
<a href="{{ item.url }}" class="item-card__link" target="_blank" rel="noopener">
|
|
96
|
+
{# Reply context #}
|
|
97
|
+
{% if item["in-reply-to"] and item["in-reply-to"].length > 0 %}
|
|
98
|
+
<div class="item-card__context">
|
|
99
|
+
{{ icon("reply") }}
|
|
100
|
+
<span>Reply to</span>
|
|
101
|
+
<span>{{ item["in-reply-to"][0] | replace("https://", "") | replace("http://", "") | truncate(50) }}</span>
|
|
102
|
+
</div>
|
|
103
|
+
{% endif %}
|
|
104
|
+
|
|
105
|
+
{# Title #}
|
|
106
|
+
{% if item.name %}
|
|
107
|
+
<h3 class="item-card__title">{{ item.name }}</h3>
|
|
108
|
+
{% endif %}
|
|
109
|
+
|
|
110
|
+
{# Content #}
|
|
111
|
+
{% if item.content %}
|
|
112
|
+
<div class="item-card__content{% if (item.content.text or '') | length > 300 %} item-card__content--truncated{% endif %}">
|
|
113
|
+
{% if item.content.html %}
|
|
114
|
+
{{ item.content.html | safe | striptags | truncate(400) }}
|
|
115
|
+
{% elif item.content.text %}
|
|
116
|
+
{{ item.content.text | truncate(400) }}
|
|
117
|
+
{% endif %}
|
|
118
|
+
</div>
|
|
119
|
+
{% endif %}
|
|
120
|
+
|
|
121
|
+
{# Tags #}
|
|
122
|
+
{% if item.category and item.category.length > 0 %}
|
|
123
|
+
<div class="item-card__categories">
|
|
124
|
+
{% for cat in item.category | slice(0, 5) %}
|
|
125
|
+
<span class="item-card__category">#{{ cat }}</span>
|
|
126
|
+
{% endfor %}
|
|
127
|
+
</div>
|
|
128
|
+
{% endif %}
|
|
129
|
+
|
|
130
|
+
{# Photos #}
|
|
131
|
+
{% if item.photo and item.photo.length > 0 %}
|
|
132
|
+
{% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
|
|
133
|
+
<div class="item-card__photos item-card__photos--{{ photoCount }}">
|
|
134
|
+
{% for photo in item.photo | slice(0, 4) %}
|
|
135
|
+
<img src="{{ photo }}" alt="" class="item-card__photo" loading="lazy"
|
|
136
|
+
onerror="this.parentElement.removeChild(this)">
|
|
137
|
+
{% endfor %}
|
|
138
|
+
</div>
|
|
139
|
+
{% endif %}
|
|
140
|
+
|
|
141
|
+
{# Footer #}
|
|
142
|
+
<footer class="item-card__footer">
|
|
143
|
+
{% if item.published %}
|
|
144
|
+
<time datetime="{{ item.published }}" class="item-card__date">
|
|
145
|
+
{{ item.published | date("PP", { locale: locale, timeZone: application.timeZone }) }}
|
|
146
|
+
</time>
|
|
147
|
+
{% endif %}
|
|
148
|
+
</footer>
|
|
149
|
+
</a>
|
|
150
|
+
|
|
151
|
+
{# Actions #}
|
|
152
|
+
<div class="item-actions">
|
|
153
|
+
<a href="{{ item.url }}" class="item-actions__button" target="_blank" rel="noopener" title="View original">
|
|
154
|
+
{{ icon("external") }}
|
|
155
|
+
</a>
|
|
156
|
+
<a href="{{ baseUrl }}/compose?reply={{ item.url | urlencode }}" class="item-actions__button" title="Reply">
|
|
157
|
+
{{ icon("reply") }}
|
|
158
|
+
</a>
|
|
159
|
+
<a href="{{ baseUrl }}/compose?like={{ item.url | urlencode }}" class="item-actions__button" title="Like">
|
|
160
|
+
{{ icon("like") }}
|
|
161
|
+
</a>
|
|
162
|
+
<a href="{{ baseUrl }}/compose?repost={{ item.url | urlencode }}" class="item-actions__button" title="Repost">
|
|
163
|
+
{{ icon("repost") }}
|
|
164
|
+
</a>
|
|
165
|
+
<a href="{{ baseUrl }}/compose?bookmark={{ item.url | urlencode }}" class="item-actions__button" title="Bookmark">
|
|
166
|
+
{{ icon("bookmark") }}
|
|
167
|
+
</a>
|
|
168
|
+
</div>
|
|
169
|
+
</article>
|
|
170
|
+
{% endfor %}
|
|
171
|
+
</div>
|
|
172
|
+
{% else %}
|
|
173
|
+
<div class="reader__empty">
|
|
174
|
+
{{ icon("syndicate") }}
|
|
175
|
+
<p>No posts found for this actor.</p>
|
|
176
|
+
</div>
|
|
177
|
+
{% endif %}
|
|
178
|
+
</div>
|
|
179
|
+
{% endblock %}
|
|
@@ -64,7 +64,25 @@
|
|
|
64
64
|
<div class="item-card__author-info">
|
|
65
65
|
<span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
|
66
66
|
{% if item._source %}
|
|
67
|
-
<span class="item-card__source">
|
|
67
|
+
<span class="item-card__source">
|
|
68
|
+
{# Protocol source indicator #}
|
|
69
|
+
{% set sourceUrl = item._source.url or item.author.url or "" %}
|
|
70
|
+
{% set sourceType = item._source.source_type or item._source.type %}
|
|
71
|
+
{% if sourceType == "activitypub" or sourceType == "mastodon" or ("mastodon." in sourceUrl) or ("mstdn." in sourceUrl) or ("fosstodon." in sourceUrl) or ("pleroma." in sourceUrl) or ("misskey." in sourceUrl) %}
|
|
72
|
+
<svg class="item-card__source-icon" viewBox="0 0 24 24" fill="#6364ff" aria-label="Fediverse" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
|
73
|
+
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
|
|
74
|
+
</svg>
|
|
75
|
+
{% elif sourceType == "bluesky" or ("bsky.app" in sourceUrl) or ("bluesky" in sourceUrl) %}
|
|
76
|
+
<svg class="item-card__source-icon" viewBox="0 0 568 501" fill="#0085ff" aria-label="ATmosphere" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
|
77
|
+
<path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
|
|
78
|
+
</svg>
|
|
79
|
+
{% else %}
|
|
80
|
+
<svg class="item-card__source-icon" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" aria-label="Web" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
|
81
|
+
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|
82
|
+
</svg>
|
|
83
|
+
{% endif %}
|
|
84
|
+
{{ item._source.name or item._source.url }}
|
|
85
|
+
</span>
|
|
68
86
|
{% elif item.author.url %}
|
|
69
87
|
<span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
70
88
|
{% endif %}
|
|
@@ -147,6 +165,12 @@
|
|
|
147
165
|
|
|
148
166
|
{# Inline actions (Aperture pattern) #}
|
|
149
167
|
<div class="item-actions">
|
|
168
|
+
{% if item._source and item._source.type === "activitypub" and item.author and item.author.url %}
|
|
169
|
+
<a href="{{ baseUrl }}/actor?url={{ item.author.url | urlencode }}" class="item-actions__button" title="View actor profile">
|
|
170
|
+
{{ icon("mention") }}
|
|
171
|
+
<span class="visually-hidden">Actor profile</span>
|
|
172
|
+
</a>
|
|
173
|
+
{% endif %}
|
|
150
174
|
{% if item.url %}
|
|
151
175
|
<a href="{{ item.url }}" class="item-actions__button" target="_blank" rel="noopener" title="View original">
|
|
152
176
|
{{ icon("external") }}
|