@rmdes/indiekit-endpoint-activitypub 1.1.12 → 1.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ // Post detail controller — view individual AP posts/notes/articles
2
+ import { Article, Note, Person, Service, Application } from "@fedify/fedify";
3
+ import { getToken } from "../csrf.js";
4
+ import { extractObjectData } from "../timeline-store.js";
5
+ import { getCached, setCache } from "../lookup-cache.js";
6
+
7
+ // Load parent posts (inReplyTo chain) up to maxDepth levels
8
+ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
9
+ const parents = [];
10
+ let currentUrl = parentUrl;
11
+ let depth = 0;
12
+
13
+ while (currentUrl && depth < maxDepth) {
14
+ depth++;
15
+
16
+ // Check timeline first
17
+ let parent = timelineCol
18
+ ? await timelineCol.findOne({
19
+ $or: [{ uid: currentUrl }, { url: currentUrl }],
20
+ })
21
+ : null;
22
+
23
+ if (!parent) {
24
+ // Fetch via lookupObject
25
+ const cached = getCached(currentUrl);
26
+ let object = cached;
27
+
28
+ if (!object) {
29
+ try {
30
+ object = await ctx.lookupObject(new URL(currentUrl), {
31
+ documentLoader,
32
+ });
33
+ if (object) {
34
+ setCache(currentUrl, object);
35
+ }
36
+ } catch {
37
+ break; // Stop on error
38
+ }
39
+ }
40
+
41
+ if (!object || !(object instanceof Note || object instanceof Article)) {
42
+ break;
43
+ }
44
+
45
+ try {
46
+ parent = await extractObjectData(object);
47
+ } catch {
48
+ break;
49
+ }
50
+ }
51
+
52
+ if (parent) {
53
+ parents.unshift(parent); // Add to beginning (chronological order)
54
+ currentUrl = parent.inReplyTo; // Continue up the chain
55
+ } else {
56
+ break;
57
+ }
58
+ }
59
+
60
+ return parents;
61
+ }
62
+
63
+ // Load replies collection (best-effort)
64
+ async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 10) {
65
+ const replies = [];
66
+
67
+ try {
68
+ const repliesCollection = await object.getReplies({ documentLoader });
69
+ if (!repliesCollection) return replies;
70
+
71
+ let items = [];
72
+ try {
73
+ items = await repliesCollection.getItems({ documentLoader });
74
+ } catch {
75
+ return replies;
76
+ }
77
+
78
+ for (const replyItem of items.slice(0, maxReplies)) {
79
+ try {
80
+ const replyUrl = replyItem.id?.href || replyItem.url?.href;
81
+ if (!replyUrl) continue;
82
+
83
+ // Check timeline first
84
+ let reply = timelineCol
85
+ ? await timelineCol.findOne({
86
+ $or: [{ uid: replyUrl }, { url: replyUrl }],
87
+ })
88
+ : null;
89
+
90
+ if (!reply) {
91
+ // Extract from the item we already have
92
+ if (replyItem instanceof Note || replyItem instanceof Article) {
93
+ reply = await extractObjectData(replyItem);
94
+ }
95
+ }
96
+
97
+ if (reply) {
98
+ replies.push(reply);
99
+ }
100
+ } catch {
101
+ continue; // Skip failed replies
102
+ }
103
+ }
104
+ } catch {
105
+ // getReplies() failed or not available
106
+ }
107
+
108
+ return replies;
109
+ }
110
+
111
+ // GET /admin/reader/post — Show post detail view
112
+ export function postDetailController(mountPath, plugin) {
113
+ return async (request, response, next) => {
114
+ try {
115
+ const { application } = request.app.locals;
116
+ const objectUrl = request.query.url;
117
+
118
+ if (!objectUrl || typeof objectUrl !== "string") {
119
+ return response.status(400).render("error", {
120
+ title: "Error",
121
+ content: "Missing post URL",
122
+ });
123
+ }
124
+
125
+ // Validate URL format
126
+ try {
127
+ new URL(objectUrl);
128
+ } catch {
129
+ return response.status(400).render("error", {
130
+ title: "Error",
131
+ content: "Invalid post URL",
132
+ });
133
+ }
134
+
135
+ if (!plugin._federation) {
136
+ return response.status(503).render("error", {
137
+ title: "Error",
138
+ content: "Federation not initialized",
139
+ });
140
+ }
141
+
142
+ const timelineCol = application?.collections?.get("ap_timeline");
143
+ const interactionsCol =
144
+ application?.collections?.get("ap_interactions");
145
+
146
+ // Check local timeline first (optimization)
147
+ let timelineItem = null;
148
+ if (timelineCol) {
149
+ timelineItem = await timelineCol.findOne({
150
+ $or: [{ uid: objectUrl }, { url: objectUrl }],
151
+ });
152
+ }
153
+
154
+ let object = null;
155
+
156
+ if (!timelineItem) {
157
+ // Not in local timeline — fetch via lookupObject
158
+ const handle = plugin.options.actor.handle;
159
+ const ctx = plugin._federation.createContext(
160
+ new URL(plugin._publicationUrl),
161
+ { handle, publicationUrl: plugin._publicationUrl },
162
+ );
163
+
164
+ const documentLoader = await ctx.getDocumentLoader({
165
+ identifier: handle,
166
+ });
167
+
168
+ // Check cache first
169
+ const cached = getCached(objectUrl);
170
+ if (cached) {
171
+ object = cached;
172
+ } else {
173
+ try {
174
+ object = await ctx.lookupObject(new URL(objectUrl), {
175
+ documentLoader,
176
+ });
177
+ if (object) {
178
+ setCache(objectUrl, object);
179
+ }
180
+ } catch (error) {
181
+ console.warn(
182
+ `[post-detail] lookupObject failed for ${objectUrl}:`,
183
+ error.message,
184
+ );
185
+ }
186
+ }
187
+
188
+ if (!object) {
189
+ return response.status(404).render("activitypub-post-detail", {
190
+ title: response.locals.__("activitypub.reader.post.title"),
191
+ notFound: true, objectUrl, mountPath,
192
+ item: null, interactionMap: {}, csrfToken: null,
193
+ parentPosts: [], replyPosts: [],
194
+ });
195
+ }
196
+
197
+ // If it's an actor (Person, Service, Application), redirect to profile
198
+ if (
199
+ object instanceof Person ||
200
+ object instanceof Service ||
201
+ object instanceof Application
202
+ ) {
203
+ return response.redirect(
204
+ `${mountPath}/admin/reader/profile?url=${encodeURIComponent(objectUrl)}`,
205
+ );
206
+ }
207
+
208
+ // Extract timeline item data from the Fedify object
209
+ if (object instanceof Note || object instanceof Article) {
210
+ try {
211
+ timelineItem = await extractObjectData(object);
212
+ } catch (error) {
213
+ console.error(`[post-detail] extractObjectData failed for ${objectUrl}:`, error.message);
214
+ return response.status(500).render("error", {
215
+ title: "Error",
216
+ content: "Failed to extract post data",
217
+ });
218
+ }
219
+ } else {
220
+ return response.status(400).render("error", {
221
+ title: "Error",
222
+ content: "Object is not a viewable post (must be Note or Article)",
223
+ });
224
+ }
225
+ }
226
+
227
+ // Build interaction state for this post
228
+ const interactionMap = {};
229
+ if (interactionsCol && timelineItem) {
230
+ const uid = timelineItem.uid;
231
+ const displayUrl = timelineItem.url || timelineItem.originalUrl;
232
+
233
+ const interactions = await interactionsCol
234
+ .find({
235
+ $or: [{ objectUrl: uid }, { objectUrl: displayUrl }],
236
+ })
237
+ .toArray();
238
+
239
+ for (const interaction of interactions) {
240
+ const key = uid;
241
+ if (!interactionMap[key]) {
242
+ interactionMap[key] = {};
243
+ }
244
+ interactionMap[key][interaction.type] = true;
245
+ }
246
+ }
247
+
248
+ // Load thread (parent chain + replies) with timeout
249
+ let parentPosts = [];
250
+ let replyPosts = [];
251
+
252
+ try {
253
+ const handle = plugin.options.actor.handle;
254
+ const ctx = plugin._federation.createContext(
255
+ new URL(plugin._publicationUrl),
256
+ { handle, publicationUrl: plugin._publicationUrl },
257
+ );
258
+
259
+ const documentLoader = await ctx.getDocumentLoader({
260
+ identifier: handle,
261
+ });
262
+
263
+ const threadPromise = Promise.all([
264
+ // Load parent chain
265
+ timelineItem.inReplyTo
266
+ ? loadParentChain(ctx, documentLoader, timelineCol, timelineItem.inReplyTo)
267
+ : Promise.resolve([]),
268
+ // Load replies (if object is available)
269
+ object
270
+ ? loadReplies(object, ctx, documentLoader, timelineCol)
271
+ : Promise.resolve([]),
272
+ ]);
273
+
274
+ // 15-second timeout for thread loading
275
+ const timeout = new Promise((resolve) =>
276
+ setTimeout(() => resolve([[], []]), 15000),
277
+ );
278
+
279
+ [parentPosts, replyPosts] = await Promise.race([threadPromise, timeout]);
280
+ } catch (error) {
281
+ console.error("[post-detail] Thread loading failed:", error.message);
282
+ // Continue with empty thread
283
+ }
284
+
285
+ const csrfToken = getToken(request.session);
286
+
287
+ response.render("activitypub-post-detail", {
288
+ title: response.locals.__("activitypub.reader.post.title"),
289
+ item: timelineItem,
290
+ interactionMap,
291
+ csrfToken,
292
+ mountPath,
293
+ parentPosts,
294
+ replyPosts,
295
+ });
296
+ } catch (error) {
297
+ next(error);
298
+ }
299
+ };
300
+ }
@@ -25,6 +25,7 @@ export {
25
25
  followController,
26
26
  unfollowController,
27
27
  } from "./profile.remote.js";
28
+ export { postDetailController } from "./post-detail.js";
28
29
 
29
30
  export function readerController(mountPath) {
30
31
  return async (request, response, next) => {
@@ -47,6 +48,7 @@ export function readerController(mountPath) {
47
48
  // Tab filtering
48
49
  if (tab === "notes") {
49
50
  options.type = "note";
51
+ options.excludeReplies = true;
50
52
  } else if (tab === "articles") {
51
53
  options.type = "article";
52
54
  } else if (tab === "boosts") {
@@ -27,6 +27,7 @@ import { logActivity as logActivityShared } from "./activity-log.js";
27
27
  import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
28
28
  import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
29
29
  import { addNotification } from "./storage/notifications.js";
30
+ import { fetchAndStorePreviews } from "./og-unfurl.js";
30
31
 
31
32
  /**
32
33
  * Register all inbox listeners on a federation's inbox chain.
@@ -450,6 +451,14 @@ export function registerInboxListeners(inboxChain, options) {
450
451
  actorFallback: actorObj,
451
452
  });
452
453
  await addTimelineItem(collections, timelineItem);
454
+
455
+ // Fire-and-forget OG unfurling for notes and articles (not boosts)
456
+ if (timelineItem.type === "note" || timelineItem.type === "article") {
457
+ fetchAndStorePreviews(collections, timelineItem.uid, timelineItem.content.html)
458
+ .catch((error) => {
459
+ console.error(`[inbox] OG unfurl failed for ${timelineItem.uid}:`, error);
460
+ });
461
+ }
453
462
  } catch (error) {
454
463
  // Log extraction errors but don't fail the entire handler
455
464
  console.error("Failed to store timeline item:", error);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Simple in-memory LRU cache for lookupObject results
3
+ * Max 100 entries, 5-minute TTL
4
+ * @module lookup-cache
5
+ */
6
+
7
+ const lookupCache = new Map();
8
+ const CACHE_MAX_SIZE = 100;
9
+ const CACHE_TTL_MS = 5 * 60 * 1000;
10
+
11
+ /**
12
+ * Get a cached lookup result
13
+ * @param {string} url - URL key
14
+ * @returns {*} Cached data or null
15
+ */
16
+ export function getCached(url) {
17
+ const entry = lookupCache.get(url);
18
+ if (!entry) return null;
19
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
20
+ lookupCache.delete(url);
21
+ return null;
22
+ }
23
+ return entry.data;
24
+ }
25
+
26
+ /**
27
+ * Store a lookup result in cache
28
+ * @param {string} url - URL key
29
+ * @param {*} data - Data to cache
30
+ */
31
+ export function setCache(url, data) {
32
+ // Evict oldest entry if at max size
33
+ if (lookupCache.size >= CACHE_MAX_SIZE) {
34
+ const firstKey = lookupCache.keys().next().value;
35
+ lookupCache.delete(firstKey);
36
+ }
37
+ lookupCache.set(url, { data, timestamp: Date.now() });
38
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * OpenGraph metadata fetching with concurrency limiting
3
+ * @module og-unfurl
4
+ */
5
+
6
+ import { unfurl } from "unfurl.js";
7
+
8
+ const USER_AGENT =
9
+ "Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
10
+ const TIMEOUT_MS = 10000; // 10 seconds per URL
11
+ const MAX_CONCURRENT = 3; // Lower than theme's 5 (inbox context)
12
+ const MAX_PREVIEWS = 3; // Max previews per post
13
+
14
+ // Concurrency limiter — prevents overwhelming outbound network
15
+ let activeRequests = 0;
16
+ const queue = [];
17
+
18
+ function runNext() {
19
+ if (queue.length === 0 || activeRequests >= MAX_CONCURRENT) return;
20
+ activeRequests++;
21
+ const { resolve: res, fn } = queue.shift();
22
+ fn()
23
+ .then(res)
24
+ .finally(() => {
25
+ activeRequests--;
26
+ runNext();
27
+ });
28
+ }
29
+
30
+ function throttled(fn) {
31
+ return new Promise((res) => {
32
+ queue.push({ resolve: res, fn });
33
+ runNext();
34
+ });
35
+ }
36
+
37
+ function extractDomain(url) {
38
+ try {
39
+ return new URL(url).hostname.replace(/^www\./, "");
40
+ } catch {
41
+ return url;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Check if a URL points to a private/reserved IP or localhost (SSRF protection)
47
+ * @param {string} url - URL to check
48
+ * @returns {boolean} True if URL targets a private network
49
+ */
50
+ function isPrivateUrl(url) {
51
+ try {
52
+ const urlObj = new URL(url);
53
+ const hostname = urlObj.hostname.toLowerCase();
54
+
55
+ // Block non-http(s) schemes
56
+ if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") {
57
+ return true;
58
+ }
59
+
60
+ // Block localhost variants
61
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
62
+ return true;
63
+ }
64
+
65
+ // Block private IPv4 ranges
66
+ const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
67
+ if (ipv4Match) {
68
+ const [, a, b] = ipv4Match.map(Number);
69
+ if (a === 10) return true; // 10.0.0.0/8
70
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
71
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
72
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata)
73
+ if (a === 127) return true; // 127.0.0.0/8
74
+ if (a === 0) return true; // 0.0.0.0/8
75
+ }
76
+
77
+ // Block IPv6 private ranges (basic check)
78
+ if (hostname.startsWith("[fc") || hostname.startsWith("[fd") || hostname.startsWith("[fe80")) {
79
+ return true;
80
+ }
81
+
82
+ return false;
83
+ } catch {
84
+ return true; // Invalid URL, treat as private
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Extract links from HTML content
90
+ * @param {string} html - Sanitized HTML content
91
+ * @returns {Array<{url: string, classes: string}>} Links with their class attributes
92
+ */
93
+ function extractLinks(html) {
94
+ if (!html) return [];
95
+
96
+ const links = [];
97
+ // Match complete <a> tags and extract href + class from anywhere in attributes
98
+ const anchorRegex = /<a\s([^>]+)>/gi;
99
+
100
+ let match;
101
+ while ((match = anchorRegex.exec(html)) !== null) {
102
+ const attrs = match[1];
103
+ const hrefMatch = attrs.match(/href="([^"]+)"/);
104
+ const classMatch = attrs.match(/class="([^"]+)"/);
105
+ if (hrefMatch) {
106
+ links.push({ url: hrefMatch[1], classes: classMatch ? classMatch[1] : "" });
107
+ }
108
+ }
109
+
110
+ return links;
111
+ }
112
+
113
+ /**
114
+ * Check if URL is likely an ActivityPub object or media file
115
+ * @param {string} url - URL to check
116
+ * @returns {boolean} True if URL should be skipped
117
+ */
118
+ function shouldSkipUrl(url) {
119
+ try {
120
+ const urlObj = new URL(url);
121
+
122
+ // SSRF protection — skip private/internal URLs
123
+ if (isPrivateUrl(url)) {
124
+ return true;
125
+ }
126
+
127
+ // Skip media extensions
128
+ const mediaExtensions = /\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|mp3|wav|ogg)$/i;
129
+ if (mediaExtensions.test(urlObj.pathname)) {
130
+ return true;
131
+ }
132
+
133
+ // Skip common AP object patterns (heuristic - not exhaustive)
134
+ const apPatterns = [
135
+ /\/@[\w.-]+\/\d+/, // Mastodon /@user/12345
136
+ /\/@[\w.-]+\/statuses\/[\w]+/, // GoToSocial /@user/statuses/id
137
+ /\/users\/[\w.-]+\/statuses\/\d+/, // Mastodon/Pleroma /users/user/statuses/12345
138
+ /\/objects\/[\w-]+/, // Pleroma/Akkoma /objects/uuid
139
+ /\/notice\/[\w]+/, // Pleroma /notice/id
140
+ /\/notes\/[\w]+/, // Misskey /notes/id
141
+ ];
142
+
143
+ return apPatterns.some((pattern) => pattern.test(urlObj.pathname));
144
+ } catch {
145
+ return true; // Invalid URL, skip
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Fetch OpenGraph metadata for external links in HTML content
151
+ * @param {string} html - Sanitized HTML content
152
+ * @returns {Promise<Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>>} Link preview objects
153
+ */
154
+ export async function fetchLinkPreviews(html) {
155
+ if (!html) return [];
156
+
157
+ const links = extractLinks(html);
158
+
159
+ // Filter links
160
+ const urlsToFetch = links
161
+ .filter((link) => {
162
+ // Skip mention links (class="mention")
163
+ if (link.classes.includes("mention")) return false;
164
+
165
+ // Skip hashtag links (class="hashtag")
166
+ if (link.classes.includes("hashtag")) return false;
167
+
168
+ // Skip AP object URLs and media files
169
+ if (shouldSkipUrl(link.url)) return false;
170
+
171
+ return true;
172
+ })
173
+ .map((link) => link.url)
174
+ .filter((url, index, self) => self.indexOf(url) === index) // Dedupe
175
+ .slice(0, MAX_PREVIEWS); // Cap at max
176
+
177
+ if (urlsToFetch.length === 0) return [];
178
+
179
+ // Fetch metadata for each URL (throttled)
180
+ const previews = await Promise.all(
181
+ urlsToFetch.map(async (url) => {
182
+ const metadata = await throttled(async () => {
183
+ try {
184
+ return await unfurl(url, {
185
+ timeout: TIMEOUT_MS,
186
+ headers: { "User-Agent": USER_AGENT },
187
+ });
188
+ } catch (error) {
189
+ console.warn(`[og-unfurl] Failed to fetch ${url}: ${error.message}`);
190
+ return null;
191
+ }
192
+ });
193
+
194
+ if (!metadata) return null;
195
+
196
+ const og = metadata.open_graph || {};
197
+ const tc = metadata.twitter_card || {};
198
+
199
+ const title = og.title || tc.title || metadata.title || extractDomain(url);
200
+ const description = og.description || tc.description || metadata.description || "";
201
+ const image = og.images?.[0]?.url || tc.images?.[0]?.url || null;
202
+ const favicon = metadata.favicon || null;
203
+ const domain = extractDomain(url);
204
+
205
+ // Truncate description
206
+ const maxDesc = 160;
207
+ const desc =
208
+ description.length > maxDesc
209
+ ? description.slice(0, maxDesc).trim() + "\u2026"
210
+ : description;
211
+
212
+ return {
213
+ url,
214
+ title,
215
+ description: desc,
216
+ image,
217
+ favicon,
218
+ domain,
219
+ fetchedAt: new Date().toISOString(),
220
+ };
221
+ }),
222
+ );
223
+
224
+ // Filter out failed fetches (null results)
225
+ return previews.filter((preview) => preview !== null);
226
+ }
227
+
228
+ /**
229
+ * Fetch link previews and store them on a timeline item
230
+ * Fire-and-forget — caller does NOT await. Errors are caught and logged.
231
+ * @param {object} collections - MongoDB collections
232
+ * @param {string} uid - Timeline item UID
233
+ * @param {string} html - Post content HTML
234
+ * @returns {Promise<void>}
235
+ */
236
+ export async function fetchAndStorePreviews(collections, uid, html) {
237
+ try {
238
+ const linkPreviews = await fetchLinkPreviews(html);
239
+
240
+ await collections.ap_timeline.updateOne(
241
+ { uid },
242
+ { $set: { linkPreviews } },
243
+ );
244
+ } catch (error) {
245
+ // Fire-and-forget — log errors but don't throw
246
+ console.error(
247
+ `[og-unfurl] Failed to store previews for ${uid}: ${error.message}`,
248
+ );
249
+ }
250
+ }
@@ -24,6 +24,7 @@
24
24
  * @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
25
25
  * @param {Date} [item.boostedAt] - Boost timestamp
26
26
  * @param {string} [item.originalUrl] - Original post URL for boosts
27
+ * @param {Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>} [item.linkPreviews] - OpenGraph link previews for external links in content
27
28
  * @param {string} item.createdAt - ISO string creation timestamp
28
29
  * @returns {Promise<object>} Created or existing item
29
30
  */
@@ -75,6 +76,15 @@ export async function getTimelineItems(collections, options = {}) {
75
76
  query.type = options.type;
76
77
  }
77
78
 
79
+ // Exclude replies (notes with inReplyTo set)
80
+ if (options.excludeReplies) {
81
+ query.$or = [
82
+ { inReplyTo: null },
83
+ { inReplyTo: "" },
84
+ { inReplyTo: { $exists: false } },
85
+ ];
86
+ }
87
+
78
88
  // Author filter (for profile view) — validate string type to prevent operator injection
79
89
  if (options.authorUrl) {
80
90
  if (typeof options.authorUrl !== "string") {
package/locales/en.json CHANGED
@@ -176,6 +176,19 @@
176
176
  "boosted": "Boosted",
177
177
  "likeError": "Could not like this post",
178
178
  "boostError": "Could not boost this post"
179
+ },
180
+ "post": {
181
+ "title": "Post Detail",
182
+ "notFound": "Post not found or no longer available.",
183
+ "openExternal": "Open on original instance",
184
+ "parentPosts": "Thread",
185
+ "replies": "Replies",
186
+ "back": "Back to timeline",
187
+ "loadingThread": "Loading thread...",
188
+ "threadError": "Could not load full thread"
189
+ },
190
+ "linkPreview": {
191
+ "label": "Link preview"
179
192
  }
180
193
  }
181
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -43,7 +43,8 @@
43
43
  "@js-temporal/polyfill": "^0.5.0",
44
44
  "express": "^5.0.0",
45
45
  "ioredis": "^5.9.3",
46
- "sanitize-html": "^2.13.1"
46
+ "sanitize-html": "^2.13.1",
47
+ "unfurl.js": "^6.4.0"
47
48
  },
48
49
  "peerDependencies": {
49
50
  "@indiekit/error": "^1.0.0-beta.25",