@rmdes/indiekit-endpoint-microsub 1.0.55 → 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 +61 -49
- package/lib/activitypub/outbox-fetcher.js +14 -2
- package/lib/cache/redis.js +26 -7
- package/lib/controllers/channels.js +2 -2
- 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/search.js +6 -0
- package/lib/controllers/timeline.js +6 -4
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/capabilities.js +5 -0
- package/lib/feeds/fetcher.js +5 -28
- 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 +22 -614
- package/lib/feeds/rss.js +1 -1
- package/lib/media/proxy.js +82 -27
- package/lib/polling/processor.js +30 -21
- package/lib/polling/scheduler.js +2 -0
- package/lib/realtime/broker.js +6 -1
- package/lib/storage/channels.js +53 -42
- package/lib/storage/feeds.js +3 -1
- 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 +113 -610
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/constants.js +7 -0
- package/lib/utils/csrf.js +51 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/sanitize.js +61 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/utils/validation.js +8 -2
- package/lib/webmention/processor.js +1 -1
- package/lib/webmention/verifier.js +10 -21
- package/lib/websub/subscriber.js +12 -0
- 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 +3 -1
- package/views/actor.njk +2 -0
- package/views/channel-new.njk +1 -0
- package/views/channel.njk +3 -344
- package/views/compose.njk +1 -0
- package/views/deck-settings.njk +1 -0
- package/views/feed-edit.njk +3 -0
- package/views/feeds.njk +4 -0
- package/views/layouts/reader.njk +1 -0
- package/views/search.njk +2 -0
- package/views/settings.njk +2 -0
- package/views/timeline.njk +3 -271
- package/lib/controllers/reader.js +0 -1580
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deck view
|
|
3
|
+
* @module controllers/reader/deck
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getChannelsWithColors } from "../../storage/channels.js";
|
|
7
|
+
import { getTimelineItems } from "../../storage/items.js";
|
|
8
|
+
import { getDeckConfig, saveDeckConfig } from "../../storage/deck.js";
|
|
9
|
+
import { getUserId } from "../../utils/auth.js";
|
|
10
|
+
import { proxyItemImages } from "../../media/proxy.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deck view - TweetDeck-style columns
|
|
14
|
+
* @param {object} request - Express request
|
|
15
|
+
* @param {object} response - Express response
|
|
16
|
+
*/
|
|
17
|
+
export async function deck(request, response) {
|
|
18
|
+
const { application } = request.app.locals;
|
|
19
|
+
const userId = getUserId(request);
|
|
20
|
+
|
|
21
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
22
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
23
|
+
|
|
24
|
+
// Determine which channels to show as columns
|
|
25
|
+
let columnChannels;
|
|
26
|
+
if (deckConfig?.columns?.length > 0) {
|
|
27
|
+
// Use saved config order
|
|
28
|
+
const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
|
|
29
|
+
columnChannels = deckConfig.columns
|
|
30
|
+
.map((col) => channelMap.get(col.channelId.toString()))
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
} else {
|
|
33
|
+
// Default: all channels except notifications
|
|
34
|
+
columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fetch items for each column (limited to 10 per column for performance)
|
|
38
|
+
// Batch in groups of 4 to avoid overwhelming MongoDB with parallel queries
|
|
39
|
+
const proxyBaseUrl = application.url;
|
|
40
|
+
const columns = [];
|
|
41
|
+
for (let i = 0; i < columnChannels.length; i += 4) {
|
|
42
|
+
const batch = columnChannels.slice(i, i + 4);
|
|
43
|
+
const batchResults = await Promise.all(
|
|
44
|
+
batch.map(async (channel) => {
|
|
45
|
+
const result = await getTimelineItems(application, channel._id, {
|
|
46
|
+
userId,
|
|
47
|
+
limit: 10,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (proxyBaseUrl && result.items) {
|
|
51
|
+
result.items = result.items.map((item) =>
|
|
52
|
+
proxyItemImages(item, proxyBaseUrl),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
channel,
|
|
58
|
+
items: result.items,
|
|
59
|
+
paging: result.paging,
|
|
60
|
+
};
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
columns.push(...batchResults);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Set view preference cookie
|
|
67
|
+
if (request.session) request.session.microsubView = "deck";
|
|
68
|
+
|
|
69
|
+
response.render("deck", {
|
|
70
|
+
title: "Deck",
|
|
71
|
+
columns,
|
|
72
|
+
baseUrl: request.baseUrl,
|
|
73
|
+
readerBaseUrl: request.baseUrl,
|
|
74
|
+
activeView: "deck",
|
|
75
|
+
breadcrumbs: [
|
|
76
|
+
{ text: "Reader", href: request.baseUrl },
|
|
77
|
+
{ text: "Deck" },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Deck settings page
|
|
84
|
+
* @param {object} request - Express request
|
|
85
|
+
* @param {object} response - Express response
|
|
86
|
+
*/
|
|
87
|
+
export async function deckSettings(request, response) {
|
|
88
|
+
const { application } = request.app.locals;
|
|
89
|
+
const userId = getUserId(request);
|
|
90
|
+
|
|
91
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
92
|
+
const deckConfig = await getDeckConfig(application, userId);
|
|
93
|
+
|
|
94
|
+
const selectedIds = deckConfig?.columns
|
|
95
|
+
? deckConfig.columns.map((col) => col.channelId.toString())
|
|
96
|
+
: channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
|
|
97
|
+
|
|
98
|
+
response.render("deck-settings", {
|
|
99
|
+
title: "Deck settings",
|
|
100
|
+
channels: channelList,
|
|
101
|
+
selectedIds,
|
|
102
|
+
baseUrl: request.baseUrl,
|
|
103
|
+
readerBaseUrl: request.baseUrl,
|
|
104
|
+
activeView: "deck",
|
|
105
|
+
breadcrumbs: [
|
|
106
|
+
{ text: "Reader", href: request.baseUrl },
|
|
107
|
+
{ text: "Deck", href: `${request.baseUrl}/deck` },
|
|
108
|
+
{ text: "Settings" },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Save deck settings
|
|
115
|
+
* @param {object} request - Express request
|
|
116
|
+
* @param {object} response - Express response
|
|
117
|
+
*/
|
|
118
|
+
export async function saveDeckSettings(request, response) {
|
|
119
|
+
const { application } = request.app.locals;
|
|
120
|
+
const userId = getUserId(request);
|
|
121
|
+
|
|
122
|
+
let { columns } = request.body;
|
|
123
|
+
if (!columns) columns = [];
|
|
124
|
+
if (!Array.isArray(columns)) columns = [columns];
|
|
125
|
+
|
|
126
|
+
await saveDeckConfig(application, userId, columns);
|
|
127
|
+
|
|
128
|
+
response.redirect(`${request.baseUrl}/deck`);
|
|
129
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed repair operations (rediscover + force refresh)
|
|
3
|
+
* @module controllers/reader/feed-repair
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { discoverAndValidateFeeds, getBestFeed } from "../../feeds/discovery.js";
|
|
7
|
+
import { refreshFeedNow } from "../../polling/scheduler.js";
|
|
8
|
+
import { getChannel } from "../../storage/channels.js";
|
|
9
|
+
import { getFeedById, updateFeed, updateFeedStatus } from "../../storage/feeds.js";
|
|
10
|
+
import { getUserId } from "../../utils/auth.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rediscover feed - run discovery on URL to find actual RSS feed
|
|
14
|
+
* @param {object} request - Express request
|
|
15
|
+
* @param {object} response - Express response
|
|
16
|
+
* @returns {Promise<void>}
|
|
17
|
+
*/
|
|
18
|
+
export async function rediscoverFeed(request, response) {
|
|
19
|
+
const { application } = request.app.locals;
|
|
20
|
+
const userId = getUserId(request);
|
|
21
|
+
const { uid, feedId } = request.params;
|
|
22
|
+
|
|
23
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
24
|
+
if (!channelDocument) {
|
|
25
|
+
return response.status(404).render("404");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const feed = await getFeedById(application, feedId);
|
|
29
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
30
|
+
return response.status(404).render("404");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Run feed discovery on the current URL
|
|
34
|
+
try {
|
|
35
|
+
const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
|
|
36
|
+
const bestFeed = getBestFeed(discoveredFeeds);
|
|
37
|
+
|
|
38
|
+
if (bestFeed && bestFeed.url !== feed.url) {
|
|
39
|
+
// Found a different (better) feed URL - update the record
|
|
40
|
+
await updateFeed(application, feedId, {
|
|
41
|
+
url: bestFeed.url,
|
|
42
|
+
title: bestFeed.title || feed.title,
|
|
43
|
+
status: "active",
|
|
44
|
+
lastError: undefined,
|
|
45
|
+
lastErrorAt: undefined,
|
|
46
|
+
consecutiveErrors: 0,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
console.info(
|
|
50
|
+
`[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Trigger immediate fetch
|
|
54
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
55
|
+
console.error(
|
|
56
|
+
`[Microsub] Error refreshing rediscovered feed:`,
|
|
57
|
+
error.message,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
} else if (bestFeed) {
|
|
61
|
+
// Same URL but valid - just reset error state and refresh
|
|
62
|
+
await updateFeedStatus(application, feedId, { success: true });
|
|
63
|
+
await updateFeed(application, feedId, {
|
|
64
|
+
status: "active",
|
|
65
|
+
lastError: undefined,
|
|
66
|
+
lastErrorAt: undefined,
|
|
67
|
+
consecutiveErrors: 0,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
71
|
+
console.error(`[Microsub] Error refreshing feed:`, error.message);
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
// No valid feed found
|
|
75
|
+
await updateFeedStatus(application, feedId, {
|
|
76
|
+
success: false,
|
|
77
|
+
error: "No valid feed found at this URL",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await updateFeedStatus(application, feedId, {
|
|
82
|
+
success: false,
|
|
83
|
+
error: error.message,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Force refresh a feed
|
|
92
|
+
* @param {object} request - Express request
|
|
93
|
+
* @param {object} response - Express response
|
|
94
|
+
* @returns {Promise<void>}
|
|
95
|
+
*/
|
|
96
|
+
export async function refreshFeed(request, response) {
|
|
97
|
+
const { application } = request.app.locals;
|
|
98
|
+
const userId = getUserId(request);
|
|
99
|
+
const { uid, feedId } = request.params;
|
|
100
|
+
|
|
101
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
102
|
+
if (!channelDocument) {
|
|
103
|
+
return response.status(404).render("404");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const feed = await getFeedById(application, feedId);
|
|
107
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
108
|
+
return response.status(404).render("404");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Trigger immediate fetch
|
|
112
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
113
|
+
console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
117
|
+
}
|