@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.
- package/assets/reader.js +408 -0
- package/index.js +37 -36
- package/lib/cache/redis.js +12 -3
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +250 -0
- package/lib/controllers/timeline.js +4 -2
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/fetcher.js +1 -30
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +20 -560
- package/lib/feeds/rss.js +1 -1
- package/lib/polling/processor.js +3 -17
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +99 -590
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/webmention/processor.js +1 -1
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +1 -1
- package/views/channel.njk +1 -348
- package/views/timeline.njk +3 -274
- 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
|
+
}
|