@rmdes/indiekit-endpoint-activitypub 1.1.10 → 1.1.12

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.
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { Temporal } from "@js-temporal/polyfill";
6
- import { getTimelineItem } from "../storage/timeline.js";
7
6
  import { getToken, validateToken } from "../csrf.js";
8
7
  import { sanitizeContent } from "../timeline-store.js";
9
8
 
@@ -62,7 +61,12 @@ export function composeController(mountPath, plugin) {
62
61
  };
63
62
 
64
63
  // Try to find the post in our timeline first
65
- replyContext = await getTimelineItem(collections, replyTo);
64
+ // Note: Timeline stores uid (canonical AP URL) and url (display URL).
65
+ // The card link passes the display URL, so search both fields.
66
+ const ap_timeline = collections.ap_timeline;
67
+ replyContext = ap_timeline
68
+ ? await ap_timeline.findOne({ $or: [{ uid: replyTo }, { url: replyTo }] })
69
+ : null;
66
70
 
67
71
  // If not in timeline, try to look up remotely
68
72
  if (!replyContext && plugin._federation) {
@@ -110,8 +114,11 @@ export function composeController(mountPath, plugin) {
110
114
  author: { name: authorName, url: authorUrl },
111
115
  };
112
116
  }
113
- } catch {
114
- // Could not resolve — form still works without context
117
+ } catch (error) {
118
+ console.warn(
119
+ `[ActivityPub] lookupObject failed for ${replyTo} (compose):`,
120
+ error.message,
121
+ );
115
122
  }
116
123
  }
117
124
  }
@@ -82,8 +82,11 @@ export function boostController(mountPath, plugin) {
82
82
  );
83
83
  }
84
84
  }
85
- } catch {
86
- // Non-critical — followers still received the boost
85
+ } catch (error) {
86
+ console.warn(
87
+ `[ActivityPub] lookupObject failed for ${url} (boost):`,
88
+ error.message,
89
+ );
87
90
  }
88
91
 
89
92
  // Track the interaction
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import { validateToken } from "../csrf.js";
7
- import { getTimelineItem } from "../storage/timeline.js";
8
7
 
9
8
  /**
10
9
  * POST /admin/reader/like — send a Like activity to the post author.
@@ -61,17 +60,22 @@ export function likeController(mountPath, plugin) {
61
60
  const author = await remoteObject.getAttributedTo({ documentLoader });
62
61
  recipient = Array.isArray(author) ? author[0] : author;
63
62
  }
64
- } catch {
65
- // Network failure — fall through to timeline
63
+ } catch (error) {
64
+ console.warn(
65
+ `[ActivityPub] lookupObject failed for ${url}:`,
66
+ error.message,
67
+ );
66
68
  }
67
69
 
68
70
  // Strategy 2: Use author URL from our timeline (already stored)
71
+ // Note: Timeline items store both uid (canonical AP URL) and url (display URL).
72
+ // The card passes the display URL, so we search by both fields.
69
73
  if (!recipient) {
70
74
  const { application } = request.app.locals;
71
- const collections = {
72
- ap_timeline: application?.collections?.get("ap_timeline"),
73
- };
74
- const timelineItem = await getTimelineItem(collections, url);
75
+ const ap_timeline = application?.collections?.get("ap_timeline");
76
+ const timelineItem = ap_timeline
77
+ ? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] })
78
+ : null;
75
79
  const authorUrl = timelineItem?.author?.url;
76
80
 
77
81
  if (authorUrl) {
@@ -210,15 +214,18 @@ export function unlikeController(mountPath, plugin) {
210
214
  const author = await remoteObject.getAttributedTo({ documentLoader });
211
215
  recipient = Array.isArray(author) ? author[0] : author;
212
216
  }
213
- } catch {
214
- // Network failure
217
+ } catch (error) {
218
+ console.warn(
219
+ `[ActivityPub] lookupObject failed for ${url} (unlike):`,
220
+ error.message,
221
+ );
215
222
  }
216
223
 
217
224
  if (!recipient) {
218
- const collections = {
219
- ap_timeline: application?.collections?.get("ap_timeline"),
220
- };
221
- const timelineItem = await getTimelineItem(collections, url);
225
+ const ap_timeline = application?.collections?.get("ap_timeline");
226
+ const timelineItem = ap_timeline
227
+ ? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] })
228
+ : null;
222
229
  const authorUrl = timelineItem?.author?.url;
223
230
 
224
231
  if (authorUrl) {
@@ -64,7 +64,7 @@ export function remoteProfileController(mountPath, plugin) {
64
64
  actor.preferredUsername?.toString() ||
65
65
  actorUrl;
66
66
  const actorHandle = actor.preferredUsername?.toString() || "";
67
- const summary = sanitizeContent(actor.summary?.toString() || "");
67
+ const bio = sanitizeContent(actor.summary?.toString() || "");
68
68
  let icon = "";
69
69
  let image = "";
70
70
 
@@ -129,7 +129,7 @@ export function remoteProfileController(mountPath, plugin) {
129
129
  actorUrl,
130
130
  name,
131
131
  actorHandle,
132
- summary,
132
+ bio,
133
133
  icon,
134
134
  image,
135
135
  instanceHost,
@@ -107,26 +107,47 @@ export function readerController(mountPath) {
107
107
  const unreadCount = await getUnreadNotificationCount(collections);
108
108
 
109
109
  // Get interaction state for liked/boosted indicators
110
+ // Interactions are keyed by canonical AP uid (new) or display url (legacy).
111
+ // Query by both, normalize map keys to uid for template lookup.
110
112
  const interactionsCol =
111
113
  application?.collections?.get("ap_interactions");
112
114
  const interactionMap = {};
113
115
 
114
116
  if (interactionsCol) {
115
- const itemUrls = items
116
- .map((item) => item.url || item.originalUrl)
117
- .filter(Boolean);
117
+ const lookupUrls = new Set();
118
+ const objectUrlToUid = new Map();
118
119
 
119
- if (itemUrls.length > 0) {
120
+ for (const item of items) {
121
+ const uid = item.uid;
122
+ const displayUrl = item.url || item.originalUrl;
123
+
124
+ if (uid) {
125
+ lookupUrls.add(uid);
126
+ objectUrlToUid.set(uid, uid);
127
+ }
128
+
129
+ if (displayUrl) {
130
+ lookupUrls.add(displayUrl);
131
+ objectUrlToUid.set(displayUrl, uid || displayUrl);
132
+ }
133
+ }
134
+
135
+ if (lookupUrls.size > 0) {
120
136
  const interactions = await interactionsCol
121
- .find({ objectUrl: { $in: itemUrls } })
137
+ .find({ objectUrl: { $in: [...lookupUrls] } })
122
138
  .toArray();
123
139
 
124
140
  for (const interaction of interactions) {
125
- if (!interactionMap[interaction.objectUrl]) {
126
- interactionMap[interaction.objectUrl] = {};
141
+ // Normalize to uid so template can look up by itemUid
142
+ const key =
143
+ objectUrlToUid.get(interaction.objectUrl) ||
144
+ interaction.objectUrl;
145
+
146
+ if (!interactionMap[key]) {
147
+ interactionMap[key] = {};
127
148
  }
128
149
 
129
- interactionMap[interaction.objectUrl][interaction.type] = true;
150
+ interactionMap[key][interaction.type] = true;
130
151
  }
131
152
  }
132
153
  }
@@ -9,6 +9,7 @@ import {
9
9
  Accept,
10
10
  Add,
11
11
  Announce,
12
+ Article,
12
13
  Block,
13
14
  Create,
14
15
  Delete,
@@ -365,14 +366,8 @@ export function registerInboxListeners(inboxChain, options) {
365
366
  actorObj?.preferredUsername?.toString() ||
366
367
  actorUrl;
367
368
 
368
- let inReplyTo = null;
369
- if (object instanceof Note && typeof object.getInReplyTo === "function") {
370
- try {
371
- inReplyTo = (await object.getInReplyTo())?.id?.href ?? null;
372
- } catch {
373
- /* remote fetch may fail */
374
- }
375
- }
369
+ // Use replyTargetId (non-fetching) for the inReplyTo URL
370
+ const inReplyTo = object.replyTargetId?.href || null;
376
371
 
377
372
  // Log replies to our posts (existing behavior for conversations)
378
373
  const pubUrl = collections._publicationUrl;
@@ -505,7 +500,7 @@ export function registerInboxListeners(inboxChain, options) {
505
500
  }
506
501
 
507
502
  // PATH 1: If object is a Note/Article → Update timeline item content
508
- if (object && (object instanceof Note || object.type === "Article")) {
503
+ if (object && (object instanceof Note || object instanceof Article)) {
509
504
  const objectUrl = object.id?.href || "";
510
505
  if (objectUrl) {
511
506
  try {
@@ -3,6 +3,7 @@
3
3
  * @module timeline-store
4
4
  */
5
5
 
6
+ import { Article } from "@fedify/fedify";
6
7
  import sanitizeHtml from "sanitize-html";
7
8
 
8
9
  /**
@@ -98,9 +99,9 @@ export async function extractObjectData(object, options = {}) {
98
99
  const uid = object.id?.href || "";
99
100
  const url = object.url?.href || uid;
100
101
 
101
- // Determine type
102
+ // Determine type — use instanceof for Fedify vocab objects
102
103
  let type = "note";
103
- if (object.type?.toLowerCase() === "article") {
104
+ if (object instanceof Article) {
104
105
  type = "article";
105
106
  }
106
107
  if (options.boostedBy) {
@@ -179,42 +180,51 @@ export async function extractObjectData(object, options = {}) {
179
180
  }
180
181
  }
181
182
 
182
- // Extract tags/categories
183
+ // Extract tags/categories — Fedify uses async getTags()
183
184
  const category = [];
184
- if (object.tag) {
185
- const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
186
- for (const tag of tags) {
187
- if (tag.type === "Hashtag" && tag.name) {
188
- category.push(tag.name.toString().replace(/^#/, ""));
185
+ try {
186
+ if (typeof object.getTags === "function") {
187
+ const tags = await object.getTags();
188
+ for (const tag of tags) {
189
+ if (tag.name) {
190
+ const tagName = tag.name.toString().replace(/^#/, "");
191
+ if (tagName) category.push(tagName);
192
+ }
189
193
  }
190
194
  }
195
+ } catch {
196
+ // Tags extraction failed — non-critical
191
197
  }
192
198
 
193
- // Extract media attachments
199
+ // Extract media attachments — Fedify uses async getAttachments()
194
200
  const photo = [];
195
201
  const video = [];
196
202
  const audio = [];
197
203
 
198
- if (object.attachment) {
199
- const attachments = Array.isArray(object.attachment) ? object.attachment : [object.attachment];
200
- for (const att of attachments) {
201
- const mediaUrl = att.url?.href || "";
202
- if (!mediaUrl) continue;
204
+ try {
205
+ if (typeof object.getAttachments === "function") {
206
+ const attachments = await object.getAttachments();
207
+ for (const att of attachments) {
208
+ const mediaUrl = att.url?.href || "";
209
+ if (!mediaUrl) continue;
203
210
 
204
- const mediaType = att.mediaType?.toLowerCase() || "";
211
+ const mediaType = att.mediaType?.toLowerCase() || "";
205
212
 
206
- if (mediaType.startsWith("image/")) {
207
- photo.push(mediaUrl);
208
- } else if (mediaType.startsWith("video/")) {
209
- video.push(mediaUrl);
210
- } else if (mediaType.startsWith("audio/")) {
211
- audio.push(mediaUrl);
213
+ if (mediaType.startsWith("image/")) {
214
+ photo.push(mediaUrl);
215
+ } else if (mediaType.startsWith("video/")) {
216
+ video.push(mediaUrl);
217
+ } else if (mediaType.startsWith("audio/")) {
218
+ audio.push(mediaUrl);
219
+ }
212
220
  }
213
221
  }
222
+ } catch {
223
+ // Attachment extraction failed — non-critical
214
224
  }
215
225
 
216
- // In-reply-to
217
- const inReplyTo = object.inReplyTo?.href || "";
226
+ // In-reply-to — Fedify uses replyTargetId (non-fetching)
227
+ const inReplyTo = object.replyTargetId?.href || "";
218
228
 
219
229
  // Build base timeline item
220
230
  const item = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
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",
@@ -57,8 +57,8 @@
57
57
  {% if actorHandle %}
58
58
  <div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
59
59
  {% endif %}
60
- {% if summary %}
61
- <div class="ap-profile__bio">{{ summary | safe }}</div>
60
+ {% if bio %}
61
+ <div class="ap-profile__bio">{{ bio | safe }}</div>
62
62
  {% endif %}
63
63
  </div>
64
64
 
@@ -97,11 +97,13 @@
97
97
  {% endif %}
98
98
 
99
99
  {# Interaction buttons — Alpine.js for optimistic updates #}
100
- {# Dynamic data moved to data-* attributes to prevent XSS from inline interpolation #}
100
+ {# Use canonical AP uid for interactions (Fedify lookupObject), display url for links #}
101
101
  {% set itemUrl = item.url or item.originalUrl %}
102
- {% set isLiked = interactionMap[itemUrl].like if interactionMap[itemUrl] else false %}
103
- {% set isBoosted = interactionMap[itemUrl].boost if interactionMap[itemUrl] else false %}
102
+ {% set itemUid = item.uid or item.url or item.originalUrl %}
103
+ {% set isLiked = interactionMap[itemUid].like if interactionMap[itemUid] else false %}
104
+ {% set isBoosted = interactionMap[itemUid].boost if interactionMap[itemUid] else false %}
104
105
  <footer class="ap-card__actions"
106
+ data-item-uid="{{ itemUid }}"
105
107
  data-item-url="{{ itemUrl }}"
106
108
  data-csrf-token="{{ csrfToken }}"
107
109
  data-mount-path="{{ mountPath }}"
@@ -115,7 +117,7 @@
115
117
  this.loading = true;
116
118
  this.error = '';
117
119
  const el = this.$root;
118
- const itemUrl = el.dataset.itemUrl;
120
+ const itemUid = el.dataset.itemUid;
119
121
  const csrfToken = el.dataset.csrfToken;
120
122
  const basePath = el.dataset.mountPath;
121
123
  const prev = { liked: this.liked, boosted: this.boosted };
@@ -130,7 +132,7 @@
130
132
  'Content-Type': 'application/json',
131
133
  'X-CSRF-Token': csrfToken
132
134
  },
133
- body: JSON.stringify({ url: itemUrl })
135
+ body: JSON.stringify({ url: itemUid })
134
136
  });
135
137
  const data = await res.json();
136
138
  if (!data.success) {
@@ -147,7 +149,7 @@
147
149
  if (this.error) setTimeout(() => this.error = '', 3000);
148
150
  }
149
151
  }">
150
- <a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUrl | urlencode }}"
152
+ <a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUid | urlencode }}"
151
153
  class="ap-card__action ap-card__action--reply"
152
154
  title="{{ __('activitypub.reader.actions.reply') }}">
153
155
  ↩ {{ __("activitypub.reader.actions.reply") }}