@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.57

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.
Files changed (51) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +37 -36
  3. package/lib/cache/redis.js +12 -3
  4. package/lib/controllers/reader/actor.js +142 -0
  5. package/lib/controllers/reader/channel.js +301 -0
  6. package/lib/controllers/reader/compose.js +242 -0
  7. package/lib/controllers/reader/deck.js +129 -0
  8. package/lib/controllers/reader/feed-repair.js +117 -0
  9. package/lib/controllers/reader/feed.js +246 -0
  10. package/lib/controllers/reader/index.js +126 -0
  11. package/lib/controllers/reader/search.js +157 -0
  12. package/lib/controllers/reader/timeline.js +250 -0
  13. package/lib/controllers/timeline.js +4 -2
  14. package/lib/feeds/atom.js +1 -1
  15. package/lib/feeds/fetcher.js +1 -30
  16. package/lib/feeds/hfeed.js +1 -1
  17. package/lib/feeds/jsonfeed.js +1 -1
  18. package/lib/feeds/normalizer-hfeed.js +209 -0
  19. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  20. package/lib/feeds/normalizer-rss.js +178 -0
  21. package/lib/feeds/normalizer.js +20 -560
  22. package/lib/feeds/rss.js +1 -1
  23. package/lib/polling/processor.js +3 -17
  24. package/lib/storage/items-read-state.js +287 -0
  25. package/lib/storage/items-retention.js +174 -0
  26. package/lib/storage/items-search.js +34 -0
  27. package/lib/storage/items.js +99 -590
  28. package/lib/storage/read-state.js +1 -1
  29. package/lib/utils/async-handler.js +7 -0
  30. package/lib/utils/html.js +25 -0
  31. package/lib/utils/source-type.js +28 -0
  32. package/lib/webmention/processor.js +1 -1
  33. package/locales/de.json +3 -0
  34. package/locales/en.json +2 -0
  35. package/locales/es-419.json +3 -0
  36. package/locales/es.json +3 -0
  37. package/locales/fr.json +3 -0
  38. package/locales/hi.json +3 -0
  39. package/locales/id.json +3 -0
  40. package/locales/it.json +3 -0
  41. package/locales/nl.json +3 -0
  42. package/locales/pl.json +3 -0
  43. package/locales/pt-BR.json +3 -0
  44. package/locales/pt.json +3 -0
  45. package/locales/sr.json +3 -0
  46. package/locales/sv.json +3 -0
  47. package/locales/zh-Hans-CN.json +3 -0
  48. package/package.json +1 -1
  49. package/views/channel.njk +1 -348
  50. package/views/timeline.njk +3 -274
  51. package/lib/controllers/reader.js +0 -1562
@@ -0,0 +1,142 @@
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
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Channel CRUD + HTML fragments
3
+ * @module controllers/reader/channel
4
+ */
5
+
6
+ import {
7
+ getChannels,
8
+ getChannel,
9
+ createChannel,
10
+ updateChannelSettings,
11
+ deleteChannel,
12
+ } from "../../storage/channels.js";
13
+ import { getFeedsForChannel } from "../../storage/feeds.js";
14
+ import { getTimelineItems } from "../../storage/items.js";
15
+ import { countReadItems } from "../../storage/items-read-state.js";
16
+ import { getUserId } from "../../utils/auth.js";
17
+ import {
18
+ validateChannelName,
19
+ validateExcludeTypes,
20
+ validateExcludeRegex,
21
+ } from "../../utils/validation.js";
22
+ import { proxyItemImages } from "../../media/proxy.js";
23
+
24
+ /**
25
+ * Reader index - redirect to channels
26
+ * @param {object} request - Express request
27
+ * @param {object} response - Express response
28
+ */
29
+ export async function index(request, response) {
30
+ const lastView = request.session?.microsubView || "timeline";
31
+ const validViews = ["channels", "deck", "timeline"];
32
+ const view = validViews.includes(lastView) ? lastView : "timeline";
33
+ response.redirect(`${request.baseUrl}/${view}`);
34
+ }
35
+
36
+ /**
37
+ * List channels
38
+ * @param {object} request - Express request
39
+ * @param {object} response - Express response
40
+ */
41
+ export async function channels(request, response) {
42
+ const { application } = request.app.locals;
43
+ const userId = getUserId(request);
44
+
45
+ const channelList = await getChannels(application, userId);
46
+
47
+ if (request.session) request.session.microsubView = "channels";
48
+
49
+ response.render("reader", {
50
+ title: request.__("microsub.views.channels"),
51
+ channels: channelList,
52
+ baseUrl: request.baseUrl,
53
+ readerBaseUrl: request.baseUrl,
54
+ activeView: "channels",
55
+ breadcrumbs: [
56
+ { text: "Reader", href: request.baseUrl },
57
+ { text: "Channels" },
58
+ ],
59
+ });
60
+ }
61
+
62
+ /**
63
+ * New channel form
64
+ * @param {object} request - Express request
65
+ * @param {object} response - Express response
66
+ */
67
+ export async function newChannel(request, response) {
68
+ response.render("channel-new", {
69
+ title: request.__("microsub.channels.new"),
70
+ baseUrl: request.baseUrl,
71
+ readerBaseUrl: request.baseUrl,
72
+ activeView: "channels",
73
+ breadcrumbs: [
74
+ { text: "Reader", href: request.baseUrl },
75
+ { text: "Channels", href: `${request.baseUrl}/channels` },
76
+ { text: request.__("microsub.channels.new") },
77
+ ],
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Create channel
83
+ * @param {object} request - Express request
84
+ * @param {object} response - Express response
85
+ */
86
+ export async function createChannelAction(request, response) {
87
+ const { application } = request.app.locals;
88
+ const userId = getUserId(request);
89
+ const { name } = request.body;
90
+
91
+ validateChannelName(name);
92
+
93
+ await createChannel(application, { name, userId });
94
+
95
+ response.redirect(`${request.baseUrl}/channels`);
96
+ }
97
+
98
+ /**
99
+ * View channel timeline
100
+ * @param {object} request - Express request
101
+ * @param {object} response - Express response
102
+ * @returns {Promise<void>}
103
+ */
104
+ export async function channel(request, response) {
105
+ const { application } = request.app.locals;
106
+ const userId = getUserId(request);
107
+ const { uid } = request.params;
108
+ const { before, after, showRead } = request.query;
109
+
110
+ const channelDocument = await getChannel(application, uid, userId);
111
+ if (!channelDocument) {
112
+ return response.status(404).render("404");
113
+ }
114
+
115
+ // Check if showing read items
116
+ const showReadItems = showRead === "true";
117
+
118
+ const timeline = await getTimelineItems(application, channelDocument._id, {
119
+ before,
120
+ after,
121
+ userId,
122
+ showRead: showReadItems,
123
+ });
124
+
125
+ // Proxy images through media endpoint for privacy
126
+ const proxyBaseUrl = application.url;
127
+ if (proxyBaseUrl && timeline.items) {
128
+ timeline.items = timeline.items.map((item) =>
129
+ proxyItemImages(item, proxyBaseUrl),
130
+ );
131
+ }
132
+
133
+ // Count read items to show "View read items" button
134
+ const readCount = await countReadItems(
135
+ application,
136
+ channelDocument._id,
137
+ userId,
138
+ );
139
+
140
+ response.render("channel", {
141
+ title: channelDocument.name,
142
+ channel: channelDocument,
143
+ items: timeline.items,
144
+ paging: timeline.paging,
145
+ readCount,
146
+ showRead: showReadItems,
147
+ baseUrl: request.baseUrl,
148
+ readerBaseUrl: request.baseUrl,
149
+ activeView: "channels",
150
+ breadcrumbs: [
151
+ { text: "Reader", href: request.baseUrl },
152
+ { text: "Channels", href: `${request.baseUrl}/channels` },
153
+ { text: channelDocument.name },
154
+ ],
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Return rendered HTML fragments for infinite scroll
160
+ * @param {object} request - Express request
161
+ * @param {object} response - Express response
162
+ * @returns {Promise<void>}
163
+ */
164
+ export async function channelHtml(request, response) {
165
+ const { application } = request.app.locals;
166
+ const userId = getUserId(request);
167
+ const { uid } = request.params;
168
+ const { before, after, showRead } = request.query;
169
+
170
+ const channelDocument = await getChannel(application, uid, userId);
171
+ if (!channelDocument) {
172
+ return response.status(404).json({ error: "Channel not found" });
173
+ }
174
+
175
+ const showReadItems = showRead === "true";
176
+
177
+ const timeline = await getTimelineItems(application, channelDocument._id, {
178
+ before,
179
+ after,
180
+ userId,
181
+ showRead: showReadItems,
182
+ });
183
+
184
+ // Proxy images
185
+ const proxyBaseUrl = application.url;
186
+ if (proxyBaseUrl && timeline.items) {
187
+ timeline.items = timeline.items.map((item) =>
188
+ proxyItemImages(item, proxyBaseUrl),
189
+ );
190
+ }
191
+
192
+ // Render items via layout-less fragment template (standard response.render
193
+ // with callback returns HTML string without sending a response)
194
+ const fragmentHtml = await new Promise((resolve, reject) => {
195
+ response.render("partials/items-fragment", {
196
+ items: timeline.items,
197
+ channel: channelDocument,
198
+ baseUrl: request.baseUrl,
199
+ }, (error, html) => error ? reject(error) : resolve(html));
200
+ });
201
+
202
+ response.json({
203
+ html: fragmentHtml,
204
+ paging: timeline.paging,
205
+ count: timeline.items.length,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Channel settings form
211
+ * @param {object} request - Express request
212
+ * @param {object} response - Express response
213
+ * @returns {Promise<void>}
214
+ */
215
+ export async function settings(request, response) {
216
+ const { application } = request.app.locals;
217
+ const userId = getUserId(request);
218
+ const { uid } = request.params;
219
+
220
+ const channelDocument = await getChannel(application, uid, userId);
221
+ if (!channelDocument) {
222
+ return response.status(404).render("404");
223
+ }
224
+
225
+ response.render("settings", {
226
+ title: request.__("microsub.settings.title", {
227
+ channel: channelDocument.name,
228
+ }),
229
+ channel: channelDocument,
230
+ baseUrl: request.baseUrl,
231
+ readerBaseUrl: request.baseUrl,
232
+ activeView: "channels",
233
+ breadcrumbs: [
234
+ { text: "Reader", href: request.baseUrl },
235
+ { text: "Channels", href: `${request.baseUrl}/channels` },
236
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
237
+ { text: "Settings" },
238
+ ],
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Update channel settings
244
+ * @param {object} request - Express request
245
+ * @param {object} response - Express response
246
+ * @returns {Promise<void>}
247
+ */
248
+ export async function updateSettings(request, response) {
249
+ const { application } = request.app.locals;
250
+ const userId = getUserId(request);
251
+ const { uid } = request.params;
252
+ const { excludeTypes, excludeRegex } = request.body;
253
+
254
+ const channelDocument = await getChannel(application, uid, userId);
255
+ if (!channelDocument) {
256
+ return response.status(404).render("404");
257
+ }
258
+
259
+ const validatedTypes = validateExcludeTypes(
260
+ Array.isArray(excludeTypes) ? excludeTypes : [excludeTypes].filter(Boolean),
261
+ );
262
+ const validatedRegex = validateExcludeRegex(excludeRegex);
263
+
264
+ await updateChannelSettings(
265
+ application,
266
+ uid,
267
+ {
268
+ excludeTypes: validatedTypes,
269
+ excludeRegex: validatedRegex,
270
+ },
271
+ userId,
272
+ );
273
+
274
+ response.redirect(`${request.baseUrl}/channels/${uid}`);
275
+ }
276
+
277
+ /**
278
+ * Delete channel
279
+ * @param {object} request - Express request
280
+ * @param {object} response - Express response
281
+ * @returns {Promise<void>}
282
+ */
283
+ export async function deleteChannelAction(request, response) {
284
+ const { application } = request.app.locals;
285
+ const userId = getUserId(request);
286
+ const { uid } = request.params;
287
+
288
+ // Don't allow deleting system channels
289
+ if (uid === "notifications" || uid === "activitypub") {
290
+ return response.redirect(`${request.baseUrl}/channels`);
291
+ }
292
+
293
+ const channelDocument = await getChannel(application, uid, userId);
294
+ if (!channelDocument) {
295
+ return response.status(404).render("404");
296
+ }
297
+
298
+ await deleteChannel(application, uid, userId);
299
+
300
+ response.redirect(`${request.baseUrl}/channels`);
301
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Micropub compose
3
+ * @module controllers/reader/compose
4
+ */
5
+
6
+ import { classifyUrl } from "../../utils/source-type.js";
7
+
8
+ /**
9
+ * Ensure value is a string URL
10
+ * @param {string|object|undefined} value - Value to check
11
+ * @returns {string|undefined} String value or undefined
12
+ */
13
+ function ensureString(value) {
14
+ if (!value) return;
15
+ if (typeof value === "string") return value;
16
+ if (typeof value === "object" && value.url) return value.url;
17
+ return String(value);
18
+ }
19
+
20
+ /**
21
+ * Fetch syndication targets from Micropub config
22
+ * @param {object} application - Indiekit application
23
+ * @param {string} token - Auth token
24
+ * @returns {Promise<Array>} Syndication targets
25
+ */
26
+ async function getSyndicationTargets(application, token) {
27
+ try {
28
+ const micropubEndpoint = application.micropubEndpoint;
29
+ if (!micropubEndpoint) return [];
30
+
31
+ const micropubUrl = micropubEndpoint.startsWith("http")
32
+ ? micropubEndpoint
33
+ : new URL(micropubEndpoint, application.url).href;
34
+
35
+ const configUrl = `${micropubUrl}?q=config`;
36
+ const configResponse = await fetch(configUrl, {
37
+ headers: {
38
+ Authorization: `Bearer ${token}`,
39
+ Accept: "application/json",
40
+ },
41
+ });
42
+
43
+ if (!configResponse.ok) return [];
44
+
45
+ const config = await configResponse.json();
46
+ return config["syndicate-to"] || [];
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Compose response form
54
+ * @param {object} request - Express request
55
+ * @param {object} response - Express response
56
+ * @returns {Promise<void>}
57
+ */
58
+ export async function compose(request, response) {
59
+ const { application } = request.app.locals;
60
+
61
+ // Support both long-form (replyTo) and short-form (reply) query params
62
+ const {
63
+ replyTo,
64
+ reply,
65
+ likeOf,
66
+ like,
67
+ repostOf,
68
+ repost,
69
+ bookmarkOf,
70
+ bookmark,
71
+ } = request.query;
72
+
73
+ // Fetch syndication targets if user is authenticated
74
+ const token = request.session?.access_token;
75
+ const syndicationTargets = token
76
+ ? await getSyndicationTargets(application, token)
77
+ : [];
78
+
79
+ // Auto-select syndication target based on interaction URL protocol
80
+ const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
81
+ if (interactionUrl && syndicationTargets.length > 0) {
82
+ const protocol = classifyUrl(interactionUrl).protocol;
83
+ for (const target of syndicationTargets) {
84
+ const targetId = (target.uid || target.name || "").toLowerCase();
85
+ if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
86
+ target.checked = true;
87
+ } else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
88
+ target.checked = true;
89
+ }
90
+ }
91
+ }
92
+
93
+ response.render("compose", {
94
+ title: request.__("microsub.compose.title"),
95
+ replyTo: ensureString(replyTo || reply),
96
+ likeOf: ensureString(likeOf || like),
97
+ repostOf: ensureString(repostOf || repost),
98
+ bookmarkOf: ensureString(bookmarkOf || bookmark),
99
+ syndicationTargets,
100
+ baseUrl: request.baseUrl,
101
+ readerBaseUrl: request.baseUrl,
102
+ activeView: "channels",
103
+ breadcrumbs: [
104
+ { text: "Reader", href: request.baseUrl },
105
+ { text: "Compose" },
106
+ ],
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Submit composed response via Micropub
112
+ * @param {object} request - Express request
113
+ * @param {object} response - Express response
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function submitCompose(request, response) {
117
+ const { application } = request.app.locals;
118
+ const { content } = request.body;
119
+ const inReplyTo = request.body["in-reply-to"];
120
+ const likeOf = request.body["like-of"];
121
+ const repostOf = request.body["repost-of"];
122
+ const bookmarkOf = request.body["bookmark-of"];
123
+ const syndicateTo = request.body["mp-syndicate-to"];
124
+
125
+ // Get Micropub endpoint
126
+ const micropubEndpoint = application.micropubEndpoint;
127
+ if (!micropubEndpoint) {
128
+ return response.status(500).render("error", {
129
+ title: "Error",
130
+ content: "Micropub endpoint not configured",
131
+ });
132
+ }
133
+
134
+ // Build absolute Micropub URL
135
+ const micropubUrl = micropubEndpoint.startsWith("http")
136
+ ? micropubEndpoint
137
+ : new URL(micropubEndpoint, application.url).href;
138
+
139
+ // Get auth token from session
140
+ const token = request.session?.access_token;
141
+ if (!token) {
142
+ return response.redirect("/session/login?redirect=" + request.originalUrl);
143
+ }
144
+
145
+ // Build Micropub request body
146
+ const micropubData = new URLSearchParams();
147
+ micropubData.append("h", "entry");
148
+
149
+ if (likeOf) {
150
+ // Like post - content is optional comment
151
+ micropubData.append("like-of", likeOf);
152
+ if (content && content.trim()) {
153
+ micropubData.append("content", content.trim());
154
+ }
155
+ } else if (repostOf) {
156
+ // Repost - content is optional comment
157
+ micropubData.append("repost-of", repostOf);
158
+ if (content && content.trim()) {
159
+ micropubData.append("content", content.trim());
160
+ }
161
+ } else if (bookmarkOf) {
162
+ // Bookmark - content is optional comment
163
+ micropubData.append("bookmark-of", bookmarkOf);
164
+ if (content && content.trim()) {
165
+ micropubData.append("content", content.trim());
166
+ }
167
+ } else if (inReplyTo) {
168
+ // Reply
169
+ micropubData.append("in-reply-to", inReplyTo);
170
+ micropubData.append("content", content || "");
171
+ } else {
172
+ // Regular note
173
+ micropubData.append("content", content || "");
174
+ }
175
+
176
+ // Add syndication targets
177
+ if (syndicateTo) {
178
+ const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];
179
+ for (const target of targets) {
180
+ micropubData.append("mp-syndicate-to", target);
181
+ }
182
+ }
183
+
184
+ try {
185
+ const micropubResponse = await fetch(micropubUrl, {
186
+ method: "POST",
187
+ headers: {
188
+ Authorization: `Bearer ${token}`,
189
+ "Content-Type": "application/x-www-form-urlencoded",
190
+ Accept: "application/json",
191
+ },
192
+ body: micropubData.toString(),
193
+ });
194
+
195
+ if (
196
+ micropubResponse.ok ||
197
+ micropubResponse.status === 201 ||
198
+ micropubResponse.status === 202
199
+ ) {
200
+ // Success - get the Location header for the new post URL
201
+ const location = micropubResponse.headers.get("Location");
202
+ console.info(
203
+ `[Microsub] Created post via Micropub: ${location || "success"}`,
204
+ );
205
+
206
+ // Redirect back to reader with success message
207
+ return response.redirect(`${request.baseUrl}/channels`);
208
+ }
209
+
210
+ // Handle error
211
+ const errorBody = await micropubResponse.text();
212
+ const statusText = micropubResponse.statusText || "Unknown error";
213
+ console.error(
214
+ `[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
215
+ );
216
+
217
+ // Parse error message from response body if JSON
218
+ let errorMessage = `Micropub error: ${statusText}`;
219
+ try {
220
+ const errorJson = JSON.parse(errorBody);
221
+ if (errorJson.error_description) {
222
+ errorMessage = String(errorJson.error_description);
223
+ } else if (errorJson.error) {
224
+ errorMessage = String(errorJson.error);
225
+ }
226
+ } catch {
227
+ // Not JSON, use status text
228
+ }
229
+
230
+ return response.status(micropubResponse.status).render("error", {
231
+ title: "Error",
232
+ content: errorMessage,
233
+ });
234
+ } catch (error) {
235
+ console.error(`[Microsub] Micropub request failed: ${error.message}`);
236
+
237
+ return response.status(500).render("error", {
238
+ title: "Error",
239
+ content: `Failed to create post: ${error.message}`,
240
+ });
241
+ }
242
+ }