feedkeeper 0.0.0-stage → 0.23.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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +162 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/app.js +100 -0
  20. package/server/dist/auth/avatar.js +13 -0
  21. package/server/dist/auth/capabilities.js +53 -0
  22. package/server/dist/auth/middleware.js +77 -0
  23. package/server/dist/auth/pairing.js +36 -0
  24. package/server/dist/auth/password.js +9 -0
  25. package/server/dist/auth/plan.js +58 -0
  26. package/server/dist/auth/security.js +117 -0
  27. package/server/dist/auth/securityRoutes.js +249 -0
  28. package/server/dist/auth/tokens.js +64 -0
  29. package/server/dist/auth/users.js +54 -0
  30. package/server/dist/backup-cli.js +101 -0
  31. package/server/dist/cli.js +135 -0
  32. package/server/dist/clientAddress.js +26 -0
  33. package/server/dist/config.js +51 -0
  34. package/server/dist/db/index.js +40 -0
  35. package/server/dist/db/migrations/0001_init.sql +70 -0
  36. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  37. package/server/dist/db/migrations/0003_retention.sql +13 -0
  38. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  39. package/server/dist/db/migrations/0005_folders.sql +15 -0
  40. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  41. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  42. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  43. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  44. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  45. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  46. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  47. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  48. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  49. package/server/dist/db/migrations/0015_push.sql +13 -0
  50. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  51. package/server/dist/db/migrations/0017_devices.sql +13 -0
  52. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  53. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  54. package/server/dist/db/migrations/0020_fts.sql +32 -0
  55. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  56. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  57. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  58. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  59. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  60. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  61. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  62. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  63. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  64. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  65. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  66. package/server/dist/feedkeeper.js +38 -0
  67. package/server/dist/feeds/archive.js +334 -0
  68. package/server/dist/feeds/articleContent.js +26 -0
  69. package/server/dist/feeds/cleanup.js +179 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +222 -0
  85. package/server/dist/feeds/repository.js +674 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/index.js +15 -0
  91. package/server/dist/mcp/digest.js +73 -0
  92. package/server/dist/mcp/http.js +46 -0
  93. package/server/dist/mcp/items.js +73 -0
  94. package/server/dist/mcp/promptsAndResources.js +43 -0
  95. package/server/dist/mcp/server.js +547 -0
  96. package/server/dist/native/editionSelection.js +127 -0
  97. package/server/dist/native/editionText.js +15 -0
  98. package/server/dist/native/editions.js +218 -0
  99. package/server/dist/native/preferences.js +66 -0
  100. package/server/dist/native/requests.js +35 -0
  101. package/server/dist/oauth/constants.js +6 -0
  102. package/server/dist/oauth/host.js +8 -0
  103. package/server/dist/oauth/pages.js +148 -0
  104. package/server/dist/oauth/routes.js +361 -0
  105. package/server/dist/oauth/store.js +104 -0
  106. package/server/dist/push.js +147 -0
  107. package/server/dist/pushOutbox.js +164 -0
  108. package/server/dist/security-recover-cli.js +29 -0
  109. package/server/dist/server.js +108 -0
  110. package/server/dist/setup-cli.js +97 -0
  111. package/server/dist/sync/changeLog.js +170 -0
  112. package/server/dist/sync/mutations.js +144 -0
  113. package/server/dist/trustProxy.js +14 -0
  114. package/server/dist/version.js +6 -0
  115. package/server/package.json +6 -0
  116. package/web/dist/app-icon-180.png +0 -0
  117. package/web/dist/app-icon-192.png +0 -0
  118. package/web/dist/app-icon-512.png +0 -0
  119. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  120. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  121. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  122. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  124. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  126. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  128. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  130. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  131. package/web/dist/favicon-32.png +0 -0
  132. package/web/dist/index.html +40 -0
  133. package/web/dist/logo.svg +50 -0
  134. package/web/dist/manifest.webmanifest +64 -0
  135. package/web/dist/robots.txt +3 -0
  136. package/web/dist/sw.js +198 -0
  137. package/web/dist/theme-init.js +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Hamann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,23 @@
1
- # Temporary Holding Version
1
+ # FeedKeeper
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A lightweight, self-hosted RSS reader and remote MCP server. One Node process, one SQLite file.
4
+
5
+ ```bash
6
+ npx feedkeeper
7
+ ```
8
+
9
+ Open <http://localhost:3000> and finish the setup in the browser. The database, the archive of saved
10
+ articles and a generated session secret live in `~/.feedkeeper`.
11
+
12
+ ```text
13
+ feedkeeper [--port <number>] [--public-url <url>] [--data-dir <folder>]
14
+ feedkeeper backup <file>
15
+ feedkeeper restore <file> --force
16
+ ```
17
+
18
+ Requires Node.js ^22.22.2 || ^24.15.0 || >=26.0.0. A `.env` file in the current folder is read; the settings
19
+ are the ones of [.env.example](https://github.com/visualfusion/feedkeeper/blob/main/.env.example).
20
+
21
+ Documentation, Docker images and the changelog: <https://github.com/visualfusion/feedkeeper>
22
+
23
+ MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.23.0",
4
+ "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
+ "keywords": [
6
+ "rss",
7
+ "atom",
8
+ "feed-reader",
9
+ "self-hosted",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "sqlite"
13
+ ],
14
+ "license": "MIT",
15
+ "homepage": "https://github.com/visualfusion/feedkeeper#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/visualfusion/feedkeeper.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/visualfusion/feedkeeper/issues"
22
+ },
23
+ "bin": {
24
+ "feedkeeper": "server/dist/feedkeeper.js"
25
+ },
26
+ "engines": {
27
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
28
+ },
29
+ "files": [
30
+ "server",
31
+ "web",
32
+ "LICENSE",
33
+ "README.md"
34
+ ],
35
+ "dependencies": {
36
+ "@humanwhocodes/ico-to-png": "^1.1.0",
37
+ "@modelcontextprotocol/sdk": "^1.30.0",
38
+ "@mozilla/readability": "^0.6.0",
39
+ "@resvg/resvg-js": "^2.6.2",
40
+ "@simplewebauthn/server": "^14.0.3",
41
+ "bcryptjs": "^3.0.3",
42
+ "better-sqlite3": "^13.0.3",
43
+ "cookie-session": "^2.1.1",
44
+ "cors": "^2.8.6",
45
+ "dotenv": "^18.0.5",
46
+ "express": "^5.2.1",
47
+ "express-rate-limit": "^8.7.1",
48
+ "fast-xml-parser": "^5.11.2",
49
+ "helmet": "^8.3.0",
50
+ "ipaddr.js": "^1.9.1",
51
+ "jsdom": "^30.1.2",
52
+ "node-cron": "^4.6.0",
53
+ "otpauth": "^9.5.2",
54
+ "rss-parser": "^3.13.0",
55
+ "undici": "^8.11.2",
56
+ "web-push": "^3.6.7",
57
+ "zod": "^4.6.5"
58
+ }
59
+ }
@@ -0,0 +1,19 @@
1
+ import { Router } from "express";
2
+ import { requireSession } from "../auth/middleware.js";
3
+ import { findArchivedImage } from "../feeds/archive.js";
4
+ export const archiveRouter = Router();
5
+ archiveRouter.use(requireSession);
6
+ archiveRouter.get("/images/:imageId", (req, res) => {
7
+ const image = findArchivedImage(req.user.id, Number(req.params.imageId));
8
+ if (!image) {
9
+ res.status(404).json({ error: "not_found" });
10
+ return;
11
+ }
12
+ // Archived files never change; the URL is tied to one stored copy.
13
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
14
+ res.type(image.mime).sendFile(image.path, (error) => {
15
+ if (error && !res.headersSent)
16
+ res.status(404).json({ error: "not_found" });
17
+ });
18
+ });
19
+ //# sourceMappingURL=archive.js.map
@@ -0,0 +1,162 @@
1
+ import express, { Router } from "express";
2
+ import rateLimit from "express-rate-limit";
3
+ import { z } from "zod";
4
+ import { config } from "../config.js";
5
+ import { requireSession, requireAdmin } from "../auth/middleware.js";
6
+ import { createUser, deleteUserAvatar, findUserByEmail, findUserById, getUserAvatar, listUsers, setUserAvatar, toPublicUser, updateDisplayName, dismissIntro, updateUserPassword, } from "../auth/users.js";
7
+ import { AVATAR_MAX_BYTES, detectAvatarType } from "../auth/avatar.js";
8
+ import { verifyPassword } from "../auth/password.js";
9
+ import { getUserCapabilities } from "../auth/capabilities.js";
10
+ import { beginMfa, securityVersion } from "../auth/security.js";
11
+ import { createSecurityRouter } from "../auth/securityRoutes.js";
12
+ export const authRouter = Router();
13
+ authRouter.use("/security", createSecurityRouter({ completeLogin: (req, res, userId) => {
14
+ req.session = { userId, securityVersion: securityVersion(userId) };
15
+ res.json(toPublicUser(findUserById(userId)));
16
+ } }));
17
+ const loginLimiter = rateLimit({
18
+ windowMs: 15 * 60 * 1000,
19
+ limit: 10,
20
+ standardHeaders: true,
21
+ legacyHeaders: false,
22
+ });
23
+ const loginSchema = z.object({
24
+ email: z.string().email(),
25
+ password: z.string().min(1),
26
+ });
27
+ authRouter.post("/login", loginLimiter, (req, res) => {
28
+ const parsed = loginSchema.safeParse(req.body);
29
+ if (!parsed.success) {
30
+ res.status(400).json({ error: "invalid_input" });
31
+ return;
32
+ }
33
+ const user = findUserByEmail(parsed.data.email);
34
+ if (!user || !verifyPassword(parsed.data.password, user.password_hash)) {
35
+ res.status(401).json({ error: "invalid_credentials" });
36
+ return;
37
+ }
38
+ const mfa = beginMfa(req, user.id, "web", {});
39
+ if (mfa) {
40
+ res.status(403).json(mfa);
41
+ return;
42
+ }
43
+ req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
44
+ res.json(toPublicUser(user));
45
+ });
46
+ authRouter.post("/logout", (req, res) => {
47
+ req.session = null;
48
+ res.status(204).end();
49
+ });
50
+ authRouter.get("/me", requireSession, (req, res) => {
51
+ res.json({
52
+ ...req.user,
53
+ capabilities: getUserCapabilities(req.user.id),
54
+ });
55
+ });
56
+ const profileSchema = z.object({
57
+ displayName: z.string().trim().min(1).max(100),
58
+ });
59
+ authRouter.patch("/me", requireSession, (req, res) => {
60
+ const parsed = profileSchema.safeParse(req.body);
61
+ if (!parsed.success) {
62
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
63
+ return;
64
+ }
65
+ updateDisplayName(req.user.id, parsed.data.displayName);
66
+ res.json(toPublicUser(findUserById(req.user.id)));
67
+ });
68
+ // The introduction to the app is shown once per account; closing it on one device closes it everywhere.
69
+ authRouter.post("/me/intro", requireSession, (req, res) => {
70
+ dismissIntro(req.user.id);
71
+ res.json(toPublicUser(findUserById(req.user.id)));
72
+ });
73
+ authRouter.get("/me/avatar", requireSession, (req, res) => {
74
+ const avatar = getUserAvatar(req.user.id);
75
+ if (!avatar) {
76
+ res.status(404).json({ error: "not_found" });
77
+ return;
78
+ }
79
+ // The client adds the update timestamp to the URL, so a changed photo gets a new cache key.
80
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
81
+ res.type(avatar.mime_type).send(avatar.data);
82
+ });
83
+ authRouter.put("/me/avatar", requireSession, express.raw({ type: ["image/jpeg", "image/png", "image/webp"], limit: AVATAR_MAX_BYTES }), (req, res) => {
84
+ const data = Buffer.isBuffer(req.body) ? req.body : null;
85
+ const mimeType = data ? detectAvatarType(data) : null;
86
+ if (!data || !mimeType) {
87
+ res.status(400).json({ error: "invalid_image" });
88
+ return;
89
+ }
90
+ setUserAvatar(req.user.id, mimeType, data);
91
+ res.json(toPublicUser(findUserById(req.user.id)));
92
+ });
93
+ authRouter.delete("/me/avatar", requireSession, (req, res) => {
94
+ deleteUserAvatar(req.user.id);
95
+ res.json(toPublicUser(findUserById(req.user.id)));
96
+ });
97
+ const changePasswordSchema = z.object({
98
+ currentPassword: z.string().min(1),
99
+ newPassword: z.string().min(10),
100
+ });
101
+ authRouter.post("/change-password", requireSession, loginLimiter, (req, res) => {
102
+ const parsed = changePasswordSchema.safeParse(req.body);
103
+ if (!parsed.success) {
104
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
105
+ return;
106
+ }
107
+ const user = findUserById(req.user.id);
108
+ if (!verifyPassword(parsed.data.currentPassword, user.password_hash)) {
109
+ res.status(401).json({ error: "invalid_credentials" });
110
+ return;
111
+ }
112
+ updateUserPassword(user.id, parsed.data.newPassword);
113
+ res.status(204).end();
114
+ });
115
+ const createUserSchema = z.object({
116
+ email: z.string().email(),
117
+ password: z.string().min(10),
118
+ displayName: z.string().min(1).max(100),
119
+ role: z.enum(["admin", "user"]).default("user"),
120
+ });
121
+ // Admin-only user management. Signup stays closed by default (config.allowSignup);
122
+ // this is how additional accounts get created on a locked-down instance.
123
+ authRouter.get("/users", requireSession, requireAdmin, (_req, res) => {
124
+ res.json(listUsers());
125
+ });
126
+ authRouter.post("/users", requireSession, requireAdmin, (req, res) => {
127
+ const parsed = createUserSchema.safeParse(req.body);
128
+ if (!parsed.success) {
129
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
130
+ return;
131
+ }
132
+ if (findUserByEmail(parsed.data.email)) {
133
+ res.status(409).json({ error: "email_taken" });
134
+ return;
135
+ }
136
+ const user = createUser(parsed.data);
137
+ res.status(201).json(toPublicUser(user));
138
+ });
139
+ const signupSchema = z.object({
140
+ email: z.string().email(),
141
+ password: z.string().min(10),
142
+ displayName: z.string().min(1).max(100),
143
+ });
144
+ authRouter.post("/signup", (req, res) => {
145
+ if (!config.allowSignup) {
146
+ res.status(403).json({ error: "signup_disabled" });
147
+ return;
148
+ }
149
+ const parsed = signupSchema.safeParse(req.body);
150
+ if (!parsed.success) {
151
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
152
+ return;
153
+ }
154
+ if (findUserByEmail(parsed.data.email)) {
155
+ res.status(409).json({ error: "email_taken" });
156
+ return;
157
+ }
158
+ const user = createUser({ ...parsed.data, role: "user" });
159
+ req.session.userId = user.id;
160
+ res.status(201).json(toPublicUser(user));
161
+ });
162
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,207 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { requireSession } from "../auth/middleware.js";
4
+ import { requireCapability } from "../auth/capabilities.js";
5
+ import { listSubscriptionsForUser, findFeedById, isUserSubscribed, reorderSubscriptions } from "../feeds/repository.js";
6
+ import { subscribeToFeed, unsubscribeFromFeed, updateFeedSettings, FeedError, MultipleFeedsFoundError } from "../feeds/service.js";
7
+ import { SsrfBlockedError, normalizeUrlCandidate } from "../feeds/ssrfGuard.js";
8
+ import { generateOpml, importOpmlFeeds } from "../feeds/opml.js";
9
+ import { pollFeed, pollFeeds } from "../feeds/poller.js";
10
+ import { discoverFeeds } from "../feeds/discovery.js";
11
+ import { canAccessFeed, loadFeedIcon } from "../feeds/feedIcon.js";
12
+ import rateLimit from "express-rate-limit";
13
+ export const feedsRouter = Router();
14
+ feedsRouter.use(requireSession);
15
+ // Adding and updating feeds belongs to the plan's sync feature; reading, exporting and deleting stay available without it.
16
+ const requireSync = requireCapability("sync");
17
+ feedsRouter.get("/", (req, res) => {
18
+ res.json(listSubscriptionsForUser(req.user.id));
19
+ });
20
+ const reorderSchema = z.object({
21
+ feedIds: z.array(z.number().int().positive()),
22
+ });
23
+ feedsRouter.put("/reorder", (req, res) => {
24
+ const parsed = reorderSchema.safeParse(req.body);
25
+ if (!parsed.success) {
26
+ res.status(400).json({ error: "invalid_feed_ids" });
27
+ return;
28
+ }
29
+ const currentIds = listSubscriptionsForUser(req.user.id).map((feed) => feed.id);
30
+ const requestedIds = parsed.data.feedIds;
31
+ if (requestedIds.length !== currentIds.length ||
32
+ new Set(requestedIds).size !== currentIds.length ||
33
+ requestedIds.some((id) => !currentIds.includes(id))) {
34
+ res.status(400).json({ error: "invalid_feed_ids" });
35
+ return;
36
+ }
37
+ reorderSubscriptions(req.user.id, requestedIds);
38
+ res.json({ ok: true });
39
+ });
40
+ const iconLimiter = rateLimit({ windowMs: 60 * 1000, limit: 600, standardHeaders: true, legacyHeaders: false });
41
+ // A feed's icon through this server, so it can be stored for offline use.
42
+ feedsRouter.get("/:feedId/icon", iconLimiter, async (req, res) => {
43
+ const feedId = Number(req.params.feedId);
44
+ const icon = canAccessFeed(req.user.id, feedId) ? await loadFeedIcon(feedId) : null;
45
+ if (!icon) {
46
+ res.status(404).json({ error: "not_found" });
47
+ return;
48
+ }
49
+ res.setHeader("Cache-Control", "private, max-age=86400");
50
+ // SVG icons must never run scripts, even when opened directly.
51
+ res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
52
+ res.type(icon.mime).send(icon.buffer);
53
+ });
54
+ feedsRouter.get("/opml", (req, res) => {
55
+ const opml = generateOpml(req.user.id);
56
+ res.setHeader("Content-Type", "application/xml; charset=utf-8");
57
+ res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
58
+ res.send(opml);
59
+ });
60
+ feedsRouter.post("/opml", requireSync, async (req, res) => {
61
+ const xmlContent = typeof req.body === "string"
62
+ ? req.body
63
+ : typeof req.body?.opml === "string"
64
+ ? req.body.opml
65
+ : null;
66
+ if (!xmlContent || !xmlContent.trim()) {
67
+ res.status(400).json({ error: "missing_opml_content" });
68
+ return;
69
+ }
70
+ try {
71
+ const result = await importOpmlFeeds(req.user.id, xmlContent);
72
+ res.json(result);
73
+ }
74
+ catch (error) {
75
+ res.status(400).json({
76
+ error: "invalid_opml",
77
+ message: error instanceof Error ? error.message : String(error),
78
+ });
79
+ }
80
+ });
81
+ const urlField = z.preprocess((val) => (typeof val === "string" ? normalizeUrlCandidate(val) : val), z.string().url());
82
+ const discoverSchema = z.object({
83
+ url: urlField,
84
+ });
85
+ feedsRouter.post("/discover", requireSync, async (req, res) => {
86
+ const parsed = discoverSchema.safeParse(req.body);
87
+ if (!parsed.success) {
88
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
89
+ return;
90
+ }
91
+ try {
92
+ const feeds = await discoverFeeds(parsed.data.url);
93
+ res.json({ feeds });
94
+ }
95
+ catch (error) {
96
+ if (error instanceof SsrfBlockedError) {
97
+ res.status(400).json({ error: "blocked_url", message: error.message });
98
+ return;
99
+ }
100
+ res.status(400).json({ error: "discovery_failed", message: error instanceof Error ? error.message : String(error) });
101
+ }
102
+ });
103
+ const subscribeSchema = z.object({
104
+ url: urlField,
105
+ label: z.string().max(200).nullable().optional(),
106
+ folderId: z.number().int().positive().nullable().optional(),
107
+ });
108
+ feedsRouter.post("/", requireSync, async (req, res) => {
109
+ const parsed = subscribeSchema.safeParse(req.body);
110
+ if (!parsed.success) {
111
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
112
+ return;
113
+ }
114
+ try {
115
+ const subscription = await subscribeToFeed(req.user.id, parsed.data.url, parsed.data.label ?? null, parsed.data.folderId ?? null);
116
+ res.status(201).json(subscription);
117
+ }
118
+ catch (error) {
119
+ if (error instanceof SsrfBlockedError) {
120
+ res.status(400).json({ error: "blocked_url", message: error.message });
121
+ return;
122
+ }
123
+ if (error instanceof MultipleFeedsFoundError) {
124
+ res.status(400).json({ error: "multiple_feeds_found", feeds: error.feeds });
125
+ return;
126
+ }
127
+ if (error instanceof FeedError) {
128
+ const status = error.message === "already_subscribed" ? 409 : 400;
129
+ res.status(status).json({ error: error.message });
130
+ return;
131
+ }
132
+ res.status(400).json({ error: "invalid_feed", message: error instanceof Error ? error.message : String(error) });
133
+ }
134
+ });
135
+ const updateSchema = z.object({
136
+ label: z.string().max(200).nullable().optional(),
137
+ folderId: z.number().int().positive().nullable().optional(),
138
+ pollIntervalMinutes: z.number().int().positive().max(10080).optional(),
139
+ fullTextMode: z.enum(["auto", "never"]).optional(),
140
+ notify: z.boolean().optional(),
141
+ badge: z.boolean().optional(),
142
+ });
143
+ feedsRouter.patch("/:feedId", (req, res) => {
144
+ const feedId = Number(req.params.feedId);
145
+ const parsed = updateSchema.safeParse(req.body);
146
+ if (!parsed.success) {
147
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
148
+ return;
149
+ }
150
+ try {
151
+ const subscription = updateFeedSettings(req.user.id, feedId, parsed.data);
152
+ res.json(subscription);
153
+ }
154
+ catch (error) {
155
+ if (error instanceof FeedError) {
156
+ res.status(404).json({ error: error.message });
157
+ return;
158
+ }
159
+ throw error;
160
+ }
161
+ });
162
+ feedsRouter.delete("/:feedId", (req, res) => {
163
+ const feedId = Number(req.params.feedId);
164
+ try {
165
+ unsubscribeFromFeed(req.user.id, feedId);
166
+ res.status(204).end();
167
+ }
168
+ catch (error) {
169
+ if (error instanceof FeedError) {
170
+ res.status(404).json({ error: error.message });
171
+ return;
172
+ }
173
+ throw error;
174
+ }
175
+ });
176
+ feedsRouter.post("/:feedId/refresh", requireSync, async (req, res) => {
177
+ const feedId = Number(req.params.feedId);
178
+ if (!Number.isInteger(feedId) || feedId <= 0) {
179
+ res.status(400).json({ error: "invalid_feed_id" });
180
+ return;
181
+ }
182
+ const feed = findFeedById(feedId);
183
+ if (!feed || !isUserSubscribed(req.user.id, feedId)) {
184
+ res.status(404).json({ error: "feed_not_found" });
185
+ return;
186
+ }
187
+ const pollResult = await pollFeed(feed);
188
+ const updatedFeed = listSubscriptionsForUser(req.user.id).find((f) => f.id === feedId);
189
+ res.json({
190
+ feed: updatedFeed,
191
+ newItems: pollResult.newItems,
192
+ error: pollResult.error,
193
+ deferred: pollResult.deferred ?? false,
194
+ retryAt: pollResult.retryAt,
195
+ });
196
+ });
197
+ feedsRouter.post("/refresh-all", requireSync, async (req, res) => {
198
+ const subs = listSubscriptionsForUser(req.user.id);
199
+ const { newItems: totalNewItems, errors: errorCount, deferred } = await pollFeeds(subs);
200
+ res.json({
201
+ refreshed: subs.length,
202
+ newItems: totalNewItems,
203
+ errors: errorCount,
204
+ deferred,
205
+ });
206
+ });
207
+ //# sourceMappingURL=feeds.js.map
@@ -0,0 +1,32 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { requireSession } from "../auth/middleware.js";
4
+ import { listMutedKeywords, addMutedKeyword, removeMutedKeyword, } from "../feeds/repository.js";
5
+ export const filtersRouter = Router();
6
+ filtersRouter.use(requireSession);
7
+ filtersRouter.get("/muted", (req, res) => {
8
+ const keywords = listMutedKeywords(req.user.id);
9
+ res.json({ keywords });
10
+ });
11
+ const addKeywordSchema = z.object({
12
+ keyword: z.string().trim().min(1).max(100),
13
+ });
14
+ filtersRouter.post("/muted", (req, res) => {
15
+ const parsed = addKeywordSchema.safeParse(req.body);
16
+ if (!parsed.success) {
17
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
18
+ return;
19
+ }
20
+ const created = addMutedKeyword(req.user.id, parsed.data.keyword);
21
+ res.status(201).json(created);
22
+ });
23
+ filtersRouter.delete("/muted/:id", (req, res) => {
24
+ const keywordId = Number(req.params.id);
25
+ if (isNaN(keywordId)) {
26
+ res.status(400).json({ error: "invalid_id" });
27
+ return;
28
+ }
29
+ removeMutedKeyword(req.user.id, keywordId);
30
+ res.status(204).end();
31
+ });
32
+ //# sourceMappingURL=filters.js.map
@@ -0,0 +1,53 @@
1
+ import { requireCapability } from "../auth/capabilities.js";
2
+ import { Router } from "express";
3
+ import { z } from "zod";
4
+ import { requireSession } from "../auth/middleware.js";
5
+ import { listFoldersForUser, createFolder, updateFolder, deleteFolder, findFolderById, markAllRead, } from "../feeds/repository.js";
6
+ export const foldersRouter = Router();
7
+ foldersRouter.use(requireSession);
8
+ const folderNameSchema = z.object({
9
+ name: z.string().trim().min(1).max(100),
10
+ });
11
+ foldersRouter.get("/", (req, res) => {
12
+ const folders = listFoldersForUser(req.user.id);
13
+ res.json({ folders });
14
+ });
15
+ foldersRouter.post("/", requireCapability("sync"), (req, res) => {
16
+ const parsed = folderNameSchema.safeParse(req.body);
17
+ if (!parsed.success) {
18
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
19
+ return;
20
+ }
21
+ const folder = createFolder(req.user.id, parsed.data.name);
22
+ res.status(201).json({
23
+ ...folder,
24
+ feed_count: 0,
25
+ unread_count: 0,
26
+ });
27
+ });
28
+ foldersRouter.patch("/:folderId", (req, res) => {
29
+ const folderId = Number(req.params.folderId);
30
+ const existing = findFolderById(req.user.id, folderId);
31
+ if (!existing) {
32
+ res.status(404).json({ error: "folder_not_found" });
33
+ return;
34
+ }
35
+ const parsed = folderNameSchema.safeParse(req.body);
36
+ if (!parsed.success) {
37
+ res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
38
+ return;
39
+ }
40
+ const updated = updateFolder(req.user.id, folderId, parsed.data.name);
41
+ res.json(updated);
42
+ });
43
+ foldersRouter.delete("/:folderId", (req, res) => {
44
+ const folderId = Number(req.params.folderId);
45
+ deleteFolder(req.user.id, folderId);
46
+ res.json({ success: true });
47
+ });
48
+ foldersRouter.post("/:folderId/read", (req, res) => {
49
+ const folderId = Number(req.params.folderId);
50
+ const updatedCount = markAllRead(req.user.id, { folderId });
51
+ res.json({ read: updatedCount });
52
+ });
53
+ //# sourceMappingURL=folders.js.map
@@ -0,0 +1,37 @@
1
+ import { Router } from "express";
2
+ import { onboardingRouter } from "./onboarding.js";
3
+ import { authRouter } from "./auth.js";
4
+ import { feedsRouter } from "./feeds.js";
5
+ import { itemsRouter } from "./items.js";
6
+ import { tokensRouter } from "./tokens.js";
7
+ import { oauthGrantsRouter } from "./oauth.js";
8
+ import { systemRouter } from "./system.js";
9
+ import { filtersRouter } from "./filters.js";
10
+ import { foldersRouter } from "./folders.js";
11
+ import { archiveRouter } from "./archive.js";
12
+ import { pushRouter } from "./push.js";
13
+ import { v1Router } from "./v1/index.js";
14
+ import { config } from "../config.js";
15
+ export const apiRouter = Router();
16
+ apiRouter.get("/health", (_req, res) => {
17
+ res.json({ status: "ok" });
18
+ });
19
+ apiRouter.get("/config", (_req, res) => {
20
+ res.json({
21
+ showGithubLink: config.showGithubLink,
22
+ githubUrl: config.githubUrl,
23
+ });
24
+ });
25
+ apiRouter.use("/onboarding", onboardingRouter);
26
+ apiRouter.use("/auth", authRouter);
27
+ apiRouter.use("/feeds", feedsRouter);
28
+ apiRouter.use("/items", itemsRouter);
29
+ apiRouter.use("/tokens", tokensRouter);
30
+ apiRouter.use("/oauth-grants", oauthGrantsRouter);
31
+ apiRouter.use("/system", systemRouter);
32
+ apiRouter.use("/filters", filtersRouter);
33
+ apiRouter.use("/folders", foldersRouter);
34
+ apiRouter.use("/archive", archiveRouter);
35
+ apiRouter.use("/push", pushRouter);
36
+ apiRouter.use("/v1", v1Router);
37
+ //# sourceMappingURL=index.js.map