@rmdes/indiekit-endpoint-rss 1.0.0

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/index.js ADDED
@@ -0,0 +1,108 @@
1
+ import express from "express";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ import { dashboardController } from "./lib/controllers/dashboard.js";
6
+ import { feedsController } from "./lib/controllers/feeds.js";
7
+ import { itemsController } from "./lib/controllers/items.js";
8
+ import { statusController } from "./lib/controllers/status.js";
9
+ import { startSync } from "./lib/sync.js";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ const protectedRouter = express.Router();
14
+ const publicRouter = express.Router();
15
+
16
+ const defaults = {
17
+ mountPath: "/rssapi",
18
+ syncInterval: 900_000, // 15 minutes
19
+ maxItemsPerFeed: 50,
20
+ fetchTimeout: 10_000,
21
+ maxConcurrentFetches: 3,
22
+ };
23
+
24
+ export default class RssEndpoint {
25
+ name = "RSS feed reader endpoint";
26
+
27
+ constructor(options = {}) {
28
+ this.options = { ...defaults, ...options };
29
+ this.mountPath = this.options.mountPath;
30
+ }
31
+
32
+ get localesDirectory() {
33
+ return path.join(__dirname, "locales");
34
+ }
35
+
36
+ get navigationItems() {
37
+ return {
38
+ href: this.options.mountPath,
39
+ text: "rss.title",
40
+ requiresDatabase: true,
41
+ };
42
+ }
43
+
44
+ get shortcutItems() {
45
+ return {
46
+ url: this.options.mountPath,
47
+ name: "rss.feeds",
48
+ iconName: "syndicate",
49
+ requiresDatabase: true,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Protected routes (require authentication)
55
+ * Admin dashboard and feed management
56
+ */
57
+ get routes() {
58
+ // Dashboard
59
+ protectedRouter.get("/", dashboardController.get);
60
+
61
+ // Manual sync trigger
62
+ protectedRouter.post("/sync", dashboardController.sync);
63
+
64
+ return protectedRouter;
65
+ }
66
+
67
+ /**
68
+ * Public routes (no authentication required)
69
+ * JSON API endpoints for frontend
70
+ */
71
+ get routesPublic() {
72
+ // Feeds API
73
+ publicRouter.get("/api/feeds", feedsController.list);
74
+ publicRouter.post("/api/feeds", express.json(), feedsController.add);
75
+ publicRouter.delete("/api/feeds/:id", feedsController.remove);
76
+ publicRouter.patch("/api/feeds/:id", express.json(), feedsController.toggle);
77
+
78
+ // Items API
79
+ publicRouter.get("/api/items", itemsController.list);
80
+ publicRouter.get("/api/items/:id", itemsController.get);
81
+
82
+ // Status API
83
+ publicRouter.get("/api/status", statusController.status);
84
+ publicRouter.post("/api/refresh", statusController.refresh);
85
+
86
+ return publicRouter;
87
+ }
88
+
89
+ init(Indiekit) {
90
+ Indiekit.addEndpoint(this);
91
+
92
+ // Add MongoDB collections
93
+ Indiekit.addCollection("rssFeeds");
94
+ Indiekit.addCollection("rssItems");
95
+
96
+ // Store config in application for controller access
97
+ Indiekit.config.application.rssConfig = this.options;
98
+ Indiekit.config.application.rssEndpoint = this.mountPath;
99
+
100
+ // Store database getter for controller access
101
+ Indiekit.config.application.getRssDb = () => Indiekit.database;
102
+
103
+ // Start background sync if database is available
104
+ if (Indiekit.config.application.mongodbUrl) {
105
+ startSync(Indiekit, this.options);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,106 @@
1
+ import { getSyncState, runSync } from "../sync.js";
2
+ import { formatFeed, formatItem } from "../utils.js";
3
+
4
+ export const dashboardController = {
5
+ /**
6
+ * Render admin dashboard
7
+ * GET /
8
+ */
9
+ async get(request, response, next) {
10
+ try {
11
+ const { rssConfig, rssEndpoint } = request.app.locals.application;
12
+
13
+ if (!rssConfig) {
14
+ return response.status(500).render("rss", {
15
+ title: response.locals.__("rss.title"),
16
+ error: { message: response.locals.__("rss.error.noConfig") },
17
+ });
18
+ }
19
+
20
+ const db = request.app.locals.application.getRssDb?.();
21
+ if (!db) {
22
+ return response.render("rss", {
23
+ title: response.locals.__("rss.title"),
24
+ error: { message: response.locals.__("rss.error.noDatabase") },
25
+ });
26
+ }
27
+
28
+ const feedsCollection = db.collection("rssFeeds");
29
+ const itemsCollection = db.collection("rssItems");
30
+
31
+ // Get feeds and recent items
32
+ const [feeds, recentItems, totalItems] = await Promise.all([
33
+ feedsCollection.find({}).sort({ addedAt: -1 }).toArray(),
34
+ itemsCollection
35
+ .find({})
36
+ .sort({ pubDate: -1 })
37
+ .limit(10)
38
+ .toArray(),
39
+ itemsCollection.countDocuments({}),
40
+ ]);
41
+
42
+ const syncState = getSyncState();
43
+
44
+ response.render("rss", {
45
+ title: response.locals.__("rss.title"),
46
+ feeds: feeds.map(formatFeed),
47
+ recentItems: recentItems.map((item) => formatItem(item)),
48
+ totalFeeds: feeds.length,
49
+ totalItems,
50
+ syncState: {
51
+ syncing: syncState.syncing,
52
+ lastSync: syncState.lastSync?.toISOString(),
53
+ lastError: syncState.lastError,
54
+ },
55
+ publicUrl: rssEndpoint,
56
+ });
57
+ } catch (error) {
58
+ next(error);
59
+ }
60
+ },
61
+
62
+ /**
63
+ * Trigger manual sync
64
+ * POST /sync
65
+ */
66
+ async sync(request, response) {
67
+ try {
68
+ const Indiekit = request.app.locals.indiekit;
69
+ const { rssConfig } = request.app.locals.application;
70
+
71
+ if (!Indiekit || !rssConfig) {
72
+ return response.status(500).json({
73
+ error: response.locals.__("rss.error.noConfig"),
74
+ });
75
+ }
76
+
77
+ const syncState = getSyncState();
78
+ if (syncState.syncing) {
79
+ return response.status(409).json({
80
+ error: "Sync already in progress",
81
+ syncing: true,
82
+ });
83
+ }
84
+
85
+ // Start sync and wait for result
86
+ const result = await runSync(Indiekit, rssConfig);
87
+
88
+ if (result.error) {
89
+ return response.status(500).json({
90
+ success: false,
91
+ error: result.error,
92
+ });
93
+ }
94
+
95
+ response.json({
96
+ success: true,
97
+ message: response.locals.__("rss.success.syncComplete"),
98
+ feedsProcessed: result.feedsProcessed,
99
+ itemsAdded: result.itemsAdded,
100
+ });
101
+ } catch (error) {
102
+ console.error("[RSS] Sync error:", error.message);
103
+ response.status(500).json({ error: error.message });
104
+ }
105
+ },
106
+ };
@@ -0,0 +1,198 @@
1
+ import { ObjectId } from "mongodb";
2
+ import { RssClient } from "../rss-client.js";
3
+ import { formatFeed, isValidUrl, normalizeUrl } from "../utils.js";
4
+
5
+ export const feedsController = {
6
+ /**
7
+ * List all feeds
8
+ * GET /api/feeds
9
+ */
10
+ async list(request, response) {
11
+ try {
12
+ const db = request.app.locals.application.getRssDb?.();
13
+ if (!db) {
14
+ return response.status(500).json({ error: "Database not available" });
15
+ }
16
+
17
+ const feedsCollection = db.collection("rssFeeds");
18
+ const feeds = await feedsCollection
19
+ .find({})
20
+ .sort({ addedAt: -1 })
21
+ .toArray();
22
+
23
+ response.json({
24
+ feeds: feeds.map(formatFeed),
25
+ total: feeds.length,
26
+ });
27
+ } catch (error) {
28
+ console.error("[RSS] Error listing feeds:", error.message);
29
+ response.status(500).json({ error: error.message });
30
+ }
31
+ },
32
+
33
+ /**
34
+ * Add a new feed
35
+ * POST /api/feeds
36
+ * Body: { url: string }
37
+ */
38
+ async add(request, response) {
39
+ try {
40
+ const { url } = request.body;
41
+
42
+ if (!url || !isValidUrl(url)) {
43
+ return response.status(400).json({
44
+ error: response.locals.__("rss.error.invalidUrl"),
45
+ });
46
+ }
47
+
48
+ const normalizedUrl = normalizeUrl(url);
49
+ const db = request.app.locals.application.getRssDb?.();
50
+ if (!db) {
51
+ return response.status(500).json({ error: "Database not available" });
52
+ }
53
+
54
+ const feedsCollection = db.collection("rssFeeds");
55
+
56
+ // Check if feed already exists
57
+ const existing = await feedsCollection.findOne({ url: normalizedUrl });
58
+ if (existing) {
59
+ return response.status(409).json({
60
+ error: response.locals.__("rss.error.feedExists"),
61
+ });
62
+ }
63
+
64
+ // Fetch feed to validate and get metadata
65
+ const { rssConfig } = request.app.locals.application;
66
+ const client = new RssClient({
67
+ timeout: rssConfig?.fetchTimeout || 10_000,
68
+ });
69
+
70
+ let feedMeta;
71
+ try {
72
+ const result = await client.fetchFeed(normalizedUrl);
73
+ feedMeta = result.feed;
74
+ } catch (error) {
75
+ return response.status(400).json({
76
+ error: `${response.locals.__("rss.error.fetchFailed")}: ${error.message}`,
77
+ });
78
+ }
79
+
80
+ // Insert feed
81
+ const feed = {
82
+ url: normalizedUrl,
83
+ title: feedMeta.title,
84
+ siteUrl: feedMeta.siteUrl,
85
+ description: feedMeta.description,
86
+ imageUrl: feedMeta.imageUrl,
87
+ enabled: true,
88
+ addedAt: new Date(),
89
+ lastFetchedAt: null,
90
+ lastError: null,
91
+ itemCount: 0,
92
+ };
93
+
94
+ const result = await feedsCollection.insertOne(feed);
95
+ feed._id = result.insertedId;
96
+
97
+ response.status(201).json({
98
+ message: response.locals.__("rss.success.feedAdded"),
99
+ feed: formatFeed(feed),
100
+ });
101
+ } catch (error) {
102
+ console.error("[RSS] Error adding feed:", error.message);
103
+ response.status(500).json({ error: error.message });
104
+ }
105
+ },
106
+
107
+ /**
108
+ * Remove a feed
109
+ * DELETE /api/feeds/:id
110
+ */
111
+ async remove(request, response) {
112
+ try {
113
+ const { id } = request.params;
114
+
115
+ if (!ObjectId.isValid(id)) {
116
+ return response.status(400).json({ error: "Invalid feed ID" });
117
+ }
118
+
119
+ const db = request.app.locals.application.getRssDb?.();
120
+ if (!db) {
121
+ return response.status(500).json({ error: "Database not available" });
122
+ }
123
+
124
+ const feedsCollection = db.collection("rssFeeds");
125
+ const itemsCollection = db.collection("rssItems");
126
+ const feedId = new ObjectId(id);
127
+
128
+ // Check if feed exists
129
+ const feed = await feedsCollection.findOne({ _id: feedId });
130
+ if (!feed) {
131
+ return response.status(404).json({
132
+ error: response.locals.__("rss.error.feedNotFound"),
133
+ });
134
+ }
135
+
136
+ // Delete feed and its items
137
+ await itemsCollection.deleteMany({ feedId });
138
+ await feedsCollection.deleteOne({ _id: feedId });
139
+
140
+ response.json({
141
+ message: response.locals.__("rss.success.feedRemoved"),
142
+ });
143
+ } catch (error) {
144
+ console.error("[RSS] Error removing feed:", error.message);
145
+ response.status(500).json({ error: error.message });
146
+ }
147
+ },
148
+
149
+ /**
150
+ * Toggle feed enabled/disabled
151
+ * PATCH /api/feeds/:id
152
+ * Body: { enabled: boolean }
153
+ */
154
+ async toggle(request, response) {
155
+ try {
156
+ const { id } = request.params;
157
+ const { enabled } = request.body;
158
+
159
+ if (!ObjectId.isValid(id)) {
160
+ return response.status(400).json({ error: "Invalid feed ID" });
161
+ }
162
+
163
+ if (typeof enabled !== "boolean") {
164
+ return response.status(400).json({ error: "enabled must be boolean" });
165
+ }
166
+
167
+ const db = request.app.locals.application.getRssDb?.();
168
+ if (!db) {
169
+ return response.status(500).json({ error: "Database not available" });
170
+ }
171
+
172
+ const feedsCollection = db.collection("rssFeeds");
173
+ const feedId = new ObjectId(id);
174
+
175
+ const result = await feedsCollection.findOneAndUpdate(
176
+ { _id: feedId },
177
+ { $set: { enabled } },
178
+ { returnDocument: "after" }
179
+ );
180
+
181
+ if (!result) {
182
+ return response.status(404).json({
183
+ error: response.locals.__("rss.error.feedNotFound"),
184
+ });
185
+ }
186
+
187
+ response.json({
188
+ message: enabled
189
+ ? response.locals.__("rss.success.feedEnabled")
190
+ : response.locals.__("rss.success.feedDisabled"),
191
+ feed: formatFeed(result),
192
+ });
193
+ } catch (error) {
194
+ console.error("[RSS] Error toggling feed:", error.message);
195
+ response.status(500).json({ error: error.message });
196
+ }
197
+ },
198
+ };
@@ -0,0 +1,95 @@
1
+ import { ObjectId } from "mongodb";
2
+ import { formatItem } from "../utils.js";
3
+
4
+ export const itemsController = {
5
+ /**
6
+ * List feed items with pagination
7
+ * GET /api/items
8
+ * Query: page, limit, feedId, includeContent
9
+ */
10
+ async list(request, response) {
11
+ try {
12
+ const db = request.app.locals.application.getRssDb?.();
13
+ if (!db) {
14
+ return response.status(500).json({ error: "Database not available" });
15
+ }
16
+
17
+ const page = Math.max(1, parseInt(request.query.page) || 1);
18
+ const limit = Math.min(100, Math.max(1, parseInt(request.query.limit) || 20));
19
+ const feedId = request.query.feedId;
20
+ const includeContent = request.query.includeContent === "true";
21
+ const skip = (page - 1) * limit;
22
+
23
+ const itemsCollection = db.collection("rssItems");
24
+
25
+ // Build query
26
+ const query = {};
27
+ if (feedId && ObjectId.isValid(feedId)) {
28
+ query.feedId = new ObjectId(feedId);
29
+ }
30
+
31
+ // Get total count
32
+ const total = await itemsCollection.countDocuments(query);
33
+
34
+ // Get items
35
+ const items = await itemsCollection
36
+ .find(query)
37
+ .sort({ pubDate: -1 })
38
+ .skip(skip)
39
+ .limit(limit)
40
+ .toArray();
41
+
42
+ const totalPages = Math.ceil(total / limit);
43
+
44
+ response.json({
45
+ items: items.map((item) =>
46
+ formatItem(item, { includeContent })
47
+ ),
48
+ pagination: {
49
+ page,
50
+ limit,
51
+ total,
52
+ totalPages,
53
+ hasNext: page < totalPages,
54
+ hasPrev: page > 1,
55
+ },
56
+ });
57
+ } catch (error) {
58
+ console.error("[RSS] Error listing items:", error.message);
59
+ response.status(500).json({ error: error.message });
60
+ }
61
+ },
62
+
63
+ /**
64
+ * Get a single item by ID
65
+ * GET /api/items/:id
66
+ */
67
+ async get(request, response) {
68
+ try {
69
+ const { id } = request.params;
70
+
71
+ if (!ObjectId.isValid(id)) {
72
+ return response.status(400).json({ error: "Invalid item ID" });
73
+ }
74
+
75
+ const db = request.app.locals.application.getRssDb?.();
76
+ if (!db) {
77
+ return response.status(500).json({ error: "Database not available" });
78
+ }
79
+
80
+ const itemsCollection = db.collection("rssItems");
81
+ const item = await itemsCollection.findOne({ _id: new ObjectId(id) });
82
+
83
+ if (!item) {
84
+ return response.status(404).json({ error: "Item not found" });
85
+ }
86
+
87
+ response.json({
88
+ item: formatItem(item, { includeContent: true }),
89
+ });
90
+ } catch (error) {
91
+ console.error("[RSS] Error getting item:", error.message);
92
+ response.status(500).json({ error: error.message });
93
+ }
94
+ },
95
+ };
@@ -0,0 +1,90 @@
1
+ import { getSyncState, runSync } from "../sync.js";
2
+
3
+ export const statusController = {
4
+ /**
5
+ * Get sync status
6
+ * GET /api/status
7
+ */
8
+ async status(request, response) {
9
+ try {
10
+ const db = request.app.locals.application.getRssDb?.();
11
+ const syncState = getSyncState();
12
+ const { rssConfig } = request.app.locals.application;
13
+
14
+ let feedsCount = 0;
15
+ let itemsCount = 0;
16
+ let enabledFeedsCount = 0;
17
+
18
+ if (db) {
19
+ const feedsCollection = db.collection("rssFeeds");
20
+ const itemsCollection = db.collection("rssItems");
21
+
22
+ feedsCount = await feedsCollection.countDocuments({});
23
+ enabledFeedsCount = await feedsCollection.countDocuments({ enabled: true });
24
+ itemsCount = await itemsCollection.countDocuments({});
25
+ }
26
+
27
+ const syncIntervalMs = rssConfig?.syncInterval || 900_000;
28
+ const nextSync = syncState.lastSync
29
+ ? new Date(syncState.lastSync.getTime() + syncIntervalMs)
30
+ : null;
31
+
32
+ response.json({
33
+ status: syncState.syncing ? "syncing" : "idle",
34
+ lastSync: syncState.lastSync?.toISOString() || null,
35
+ nextSync: nextSync?.toISOString() || null,
36
+ lastError: syncState.lastError,
37
+ stats: {
38
+ feedsCount,
39
+ enabledFeedsCount,
40
+ itemsCount,
41
+ lastFeedsProcessed: syncState.feedsProcessed,
42
+ lastItemsAdded: syncState.itemsAdded,
43
+ },
44
+ config: {
45
+ syncInterval: syncIntervalMs,
46
+ maxItemsPerFeed: rssConfig?.maxItemsPerFeed || 50,
47
+ },
48
+ });
49
+ } catch (error) {
50
+ console.error("[RSS] Error getting status:", error.message);
51
+ response.status(500).json({ error: error.message });
52
+ }
53
+ },
54
+
55
+ /**
56
+ * Trigger manual sync
57
+ * POST /api/refresh
58
+ */
59
+ async refresh(request, response) {
60
+ try {
61
+ const Indiekit = request.app.locals.indiekit;
62
+ const { rssConfig } = request.app.locals.application;
63
+
64
+ if (!Indiekit || !rssConfig) {
65
+ return response.status(500).json({ error: "Plugin not configured" });
66
+ }
67
+
68
+ const syncState = getSyncState();
69
+ if (syncState.syncing) {
70
+ return response.status(409).json({
71
+ error: "Sync already in progress",
72
+ status: "syncing",
73
+ });
74
+ }
75
+
76
+ // Run sync asynchronously
77
+ runSync(Indiekit, rssConfig).catch((err) => {
78
+ console.error("[RSS] Manual sync error:", err.message);
79
+ });
80
+
81
+ response.json({
82
+ message: response.locals.__("rss.syncing"),
83
+ status: "started",
84
+ });
85
+ } catch (error) {
86
+ console.error("[RSS] Error triggering refresh:", error.message);
87
+ response.status(500).json({ error: error.message });
88
+ }
89
+ },
90
+ };