@rmdes/indiekit-endpoint-microsub 1.0.21 → 1.0.24
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/styles.css +173 -0
- package/index.js +28 -1
- package/lib/controllers/opml.js +151 -0
- package/lib/controllers/reader.js +246 -7
- package/lib/feeds/discovery.js +95 -0
- package/lib/feeds/validator.js +128 -0
- package/lib/polling/processor.js +17 -3
- package/lib/storage/feeds.js +65 -0
- package/lib/storage/items.js +62 -0
- package/locales/en.json +9 -1
- package/package.json +1 -1
- package/views/feed-edit.njk +84 -0
- package/views/feeds.njk +41 -9
- package/views/search.njk +29 -2
package/assets/styles.css
CHANGED
|
@@ -763,3 +763,176 @@
|
|
|
763
763
|
width: 100%;
|
|
764
764
|
}
|
|
765
765
|
}
|
|
766
|
+
|
|
767
|
+
/* ==========================================================================
|
|
768
|
+
Badge extensions for search results
|
|
769
|
+
========================================================================== */
|
|
770
|
+
|
|
771
|
+
/* Extend Indiekit badges with small variant for inline use */
|
|
772
|
+
.badge--small {
|
|
773
|
+
font-size: var(--font-size-small);
|
|
774
|
+
padding: 2px var(--space-xs);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/* ==========================================================================
|
|
778
|
+
Search Enhancements (feed validation)
|
|
779
|
+
========================================================================== */
|
|
780
|
+
|
|
781
|
+
.search__name {
|
|
782
|
+
display: block;
|
|
783
|
+
font-weight: 600;
|
|
784
|
+
margin-bottom: var(--space-xs);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.search__type {
|
|
788
|
+
margin-left: var(--space-xs);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.search__error {
|
|
792
|
+
color: var(--color-error, #ff4444);
|
|
793
|
+
display: block;
|
|
794
|
+
font-size: var(--font-size-small);
|
|
795
|
+
margin-top: var(--space-xs);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.search__item--invalid {
|
|
799
|
+
opacity: 0.7;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.search__item--comments {
|
|
803
|
+
border-left: 3px solid var(--color-warning, #ffcc00);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
.search__subscribe {
|
|
808
|
+
align-items: center;
|
|
809
|
+
display: flex;
|
|
810
|
+
gap: var(--space-s);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/* ==========================================================================
|
|
814
|
+
Notices (inline errors, warnings)
|
|
815
|
+
========================================================================== */
|
|
816
|
+
|
|
817
|
+
.notice {
|
|
818
|
+
border-radius: var(--border-radius-small, var(--border-radius));
|
|
819
|
+
margin-bottom: var(--space-m);
|
|
820
|
+
padding: var(--space-m);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
.notice--error {
|
|
824
|
+
background: var(--color-red90, #fef2f2);
|
|
825
|
+
border: 1px solid var(--color-error, var(--color-red45));
|
|
826
|
+
color: var(--color-red10, #7f1d1d);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
.notice--warning {
|
|
830
|
+
background: var(--color-yellow90, #fefce8);
|
|
831
|
+
border: 1px solid var(--color-yellow50, #eab308);
|
|
832
|
+
color: var(--color-yellow10, #713f12);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.notice--success {
|
|
836
|
+
background: var(--color-green90, #f0fdf4);
|
|
837
|
+
border: 1px solid var(--color-success, var(--color-green50));
|
|
838
|
+
color: var(--color-green10, #14532d);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/* ==========================================================================
|
|
842
|
+
Feed Management Enhancements
|
|
843
|
+
========================================================================== */
|
|
844
|
+
|
|
845
|
+
.feeds__item--error {
|
|
846
|
+
border-left: 3px solid var(--color-error, #ff4444);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.feeds__error {
|
|
850
|
+
color: var(--color-error, #ff4444);
|
|
851
|
+
display: block;
|
|
852
|
+
font-size: var(--font-size-small);
|
|
853
|
+
margin-top: var(--space-xs);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.feeds__error-count {
|
|
857
|
+
color: var(--color-warning, #ffcc00);
|
|
858
|
+
display: block;
|
|
859
|
+
font-size: var(--font-size-small);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
.feeds__meta {
|
|
863
|
+
color: var(--color-text-muted);
|
|
864
|
+
display: block;
|
|
865
|
+
font-size: var(--font-size-small);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
.feeds__details {
|
|
869
|
+
display: flex;
|
|
870
|
+
flex-direction: column;
|
|
871
|
+
flex: 1;
|
|
872
|
+
min-width: 0;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.feeds__actions {
|
|
876
|
+
align-items: center;
|
|
877
|
+
display: flex;
|
|
878
|
+
flex-shrink: 0;
|
|
879
|
+
gap: var(--space-xs);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
.feeds__actions form {
|
|
883
|
+
display: inline;
|
|
884
|
+
margin: 0;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
/* ==========================================================================
|
|
889
|
+
Feed Edit Page
|
|
890
|
+
========================================================================== */
|
|
891
|
+
|
|
892
|
+
.feed-edit {
|
|
893
|
+
max-width: 40rem;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.feed-edit__current {
|
|
897
|
+
background: var(--color-offset);
|
|
898
|
+
border-radius: var(--border-radius);
|
|
899
|
+
margin-bottom: var(--space-l);
|
|
900
|
+
padding: var(--space-m);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
.feed-edit__url {
|
|
904
|
+
color: var(--color-text-muted);
|
|
905
|
+
font-size: var(--font-size-small);
|
|
906
|
+
overflow-wrap: break-word;
|
|
907
|
+
word-break: break-all;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.feed-edit__title {
|
|
911
|
+
font-weight: 600;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.feed-edit__form {
|
|
915
|
+
margin-bottom: var(--space-l);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
.feed-edit__help {
|
|
919
|
+
color: var(--color-text-muted);
|
|
920
|
+
font-size: var(--font-size-small);
|
|
921
|
+
margin-bottom: var(--space-m);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
.feed-edit__actions {
|
|
925
|
+
display: flex;
|
|
926
|
+
flex-direction: column;
|
|
927
|
+
gap: var(--space-m);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.feed-edit__action {
|
|
931
|
+
background: var(--color-offset);
|
|
932
|
+
border-radius: var(--border-radius);
|
|
933
|
+
padding: var(--space-m);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
.feed-edit__action p {
|
|
937
|
+
margin-bottom: var(--space-s);
|
|
938
|
+
}
|
package/index.js
CHANGED
|
@@ -3,10 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import express from "express";
|
|
4
4
|
|
|
5
5
|
import { microsubController } from "./lib/controllers/microsub.js";
|
|
6
|
+
import { opmlController } from "./lib/controllers/opml.js";
|
|
6
7
|
import { readerController } from "./lib/controllers/reader.js";
|
|
7
8
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
8
9
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
9
|
-
import { createIndexes } from "./lib/storage/items.js";
|
|
10
|
+
import { cleanupAllReadItems, createIndexes } from "./lib/storage/items.js";
|
|
10
11
|
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
11
12
|
import { websubHandler } from "./lib/websub/handler.js";
|
|
12
13
|
|
|
@@ -90,6 +91,26 @@ export default class MicrosubEndpoint {
|
|
|
90
91
|
"/channels/:uid/feeds/remove",
|
|
91
92
|
readerController.removeFeed,
|
|
92
93
|
);
|
|
94
|
+
readerRouter.get(
|
|
95
|
+
"/channels/:uid/feeds/:feedId",
|
|
96
|
+
readerController.feedDetails,
|
|
97
|
+
);
|
|
98
|
+
readerRouter.get(
|
|
99
|
+
"/channels/:uid/feeds/:feedId/edit",
|
|
100
|
+
readerController.editFeedForm,
|
|
101
|
+
);
|
|
102
|
+
readerRouter.post(
|
|
103
|
+
"/channels/:uid/feeds/:feedId/edit",
|
|
104
|
+
readerController.updateFeedUrl,
|
|
105
|
+
);
|
|
106
|
+
readerRouter.post(
|
|
107
|
+
"/channels/:uid/feeds/:feedId/rediscover",
|
|
108
|
+
readerController.rediscoverFeed,
|
|
109
|
+
);
|
|
110
|
+
readerRouter.post(
|
|
111
|
+
"/channels/:uid/feeds/:feedId/refresh",
|
|
112
|
+
readerController.refreshFeed,
|
|
113
|
+
);
|
|
93
114
|
readerRouter.get("/item/:id", readerController.item);
|
|
94
115
|
readerRouter.get("/compose", readerController.compose);
|
|
95
116
|
readerRouter.post("/compose", readerController.submitCompose);
|
|
@@ -97,6 +118,7 @@ export default class MicrosubEndpoint {
|
|
|
97
118
|
readerRouter.post("/search", readerController.searchFeeds);
|
|
98
119
|
readerRouter.post("/subscribe", readerController.subscribe);
|
|
99
120
|
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
|
121
|
+
readerRouter.get("/opml", opmlController.exportOpml);
|
|
100
122
|
router.use("/reader", readerRouter);
|
|
101
123
|
|
|
102
124
|
return router;
|
|
@@ -157,6 +179,11 @@ export default class MicrosubEndpoint {
|
|
|
157
179
|
createIndexes(indiekit).catch((error) => {
|
|
158
180
|
console.warn("[Microsub] Index creation failed:", error.message);
|
|
159
181
|
});
|
|
182
|
+
|
|
183
|
+
// Cleanup old read items on startup
|
|
184
|
+
cleanupAllReadItems(indiekit).catch((error) => {
|
|
185
|
+
console.warn("[Microsub] Startup cleanup failed:", error.message);
|
|
186
|
+
});
|
|
160
187
|
} else {
|
|
161
188
|
console.warn(
|
|
162
189
|
"[Microsub] Database not available at init, scheduler not started",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPML export controller
|
|
3
|
+
* @module controllers/opml
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getChannels } from "../storage/channels.js";
|
|
7
|
+
import { getFeedsForChannel } from "../storage/feeds.js";
|
|
8
|
+
import { getUserId } from "../utils/auth.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate OPML export of all subscriptions
|
|
12
|
+
* GET /opml
|
|
13
|
+
* @param {object} request - Express request
|
|
14
|
+
* @param {object} response - Express response
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
async function exportOpml(request, response) {
|
|
18
|
+
const { application } = request.app.locals;
|
|
19
|
+
const userId = getUserId(request);
|
|
20
|
+
|
|
21
|
+
const channels = await getChannels(application, userId);
|
|
22
|
+
|
|
23
|
+
// Build OPML structure
|
|
24
|
+
const outlines = [];
|
|
25
|
+
|
|
26
|
+
for (const channel of channels) {
|
|
27
|
+
const feeds = await getFeedsForChannel(application, channel._id);
|
|
28
|
+
|
|
29
|
+
if (feeds.length === 0) continue;
|
|
30
|
+
|
|
31
|
+
const channelOutlines = feeds.map((feed) => ({
|
|
32
|
+
text: feed.title || extractDomain(feed.url),
|
|
33
|
+
title: feed.title || "",
|
|
34
|
+
type: "rss",
|
|
35
|
+
xmlUrl: feed.url,
|
|
36
|
+
htmlUrl: deriveSiteUrl(feed.url),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
outlines.push({
|
|
40
|
+
text: channel.name,
|
|
41
|
+
title: channel.name,
|
|
42
|
+
children: channelOutlines,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const siteUrl = application.publication?.me || "https://example.com";
|
|
47
|
+
const siteName = extractDomain(siteUrl);
|
|
48
|
+
|
|
49
|
+
const opml = generateOpmlXml({
|
|
50
|
+
title: `${siteName} - Microsub Subscriptions`,
|
|
51
|
+
dateCreated: new Date().toUTCString(),
|
|
52
|
+
ownerName: userId,
|
|
53
|
+
outlines,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
response.set("Content-Type", "text/x-opml");
|
|
57
|
+
response.set(
|
|
58
|
+
"Content-Disposition",
|
|
59
|
+
'attachment; filename="subscriptions.opml"',
|
|
60
|
+
);
|
|
61
|
+
response.send(opml);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate OPML XML from data
|
|
66
|
+
* @param {object} data - OPML data
|
|
67
|
+
* @param {string} data.title - Document title
|
|
68
|
+
* @param {string} data.dateCreated - Creation date
|
|
69
|
+
* @param {string} data.ownerName - Owner name
|
|
70
|
+
* @param {Array} data.outlines - Outline items
|
|
71
|
+
* @returns {string} OPML XML string
|
|
72
|
+
*/
|
|
73
|
+
function generateOpmlXml({ title, dateCreated, ownerName, outlines }) {
|
|
74
|
+
const renderOutline = (outline, indent = " ") => {
|
|
75
|
+
if (outline.children) {
|
|
76
|
+
const childrenXml = outline.children
|
|
77
|
+
.map((child) => renderOutline(child, indent + " "))
|
|
78
|
+
.join("\n");
|
|
79
|
+
return `${indent}<outline text="${escapeXml(outline.text)}" title="${escapeXml(outline.title)}">\n${childrenXml}\n${indent}</outline>`;
|
|
80
|
+
}
|
|
81
|
+
return `${indent}<outline text="${escapeXml(outline.text)}" title="${escapeXml(outline.title)}" type="${outline.type}" xmlUrl="${escapeXml(outline.xmlUrl)}" htmlUrl="${escapeXml(outline.htmlUrl)}"/>`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const outlinesXml = outlines.map((o) => renderOutline(o)).join("\n");
|
|
85
|
+
|
|
86
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
87
|
+
<opml version="2.0">
|
|
88
|
+
<head>
|
|
89
|
+
<title>${escapeXml(title)}</title>
|
|
90
|
+
<dateCreated>${dateCreated}</dateCreated>
|
|
91
|
+
<ownerName>${escapeXml(ownerName)}</ownerName>
|
|
92
|
+
</head>
|
|
93
|
+
<body>
|
|
94
|
+
${outlinesXml}
|
|
95
|
+
</body>
|
|
96
|
+
</opml>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Escape XML special characters
|
|
101
|
+
* @param {string} str - String to escape
|
|
102
|
+
* @returns {string} Escaped string
|
|
103
|
+
*/
|
|
104
|
+
function escapeXml(str) {
|
|
105
|
+
if (!str) return "";
|
|
106
|
+
return String(str)
|
|
107
|
+
.replace(/&/g, "&")
|
|
108
|
+
.replace(/</g, "<")
|
|
109
|
+
.replace(/>/g, ">")
|
|
110
|
+
.replace(/"/g, """)
|
|
111
|
+
.replace(/'/g, "'");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extract domain from URL
|
|
116
|
+
* @param {string} url - URL to extract domain from
|
|
117
|
+
* @returns {string} Domain
|
|
118
|
+
*/
|
|
119
|
+
function extractDomain(url) {
|
|
120
|
+
try {
|
|
121
|
+
return new URL(url).hostname;
|
|
122
|
+
} catch {
|
|
123
|
+
return url;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Derive site URL from feed URL
|
|
129
|
+
* @param {string} feedUrl - Feed URL
|
|
130
|
+
* @returns {string} Site URL
|
|
131
|
+
*/
|
|
132
|
+
function deriveSiteUrl(feedUrl) {
|
|
133
|
+
try {
|
|
134
|
+
const url = new URL(feedUrl);
|
|
135
|
+
// Remove common feed paths
|
|
136
|
+
const path = url.pathname
|
|
137
|
+
.replace(/\/feed\/?$/, "")
|
|
138
|
+
.replace(/\/rss\/?$/, "")
|
|
139
|
+
.replace(/\/atom\.xml$/, "")
|
|
140
|
+
.replace(/\/rss\.xml$/, "")
|
|
141
|
+
.replace(/\/feed\.xml$/, "")
|
|
142
|
+
.replace(/\/index\.xml$/, "")
|
|
143
|
+
.replace(/\.rss$/, "")
|
|
144
|
+
.replace(/\.atom$/, "");
|
|
145
|
+
return `${url.origin}${path || "/"}`;
|
|
146
|
+
} catch {
|
|
147
|
+
return feedUrl;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const opmlController = { exportOpml };
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* @module controllers/reader
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { discoverAndValidateFeeds, getBestFeed } from "../feeds/discovery.js";
|
|
7
|
+
import { validateFeedUrl } from "../feeds/validator.js";
|
|
8
|
+
import { ObjectId } from "mongodb";
|
|
7
9
|
import { refreshFeedNow } from "../polling/scheduler.js";
|
|
8
10
|
import {
|
|
9
11
|
getChannels,
|
|
@@ -14,8 +16,11 @@ import {
|
|
|
14
16
|
} from "../storage/channels.js";
|
|
15
17
|
import {
|
|
16
18
|
getFeedsForChannel,
|
|
19
|
+
getFeedById,
|
|
17
20
|
createFeed,
|
|
18
21
|
deleteFeed,
|
|
22
|
+
updateFeed,
|
|
23
|
+
updateFeedStatus,
|
|
19
24
|
} from "../storage/feeds.js";
|
|
20
25
|
import {
|
|
21
26
|
getTimelineItems,
|
|
@@ -585,7 +590,7 @@ export async function searchPage(request, response) {
|
|
|
585
590
|
}
|
|
586
591
|
|
|
587
592
|
/**
|
|
588
|
-
* Search for feeds from URL
|
|
593
|
+
* Search for feeds from URL - enhanced with validation
|
|
589
594
|
* @param {object} request - Express request
|
|
590
595
|
* @param {object} response - Express response
|
|
591
596
|
* @returns {Promise<void>}
|
|
@@ -598,11 +603,14 @@ export async function searchFeeds(request, response) {
|
|
|
598
603
|
const channelList = await getChannels(application, userId);
|
|
599
604
|
|
|
600
605
|
let results = [];
|
|
606
|
+
let discoveryError = null;
|
|
607
|
+
|
|
601
608
|
if (query) {
|
|
602
609
|
try {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
610
|
+
// Use enhanced discovery with validation
|
|
611
|
+
results = await discoverAndValidateFeeds(query);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
discoveryError = error.message;
|
|
606
614
|
}
|
|
607
615
|
}
|
|
608
616
|
|
|
@@ -611,13 +619,14 @@ export async function searchFeeds(request, response) {
|
|
|
611
619
|
channels: channelList,
|
|
612
620
|
query,
|
|
613
621
|
results,
|
|
622
|
+
discoveryError,
|
|
614
623
|
searched: true,
|
|
615
624
|
baseUrl: request.baseUrl,
|
|
616
625
|
});
|
|
617
626
|
}
|
|
618
627
|
|
|
619
628
|
/**
|
|
620
|
-
* Subscribe to a feed from search results
|
|
629
|
+
* Subscribe to a feed from search results - with validation
|
|
621
630
|
* @param {object} request - Express request
|
|
622
631
|
* @param {object} response - Express response
|
|
623
632
|
* @returns {Promise<void>}
|
|
@@ -625,13 +634,34 @@ export async function searchFeeds(request, response) {
|
|
|
625
634
|
export async function subscribe(request, response) {
|
|
626
635
|
const { application } = request.app.locals;
|
|
627
636
|
const userId = getUserId(request);
|
|
628
|
-
const { url, channel: channelUid } = request.body;
|
|
637
|
+
const { url, channel: channelUid, skipValidation } = request.body;
|
|
629
638
|
|
|
630
639
|
const channelDocument = await getChannel(application, channelUid, userId);
|
|
631
640
|
if (!channelDocument) {
|
|
632
641
|
return response.status(404).render("404");
|
|
633
642
|
}
|
|
634
643
|
|
|
644
|
+
// Validate feed unless explicitly skipped (for power users)
|
|
645
|
+
if (!skipValidation) {
|
|
646
|
+
const validation = await validateFeedUrl(url);
|
|
647
|
+
|
|
648
|
+
if (!validation.valid) {
|
|
649
|
+
const channelList = await getChannels(application, userId);
|
|
650
|
+
return response.render("search", {
|
|
651
|
+
title: request.__("microsub.search.title"),
|
|
652
|
+
channels: channelList,
|
|
653
|
+
query: url,
|
|
654
|
+
validationError: validation.error,
|
|
655
|
+
baseUrl: request.baseUrl,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Warn about comments feeds but allow subscription
|
|
660
|
+
if (validation.isCommentsFeed) {
|
|
661
|
+
console.warn(`[Microsub] Subscribing to comments feed: ${url}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
635
665
|
// Create feed subscription
|
|
636
666
|
const feed = await createFeed(application, {
|
|
637
667
|
channelId: channelDocument._id,
|
|
@@ -675,6 +705,210 @@ export async function markAllRead(request, response) {
|
|
|
675
705
|
response.redirect(`${request.baseUrl}/channels/${channelUid}`);
|
|
676
706
|
}
|
|
677
707
|
|
|
708
|
+
/**
|
|
709
|
+
* View single feed details with status - redirects to edit form
|
|
710
|
+
* @param {object} request - Express request
|
|
711
|
+
* @param {object} response - Express response
|
|
712
|
+
* @returns {Promise<void>}
|
|
713
|
+
*/
|
|
714
|
+
export async function feedDetails(request, response) {
|
|
715
|
+
const { uid, feedId } = request.params;
|
|
716
|
+
// Redirect to edit form which shows all details
|
|
717
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds/${feedId}/edit`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Edit feed URL form
|
|
722
|
+
* @param {object} request - Express request
|
|
723
|
+
* @param {object} response - Express response
|
|
724
|
+
* @returns {Promise<void>}
|
|
725
|
+
*/
|
|
726
|
+
export async function editFeedForm(request, response) {
|
|
727
|
+
const { application } = request.app.locals;
|
|
728
|
+
const userId = getUserId(request);
|
|
729
|
+
const { uid, feedId } = request.params;
|
|
730
|
+
|
|
731
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
732
|
+
if (!channelDocument) {
|
|
733
|
+
return response.status(404).render("404");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const feed = await getFeedById(application, feedId);
|
|
737
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
738
|
+
return response.status(404).render("404");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
response.render("feed-edit", {
|
|
742
|
+
title: request.__("microsub.feeds.edit"),
|
|
743
|
+
channel: channelDocument,
|
|
744
|
+
feed,
|
|
745
|
+
baseUrl: request.baseUrl,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Update feed URL
|
|
751
|
+
* @param {object} request - Express request
|
|
752
|
+
* @param {object} response - Express response
|
|
753
|
+
* @returns {Promise<void>}
|
|
754
|
+
*/
|
|
755
|
+
export async function updateFeedUrl(request, response) {
|
|
756
|
+
const { application } = request.app.locals;
|
|
757
|
+
const userId = getUserId(request);
|
|
758
|
+
const { uid, feedId } = request.params;
|
|
759
|
+
const { url: newUrl } = request.body;
|
|
760
|
+
|
|
761
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
762
|
+
if (!channelDocument) {
|
|
763
|
+
return response.status(404).render("404");
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const feed = await getFeedById(application, feedId);
|
|
767
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
768
|
+
return response.status(404).render("404");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Validate the new URL is a valid feed
|
|
772
|
+
const validation = await validateFeedUrl(newUrl);
|
|
773
|
+
|
|
774
|
+
if (!validation.valid) {
|
|
775
|
+
return response.render("feed-edit", {
|
|
776
|
+
title: request.__("microsub.feeds.edit"),
|
|
777
|
+
channel: channelDocument,
|
|
778
|
+
feed,
|
|
779
|
+
error: validation.error,
|
|
780
|
+
baseUrl: request.baseUrl,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Update the feed URL and reset error state
|
|
785
|
+
await updateFeed(application, feedId, {
|
|
786
|
+
url: newUrl,
|
|
787
|
+
title: validation.title || feed.title,
|
|
788
|
+
status: "active",
|
|
789
|
+
lastError: undefined,
|
|
790
|
+
lastErrorAt: undefined,
|
|
791
|
+
consecutiveErrors: 0,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Trigger immediate fetch
|
|
795
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
796
|
+
console.error(
|
|
797
|
+
`[Microsub] Error refreshing updated feed ${newUrl}:`,
|
|
798
|
+
error.message,
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Rediscover feed - run discovery on URL to find actual RSS feed
|
|
807
|
+
* @param {object} request - Express request
|
|
808
|
+
* @param {object} response - Express response
|
|
809
|
+
* @returns {Promise<void>}
|
|
810
|
+
*/
|
|
811
|
+
export async function rediscoverFeed(request, response) {
|
|
812
|
+
const { application } = request.app.locals;
|
|
813
|
+
const userId = getUserId(request);
|
|
814
|
+
const { uid, feedId } = request.params;
|
|
815
|
+
|
|
816
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
817
|
+
if (!channelDocument) {
|
|
818
|
+
return response.status(404).render("404");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const feed = await getFeedById(application, feedId);
|
|
822
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
823
|
+
return response.status(404).render("404");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Run feed discovery on the current URL
|
|
827
|
+
try {
|
|
828
|
+
const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
|
|
829
|
+
const bestFeed = getBestFeed(discoveredFeeds);
|
|
830
|
+
|
|
831
|
+
if (bestFeed && bestFeed.url !== feed.url) {
|
|
832
|
+
// Found a different (better) feed URL - update the record
|
|
833
|
+
await updateFeed(application, feedId, {
|
|
834
|
+
url: bestFeed.url,
|
|
835
|
+
title: bestFeed.title || feed.title,
|
|
836
|
+
status: "active",
|
|
837
|
+
lastError: undefined,
|
|
838
|
+
lastErrorAt: undefined,
|
|
839
|
+
consecutiveErrors: 0,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
console.info(
|
|
843
|
+
`[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
// Trigger immediate fetch
|
|
847
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
848
|
+
console.error(
|
|
849
|
+
`[Microsub] Error refreshing rediscovered feed:`,
|
|
850
|
+
error.message,
|
|
851
|
+
);
|
|
852
|
+
});
|
|
853
|
+
} else if (bestFeed) {
|
|
854
|
+
// Same URL but valid - just reset error state and refresh
|
|
855
|
+
await updateFeedStatus(application, feedId, { success: true });
|
|
856
|
+
await updateFeed(application, feedId, {
|
|
857
|
+
status: "active",
|
|
858
|
+
lastError: undefined,
|
|
859
|
+
lastErrorAt: undefined,
|
|
860
|
+
consecutiveErrors: 0,
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
864
|
+
console.error(`[Microsub] Error refreshing feed:`, error.message);
|
|
865
|
+
});
|
|
866
|
+
} else {
|
|
867
|
+
// No valid feed found
|
|
868
|
+
await updateFeedStatus(application, feedId, {
|
|
869
|
+
success: false,
|
|
870
|
+
error: "No valid feed found at this URL",
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
} catch (error) {
|
|
874
|
+
await updateFeedStatus(application, feedId, {
|
|
875
|
+
success: false,
|
|
876
|
+
error: error.message,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Force refresh a feed
|
|
885
|
+
* @param {object} request - Express request
|
|
886
|
+
* @param {object} response - Express response
|
|
887
|
+
* @returns {Promise<void>}
|
|
888
|
+
*/
|
|
889
|
+
export async function refreshFeed(request, response) {
|
|
890
|
+
const { application } = request.app.locals;
|
|
891
|
+
const userId = getUserId(request);
|
|
892
|
+
const { uid, feedId } = request.params;
|
|
893
|
+
|
|
894
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
895
|
+
if (!channelDocument) {
|
|
896
|
+
return response.status(404).render("404");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const feed = await getFeedById(application, feedId);
|
|
900
|
+
if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
|
|
901
|
+
return response.status(404).render("404");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Trigger immediate fetch
|
|
905
|
+
refreshFeedNow(application, feedId).catch((error) => {
|
|
906
|
+
console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
910
|
+
}
|
|
911
|
+
|
|
678
912
|
export const readerController = {
|
|
679
913
|
index,
|
|
680
914
|
channels,
|
|
@@ -688,6 +922,11 @@ export const readerController = {
|
|
|
688
922
|
feeds,
|
|
689
923
|
addFeed,
|
|
690
924
|
removeFeed,
|
|
925
|
+
feedDetails,
|
|
926
|
+
editFeedForm,
|
|
927
|
+
updateFeedUrl,
|
|
928
|
+
rediscoverFeed,
|
|
929
|
+
refreshFeed,
|
|
691
930
|
item,
|
|
692
931
|
compose,
|
|
693
932
|
submitCompose,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced feed discovery with type labels and validation
|
|
3
|
+
* @module feeds/discovery
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { discoverFeedsFromUrl } from "./fetcher.js";
|
|
7
|
+
import { validateFeedUrl } from "./validator.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Feed type display labels
|
|
11
|
+
*/
|
|
12
|
+
const FEED_TYPE_LABELS = {
|
|
13
|
+
rss: "RSS Feed",
|
|
14
|
+
atom: "Atom Feed",
|
|
15
|
+
jsonfeed: "JSON Feed",
|
|
16
|
+
hfeed: "h-feed (Microformats)",
|
|
17
|
+
activitypub: "ActivityPub",
|
|
18
|
+
unknown: "Unknown",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Discover and validate all feeds from a URL
|
|
23
|
+
* @param {string} url - Page or feed URL
|
|
24
|
+
* @returns {Promise<Array>} Array of discovered feeds with validation status
|
|
25
|
+
*/
|
|
26
|
+
export async function discoverAndValidateFeeds(url) {
|
|
27
|
+
// First discover feeds from the URL
|
|
28
|
+
const feeds = await discoverFeedsFromUrl(url);
|
|
29
|
+
|
|
30
|
+
// If no feeds found, return empty with error info
|
|
31
|
+
if (feeds.length === 0) {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
url,
|
|
35
|
+
type: "unknown",
|
|
36
|
+
typeLabel: "No feed found",
|
|
37
|
+
valid: false,
|
|
38
|
+
error: "No feeds were discovered at this URL",
|
|
39
|
+
isCommentsFeed: false,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Validate each discovered feed in parallel
|
|
45
|
+
const validatedFeeds = await Promise.all(
|
|
46
|
+
feeds.map(async (feed) => {
|
|
47
|
+
const validation = await validateFeedUrl(feed.url);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
url: feed.url,
|
|
51
|
+
type: validation.feedType || feed.type,
|
|
52
|
+
typeLabel:
|
|
53
|
+
FEED_TYPE_LABELS[validation.feedType] ||
|
|
54
|
+
FEED_TYPE_LABELS[feed.type] ||
|
|
55
|
+
"Feed",
|
|
56
|
+
valid: validation.valid,
|
|
57
|
+
error: validation.error,
|
|
58
|
+
isCommentsFeed: validation.isCommentsFeed || false,
|
|
59
|
+
title: validation.title || feed.title,
|
|
60
|
+
rel: feed.rel,
|
|
61
|
+
};
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Sort: valid feeds first, non-comments before comments, then alphabetically
|
|
66
|
+
return validatedFeeds.sort((a, b) => {
|
|
67
|
+
// Valid feeds first
|
|
68
|
+
if (a.valid !== b.valid) return a.valid ? -1 : 1;
|
|
69
|
+
// Non-comments before comments
|
|
70
|
+
if (a.isCommentsFeed !== b.isCommentsFeed) return a.isCommentsFeed ? 1 : -1;
|
|
71
|
+
// Then by URL
|
|
72
|
+
return a.url.localeCompare(b.url);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Filter to only main content feeds (exclude comments)
|
|
78
|
+
* @param {Array} feeds - Array of feed objects
|
|
79
|
+
* @returns {Array} Filtered array of main content feeds
|
|
80
|
+
*/
|
|
81
|
+
export function filterMainFeeds(feeds) {
|
|
82
|
+
return feeds.filter((feed) => feed.valid && !feed.isCommentsFeed);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get the best feed from a list (first valid, non-comments feed)
|
|
87
|
+
* @param {Array} feeds - Array of feed objects
|
|
88
|
+
* @returns {object|undefined} Best feed or undefined
|
|
89
|
+
*/
|
|
90
|
+
export function getBestFeed(feeds) {
|
|
91
|
+
const mainFeeds = filterMainFeeds(feeds);
|
|
92
|
+
return mainFeeds.length > 0 ? mainFeeds[0] : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { FEED_TYPE_LABELS };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed validation utilities
|
|
3
|
+
* @module feeds/validator
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fetchFeed } from "./fetcher.js";
|
|
7
|
+
import { detectFeedType } from "./parser.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Feed types that are valid subscriptions
|
|
11
|
+
*/
|
|
12
|
+
const VALID_FEED_TYPES = ["rss", "atom", "jsonfeed", "hfeed"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Patterns that indicate a comments feed (not a main feed)
|
|
16
|
+
*/
|
|
17
|
+
const COMMENTS_PATTERNS = [
|
|
18
|
+
/\/comments\/?$/i,
|
|
19
|
+
/\/feed\/comments/i,
|
|
20
|
+
/commentsfeed/i,
|
|
21
|
+
/comment-feed/i,
|
|
22
|
+
/-comments\.xml$/i,
|
|
23
|
+
/\/replies\/?$/i,
|
|
24
|
+
/comments\.rss$/i,
|
|
25
|
+
/comments\.atom$/i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a URL is actually a feed
|
|
30
|
+
* @param {string} url - URL to validate
|
|
31
|
+
* @returns {Promise<object>} Validation result
|
|
32
|
+
*/
|
|
33
|
+
export async function validateFeedUrl(url) {
|
|
34
|
+
try {
|
|
35
|
+
const result = await fetchFeed(url, { timeout: 15000 });
|
|
36
|
+
|
|
37
|
+
if (result.notModified || !result.content) {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: "Unable to fetch content from URL",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const feedType = detectFeedType(result.content, result.contentType);
|
|
45
|
+
|
|
46
|
+
if (feedType === "activitypub") {
|
|
47
|
+
return {
|
|
48
|
+
valid: false,
|
|
49
|
+
error:
|
|
50
|
+
"URL returns ActivityPub JSON instead of a feed. Try the direct feed URL.",
|
|
51
|
+
feedType,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!VALID_FEED_TYPES.includes(feedType)) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: `URL does not contain a valid feed (detected: ${feedType})`,
|
|
59
|
+
feedType,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if it's a comments feed
|
|
64
|
+
const isCommentsFeed = COMMENTS_PATTERNS.some((pattern) =>
|
|
65
|
+
pattern.test(url),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
valid: true,
|
|
70
|
+
feedType,
|
|
71
|
+
isCommentsFeed,
|
|
72
|
+
title: extractFeedTitle(result.content, feedType),
|
|
73
|
+
contentType: result.contentType,
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return {
|
|
77
|
+
valid: false,
|
|
78
|
+
error: error.message,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract feed title from content
|
|
85
|
+
* @param {string} content - Feed content
|
|
86
|
+
* @param {string} feedType - Type of feed
|
|
87
|
+
* @returns {string|undefined} Feed title
|
|
88
|
+
*/
|
|
89
|
+
function extractFeedTitle(content, feedType) {
|
|
90
|
+
if (feedType === "jsonfeed") {
|
|
91
|
+
try {
|
|
92
|
+
const json = JSON.parse(content);
|
|
93
|
+
return json.title;
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Extract title from XML (RSS or Atom)
|
|
100
|
+
// Try channel/title first (RSS), then just title (Atom)
|
|
101
|
+
const channelTitleMatch = content.match(
|
|
102
|
+
/<channel[^>]*>[\s\S]*?<title[^>]*>([^<]+)<\/title>/i,
|
|
103
|
+
);
|
|
104
|
+
if (channelTitleMatch) {
|
|
105
|
+
return decodeXmlEntities(channelTitleMatch[1].trim());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const titleMatch = content.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
109
|
+
return titleMatch ? decodeXmlEntities(titleMatch[1].trim()) : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Decode XML entities
|
|
114
|
+
* @param {string} str - String with XML entities
|
|
115
|
+
* @returns {string} Decoded string
|
|
116
|
+
*/
|
|
117
|
+
function decodeXmlEntities(str) {
|
|
118
|
+
return str
|
|
119
|
+
.replace(/&/g, "&")
|
|
120
|
+
.replace(/</g, "<")
|
|
121
|
+
.replace(/>/g, ">")
|
|
122
|
+
.replace(/"/g, '"')
|
|
123
|
+
.replace(/'/g, "'")
|
|
124
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
|
|
125
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) =>
|
|
126
|
+
String.fromCharCode(parseInt(code, 16)),
|
|
127
|
+
);
|
|
128
|
+
}
|
package/lib/polling/processor.js
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
7
7
|
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
|
8
8
|
import { getChannel } from "../storage/channels.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
updateFeedAfterFetch,
|
|
11
|
+
updateFeedStatus,
|
|
12
|
+
updateFeedWebsub,
|
|
13
|
+
} from "../storage/feeds.js";
|
|
10
14
|
import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
|
|
11
15
|
import { addItem } from "../storage/items.js";
|
|
12
16
|
import {
|
|
@@ -167,9 +171,21 @@ export async function processFeed(application, feed) {
|
|
|
167
171
|
|
|
168
172
|
result.success = true;
|
|
169
173
|
result.tier = tierResult.tier;
|
|
174
|
+
|
|
175
|
+
// Update feed status to active on success
|
|
176
|
+
await updateFeedStatus(application, feed._id, {
|
|
177
|
+
success: true,
|
|
178
|
+
itemCount: parsed.items?.length || 0,
|
|
179
|
+
});
|
|
170
180
|
} catch (error) {
|
|
171
181
|
result.error = error.message;
|
|
172
182
|
|
|
183
|
+
// Update feed status to error
|
|
184
|
+
await updateFeedStatus(application, feed._id, {
|
|
185
|
+
success: false,
|
|
186
|
+
error: error.message,
|
|
187
|
+
});
|
|
188
|
+
|
|
173
189
|
// Still update the feed to prevent retry storms
|
|
174
190
|
try {
|
|
175
191
|
const tierResult = calculateNewTier({
|
|
@@ -182,8 +198,6 @@ export async function processFeed(application, feed) {
|
|
|
182
198
|
tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error
|
|
183
199
|
unmodified: tierResult.consecutiveUnchanged,
|
|
184
200
|
nextFetchAt: tierResult.nextFetchAt,
|
|
185
|
-
lastError: error.message,
|
|
186
|
-
lastErrorAt: new Date(),
|
|
187
201
|
});
|
|
188
202
|
} catch {
|
|
189
203
|
// Ignore update errors
|
package/lib/storage/feeds.js
CHANGED
|
@@ -297,3 +297,68 @@ export async function updateFeedWebsub(application, id, websub) {
|
|
|
297
297
|
export async function getFeedBySubscriptionId(application, subscriptionId) {
|
|
298
298
|
return getFeedById(application, subscriptionId);
|
|
299
299
|
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Update feed status after processing
|
|
303
|
+
* Tracks health status, errors, and success metrics
|
|
304
|
+
* @param {object} application - Indiekit application
|
|
305
|
+
* @param {ObjectId|string} id - Feed ObjectId
|
|
306
|
+
* @param {object} status - Status update
|
|
307
|
+
* @param {boolean} status.success - Whether fetch was successful
|
|
308
|
+
* @param {string} [status.error] - Error message if failed
|
|
309
|
+
* @param {number} [status.itemCount] - Number of items in feed
|
|
310
|
+
* @returns {Promise<object|null>} Updated feed
|
|
311
|
+
*/
|
|
312
|
+
export async function updateFeedStatus(application, id, status) {
|
|
313
|
+
const collection = getCollection(application);
|
|
314
|
+
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
|
315
|
+
|
|
316
|
+
const updateFields = {
|
|
317
|
+
updatedAt: new Date(),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (status.success) {
|
|
321
|
+
updateFields.status = "active";
|
|
322
|
+
updateFields.lastSuccessAt = new Date();
|
|
323
|
+
updateFields.consecutiveErrors = 0;
|
|
324
|
+
updateFields.lastError = undefined;
|
|
325
|
+
updateFields.lastErrorAt = undefined;
|
|
326
|
+
|
|
327
|
+
if (status.itemCount !== undefined) {
|
|
328
|
+
updateFields.itemCount = status.itemCount;
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
updateFields.status = "error";
|
|
332
|
+
updateFields.lastError = status.error;
|
|
333
|
+
updateFields.lastErrorAt = new Date();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Use $set for most fields, $inc for consecutiveErrors on failure
|
|
337
|
+
const updateOp = { $set: updateFields };
|
|
338
|
+
|
|
339
|
+
if (!status.success) {
|
|
340
|
+
// Increment consecutive errors
|
|
341
|
+
updateOp.$inc = { consecutiveErrors: 1 };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return collection.findOneAndUpdate({ _id: objectId }, updateOp, {
|
|
345
|
+
returnDocument: "after",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Get feeds with errors
|
|
351
|
+
* @param {object} application - Indiekit application
|
|
352
|
+
* @param {number} [minErrors=3] - Minimum consecutive errors
|
|
353
|
+
* @returns {Promise<Array>} Array of feeds with errors
|
|
354
|
+
*/
|
|
355
|
+
export async function getFeedsWithErrors(application, minErrors = 3) {
|
|
356
|
+
const collection = getCollection(application);
|
|
357
|
+
|
|
358
|
+
return collection
|
|
359
|
+
.find({
|
|
360
|
+
status: "error",
|
|
361
|
+
consecutiveErrors: { $gte: minErrors },
|
|
362
|
+
})
|
|
363
|
+
.toArray();
|
|
364
|
+
}
|
package/lib/storage/items.js
CHANGED
|
@@ -328,6 +328,68 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
|
328
328
|
}
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Cleanup all read items across all channels (startup cleanup)
|
|
333
|
+
* @param {object} application - Indiekit application
|
|
334
|
+
* @returns {Promise<number>} Total number of items deleted
|
|
335
|
+
*/
|
|
336
|
+
export async function cleanupAllReadItems(application) {
|
|
337
|
+
const collection = getCollection(application);
|
|
338
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
339
|
+
|
|
340
|
+
// Get all channels
|
|
341
|
+
const channels = await channelsCollection.find({}).toArray();
|
|
342
|
+
let totalDeleted = 0;
|
|
343
|
+
|
|
344
|
+
for (const channel of channels) {
|
|
345
|
+
// Get unique userIds who have read items in this channel
|
|
346
|
+
const readByUsers = await collection.distinct("readBy", {
|
|
347
|
+
channelId: channel._id,
|
|
348
|
+
readBy: { $exists: true, $ne: [] },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
for (const userId of readByUsers) {
|
|
352
|
+
if (!userId) continue;
|
|
353
|
+
|
|
354
|
+
const readCount = await collection.countDocuments({
|
|
355
|
+
channelId: channel._id,
|
|
356
|
+
readBy: userId,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (readCount > MAX_READ_ITEMS) {
|
|
360
|
+
const itemsToDelete = await collection
|
|
361
|
+
.find({
|
|
362
|
+
channelId: channel._id,
|
|
363
|
+
readBy: userId,
|
|
364
|
+
})
|
|
365
|
+
.sort({ published: -1, _id: -1 })
|
|
366
|
+
.skip(MAX_READ_ITEMS)
|
|
367
|
+
.project({ _id: 1 })
|
|
368
|
+
.toArray();
|
|
369
|
+
|
|
370
|
+
if (itemsToDelete.length > 0) {
|
|
371
|
+
const idsToDelete = itemsToDelete.map((item) => item._id);
|
|
372
|
+
const deleteResult = await collection.deleteMany({
|
|
373
|
+
_id: { $in: idsToDelete },
|
|
374
|
+
});
|
|
375
|
+
totalDeleted += deleteResult.deletedCount;
|
|
376
|
+
console.info(
|
|
377
|
+
`[Microsub] Startup cleanup: deleted ${deleteResult.deletedCount} old items from channel "${channel.name}"`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (totalDeleted > 0) {
|
|
385
|
+
console.info(
|
|
386
|
+
`[Microsub] Startup cleanup complete: ${totalDeleted} total items deleted`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return totalDeleted;
|
|
391
|
+
}
|
|
392
|
+
|
|
331
393
|
export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
332
394
|
const collection = getCollection(application);
|
|
333
395
|
const channelObjectId =
|
package/locales/en.json
CHANGED
|
@@ -34,7 +34,15 @@
|
|
|
34
34
|
"unfollow": "Unfollow",
|
|
35
35
|
"empty": "No feeds followed in this channel",
|
|
36
36
|
"url": "Feed URL",
|
|
37
|
-
"urlPlaceholder": "https://example.com/feed.xml"
|
|
37
|
+
"urlPlaceholder": "https://example.com/feed.xml",
|
|
38
|
+
"edit": "Edit feed",
|
|
39
|
+
"rediscover": "Rediscover feed",
|
|
40
|
+
"refresh": "Refresh now",
|
|
41
|
+
"status": {
|
|
42
|
+
"active": "Active",
|
|
43
|
+
"error": "Error",
|
|
44
|
+
"stale": "Stale"
|
|
45
|
+
}
|
|
38
46
|
},
|
|
39
47
|
"item": {
|
|
40
48
|
"reply": "Reply",
|
package/package.json
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{% extends "layouts/reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block reader %}
|
|
4
|
+
<div class="settings">
|
|
5
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="back-link">
|
|
6
|
+
{{ icon("previous") }} {{ __("microsub.feeds.title") }}
|
|
7
|
+
</a>
|
|
8
|
+
|
|
9
|
+
<h2>{{ __("microsub.feeds.edit") }}</h2>
|
|
10
|
+
|
|
11
|
+
{% if error %}
|
|
12
|
+
<div class="notice notice--error">
|
|
13
|
+
<p>{{ error }}</p>
|
|
14
|
+
</div>
|
|
15
|
+
{% endif %}
|
|
16
|
+
|
|
17
|
+
<div class="feed-edit">
|
|
18
|
+
<div class="feed-edit__current">
|
|
19
|
+
<h3>Current Feed</h3>
|
|
20
|
+
<p class="feed-edit__url">{{ feed.url }}</p>
|
|
21
|
+
{% if feed.title %}
|
|
22
|
+
<p class="feed-edit__title">{{ feed.title }}</p>
|
|
23
|
+
{% endif %}
|
|
24
|
+
{% if feed.status == 'error' %}
|
|
25
|
+
<div class="notice notice--error">
|
|
26
|
+
<p><strong>Status:</strong> Error</p>
|
|
27
|
+
{% if feed.lastError %}
|
|
28
|
+
<p><strong>Last error:</strong> {{ feed.lastError }}</p>
|
|
29
|
+
{% endif %}
|
|
30
|
+
{% if feed.consecutiveErrors %}
|
|
31
|
+
<p><strong>Consecutive errors:</strong> {{ feed.consecutiveErrors }}</p>
|
|
32
|
+
{% endif %}
|
|
33
|
+
</div>
|
|
34
|
+
{% endif %}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit" class="feed-edit__form">
|
|
38
|
+
{{ input({
|
|
39
|
+
id: "url",
|
|
40
|
+
name: "url",
|
|
41
|
+
label: "New Feed URL",
|
|
42
|
+
type: "url",
|
|
43
|
+
required: true,
|
|
44
|
+
value: feed.url,
|
|
45
|
+
placeholder: "https://example.com/feed.xml",
|
|
46
|
+
autocomplete: "off"
|
|
47
|
+
}) }}
|
|
48
|
+
|
|
49
|
+
<p class="feed-edit__help">
|
|
50
|
+
Enter the direct URL to the RSS, Atom, or JSON Feed. The URL will be validated before updating.
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<div class="button-group">
|
|
54
|
+
{{ button({ text: "Update Feed URL" }) }}
|
|
55
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary">
|
|
56
|
+
Cancel
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
</form>
|
|
60
|
+
|
|
61
|
+
<div class="divider"></div>
|
|
62
|
+
|
|
63
|
+
<div class="feed-edit__actions">
|
|
64
|
+
<h3>Other Actions</h3>
|
|
65
|
+
|
|
66
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" class="feed-edit__action">
|
|
67
|
+
<p>Run feed discovery on the current URL to find the actual RSS/Atom feed.</p>
|
|
68
|
+
{{ button({
|
|
69
|
+
text: "Rediscover Feed",
|
|
70
|
+
classes: "button--secondary"
|
|
71
|
+
}) }}
|
|
72
|
+
</form>
|
|
73
|
+
|
|
74
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" class="feed-edit__action">
|
|
75
|
+
<p>Force refresh this feed now.</p>
|
|
76
|
+
{{ button({
|
|
77
|
+
text: "Refresh Now",
|
|
78
|
+
classes: "button--secondary"
|
|
79
|
+
}) }}
|
|
80
|
+
</form>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
{% endblock %}
|
package/views/feeds.njk
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{% if feeds.length > 0 %}
|
|
14
14
|
<div class="feeds__list">
|
|
15
15
|
{% for feed in feeds %}
|
|
16
|
-
<div class="feeds__item">
|
|
16
|
+
<div class="feeds__item{% if feed.status == 'error' %} feeds__item--error{% endif %}">
|
|
17
17
|
<div class="feeds__info">
|
|
18
18
|
{% if feed.photo %}
|
|
19
19
|
<img src="{{ feed.photo }}"
|
|
@@ -25,19 +25,51 @@
|
|
|
25
25
|
onerror="this.style.display='none'">
|
|
26
26
|
{% endif %}
|
|
27
27
|
<div class="feeds__details">
|
|
28
|
-
<span class="feeds__name">
|
|
28
|
+
<span class="feeds__name">
|
|
29
|
+
{{ feed.title or feed.url }}
|
|
30
|
+
{% if feed.status == 'error' %}
|
|
31
|
+
<span class="badge badge--red">Error</span>
|
|
32
|
+
{% elif feed.status == 'active' %}
|
|
33
|
+
<span class="badge badge--green">Active</span>
|
|
34
|
+
{% endif %}
|
|
35
|
+
</span>
|
|
29
36
|
<a href="{{ feed.url }}" class="feeds__url" target="_blank" rel="noopener">
|
|
30
37
|
{{ feed.url | replace("https://", "") | replace("http://", "") }}
|
|
31
38
|
</a>
|
|
39
|
+
{% if feed.lastError %}
|
|
40
|
+
<span class="feeds__error">{{ feed.lastError }}</span>
|
|
41
|
+
{% endif %}
|
|
42
|
+
{% if feed.consecutiveErrors > 0 %}
|
|
43
|
+
<span class="feeds__error-count">{{ feed.consecutiveErrors }} consecutive errors</span>
|
|
44
|
+
{% endif %}
|
|
45
|
+
{% if feed.lastSuccessAt %}
|
|
46
|
+
<span class="feeds__meta">Last success: {{ feed.lastSuccessAt | date("relative") }}</span>
|
|
47
|
+
{% endif %}
|
|
32
48
|
</div>
|
|
33
49
|
</div>
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
<div class="feeds__actions">
|
|
51
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit"
|
|
52
|
+
class="button button--secondary button--small"
|
|
53
|
+
title="Edit feed URL">
|
|
54
|
+
{{ icon("edit") }}
|
|
55
|
+
</a>
|
|
56
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" style="display:inline;">
|
|
57
|
+
<button type="submit" class="button button--secondary button--small" title="Rediscover feed">
|
|
58
|
+
{{ icon("discover") }}
|
|
59
|
+
</button>
|
|
60
|
+
</form>
|
|
61
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" style="display:inline;">
|
|
62
|
+
<button type="submit" class="button button--secondary button--small" title="Refresh now">
|
|
63
|
+
{{ icon("refresh") }}
|
|
64
|
+
</button>
|
|
65
|
+
</form>
|
|
66
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" style="display:inline;">
|
|
67
|
+
<input type="hidden" name="url" value="{{ feed.url }}">
|
|
68
|
+
<button type="submit" class="button button--warning button--small" title="Unfollow">
|
|
69
|
+
{{ icon("delete") }}
|
|
70
|
+
</button>
|
|
71
|
+
</form>
|
|
72
|
+
</div>
|
|
41
73
|
</div>
|
|
42
74
|
{% endfor %}
|
|
43
75
|
</div>
|
package/views/search.njk
CHANGED
|
@@ -25,16 +25,40 @@
|
|
|
25
25
|
</div>
|
|
26
26
|
</form>
|
|
27
27
|
|
|
28
|
+
{% if validationError %}
|
|
29
|
+
<div class="notice notice--error">
|
|
30
|
+
<p>{{ validationError }}</p>
|
|
31
|
+
</div>
|
|
32
|
+
{% endif %}
|
|
33
|
+
|
|
34
|
+
{% if discoveryError %}
|
|
35
|
+
<div class="notice notice--error">
|
|
36
|
+
<p>{{ discoveryError }}</p>
|
|
37
|
+
</div>
|
|
38
|
+
{% endif %}
|
|
39
|
+
|
|
28
40
|
{% if results and results.length > 0 %}
|
|
29
41
|
<div class="search__results">
|
|
30
42
|
<h3>{{ __("microsub.search.title") }}</h3>
|
|
31
43
|
<div class="search__list">
|
|
32
44
|
{% for result in results %}
|
|
33
|
-
<div class="search__item">
|
|
45
|
+
<div class="search__item{% if not result.valid %} search__item--invalid{% endif %}{% if result.isCommentsFeed %} search__item--comments{% endif %}">
|
|
34
46
|
<div class="search__feed">
|
|
35
|
-
<span class="search__name">
|
|
47
|
+
<span class="search__name">
|
|
48
|
+
{{ result.title or "Feed" }}
|
|
49
|
+
<span class="search__type badge badge--small{% if result.valid %} badge--green{% else %} badge--yellow{% endif %}">
|
|
50
|
+
{{ result.typeLabel }}
|
|
51
|
+
</span>
|
|
52
|
+
{% if result.isCommentsFeed %}
|
|
53
|
+
<span class="search__type badge badge--small badge--yellow">Comments</span>
|
|
54
|
+
{% endif %}
|
|
55
|
+
</span>
|
|
36
56
|
<span class="search__url">{{ result.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
57
|
+
{% if not result.valid %}
|
|
58
|
+
<span class="search__error">{{ result.error }}</span>
|
|
59
|
+
{% endif %}
|
|
37
60
|
</div>
|
|
61
|
+
{% if result.valid %}
|
|
38
62
|
<form method="post" action="{{ baseUrl }}/subscribe" class="search__subscribe">
|
|
39
63
|
<input type="hidden" name="url" value="{{ result.url }}">
|
|
40
64
|
<label for="channel-{{ loop.index }}" class="visually-hidden">{{ __("microsub.channels.title") }}</label>
|
|
@@ -48,6 +72,9 @@
|
|
|
48
72
|
classes: "button--small"
|
|
49
73
|
}) }}
|
|
50
74
|
</form>
|
|
75
|
+
{% else %}
|
|
76
|
+
<span class="badge badge--small badge--red">Invalid</span>
|
|
77
|
+
{% endif %}
|
|
51
78
|
</div>
|
|
52
79
|
{% endfor %}
|
|
53
80
|
</div>
|