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
@@ -0,0 +1,653 @@
1
+ import { enqueueArticleContents } from "../../feeds/fullTextQueue.js";
2
+ import { Router } from "express";
3
+ import rateLimit from "express-rate-limit";
4
+ import { z } from "zod";
5
+ import { FULL_TEXT_STATUS } from "../items.js";
6
+ import { findArchivedImage, fetchItemImage, withArchivedImages, withProxiedImages } from "../../feeds/archive.js";
7
+ import { ImageCacheBusyError } from "../../feeds/imageCache.js";
8
+ import { discoverFeeds } from "../../feeds/discovery.js";
9
+ import { loadFullText } from "../../feeds/fullText.js";
10
+ import { generateOpml, importOpmlFeeds } from "../../feeds/opml.js";
11
+ import { pollFeed, pollFeeds } from "../../feeds/poller.js";
12
+ import { canAccessFeed, loadFeedIcon } from "../../feeds/feedIcon.js";
13
+ import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
14
+ import { saveToInbox, deleteInboxItem } from "../../feeds/inbox.js";
15
+ import { changeFeedUrl, FeedError, MultipleFeedsFoundError, subscribeToFeed, unsubscribeFromFeed, updateFeedSettings } from "../../feeds/service.js";
16
+ import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.js";
17
+ import { buildOverview } from "../../mcp/digest.js";
18
+ import { decodeCursor, encodeCursor } from "../../mcp/items.js";
19
+ import { readingPositions, serializeItem, serializeSubscriptionDetail } from "./serialize.js";
20
+ import { dismissEdition, editionCandidates, generateEdition, generateEditionSchema, getEdition, getEditionState } from "../../native/editions.js";
21
+ import { getNativePreferences, patchNativePreferences } from "../../native/preferences.js";
22
+ import { NativeError } from "../../native/requests.js";
23
+ import { capabilityDenial, hasUserCapability, requireCapability } from "../../auth/capabilities.js";
24
+ export const resourcesRouter = Router();
25
+ const API_BASE = "/api/v1";
26
+ const origin = (req) => `${req.protocol}://${req.get("host")}`;
27
+ const imageLimiter = rateLimit({ windowMs: 60 * 1000, limit: 600, standardHeaders: true, legacyHeaders: false });
28
+ const urlField = z.preprocess((val) => (typeof val === "string" ? normalizeUrlCandidate(val) : val), z.string().url());
29
+ const invalid = (res, details) => void res.status(400).json({ error: "invalid_input", ...(details ? { details } : {}) });
30
+ function subscriptionOf(userId, id) {
31
+ const subscription = listSubscriptionsForUser(userId).find((entry) => entry.id === id);
32
+ return subscription ? serializeSubscriptionDetail(subscription) : null;
33
+ }
34
+ // ---- Account -------------------------------------------------------------
35
+ resourcesRouter.get("/overview", (req, res) => {
36
+ res.json(buildOverview(req.user.id));
37
+ });
38
+ // ---- Subscriptions -------------------------------------------------------
39
+ resourcesRouter.get("/subscriptions", (req, res) => {
40
+ res.json(listSubscriptionsForUser(req.user.id).map(serializeSubscriptionDetail));
41
+ });
42
+ const subscribeSchema = z.object({
43
+ url: urlField,
44
+ label: z.string().max(200).nullable().optional(),
45
+ folderId: z.number().int().positive().nullable().optional(),
46
+ });
47
+ resourcesRouter.post("/subscriptions", async (req, res) => {
48
+ const parsed = subscribeSchema.safeParse(req.body);
49
+ if (!parsed.success)
50
+ return invalid(res, parsed.error.flatten());
51
+ try {
52
+ const created = await subscribeToFeed(req.user.id, parsed.data.url, parsed.data.label ?? null, parsed.data.folderId ?? null);
53
+ res.status(201).json(subscriptionOf(req.user.id, created.id));
54
+ }
55
+ catch (error) {
56
+ if (error instanceof SsrfBlockedError)
57
+ return void res.status(400).json({ error: "blocked_url", message: error.message });
58
+ if (error instanceof MultipleFeedsFoundError)
59
+ return void res.status(409).json({ error: "multiple_feeds_found", feeds: error.feeds });
60
+ if (error instanceof FeedError)
61
+ return void res.status(error.message === "already_subscribed" ? 409 : 400).json({ error: error.message });
62
+ res.status(400).json({ error: "invalid_feed", message: error instanceof Error ? error.message : String(error) });
63
+ }
64
+ });
65
+ const reorderSchema = z.object({ ids: z.array(z.number().int().positive()) });
66
+ resourcesRouter.put("/subscriptions/order", (req, res) => {
67
+ const parsed = reorderSchema.safeParse(req.body);
68
+ const current = listSubscriptionsForUser(req.user.id).map((subscription) => subscription.id);
69
+ const ids = parsed.success ? parsed.data.ids : [];
70
+ if (!parsed.success || ids.length !== current.length || new Set(ids).size !== current.length || ids.some((id) => !current.includes(id))) {
71
+ return invalid(res, "ids must list every subscription exactly once");
72
+ }
73
+ reorderSubscriptions(req.user.id, ids);
74
+ res.status(204).end();
75
+ });
76
+ const updateSchema = z.object({
77
+ url: urlField.optional(),
78
+ label: z.string().max(200).nullable().optional(),
79
+ folderId: z.number().int().positive().nullable().optional(),
80
+ pollIntervalMinutes: z.number().int().positive().max(10080).optional(),
81
+ fullTextMode: z.enum(["auto", "never"]).optional(),
82
+ notify: z.boolean().optional(),
83
+ badge: z.boolean().optional(),
84
+ });
85
+ resourcesRouter.patch("/subscriptions/:id", async (req, res) => {
86
+ const id = Number(req.params.id);
87
+ const parsed = updateSchema.safeParse(req.body);
88
+ if (!parsed.success)
89
+ return invalid(res, parsed.error.flatten());
90
+ const { url, ...settings } = parsed.data;
91
+ try {
92
+ let targetId = id;
93
+ if (url)
94
+ targetId = (await changeFeedUrl(req.user.id, id, url)).subscription.id;
95
+ updateFeedSettings(req.user.id, targetId, settings);
96
+ res.json(subscriptionOf(req.user.id, targetId));
97
+ }
98
+ catch (error) {
99
+ if (error instanceof MultipleFeedsFoundError)
100
+ return void res.status(409).json({ error: "multiple_feeds_found", feeds: error.feeds });
101
+ if (error instanceof SsrfBlockedError)
102
+ return void res.status(400).json({ error: "blocked_url", message: error.message });
103
+ if (error instanceof FeedError)
104
+ return void res.status(error.message === "not_subscribed" ? 404 : 400).json({ error: error.message });
105
+ res.status(400).json({ error: "invalid_feed", message: error instanceof Error ? error.message : String(error) });
106
+ }
107
+ });
108
+ resourcesRouter.delete("/subscriptions/:id", (req, res) => {
109
+ try {
110
+ unsubscribeFromFeed(req.user.id, Number(req.params.id));
111
+ res.status(204).end();
112
+ }
113
+ catch (error) {
114
+ if (error instanceof FeedError)
115
+ return void res.status(404).json({ error: error.message });
116
+ throw error;
117
+ }
118
+ });
119
+ async function refresh(userId, id) {
120
+ const feed = findFeedById(id);
121
+ if (!feed || !isUserSubscribed(userId, id))
122
+ return null;
123
+ const result = await pollFeed(feed);
124
+ return { subscription: subscriptionOf(userId, id), ...result };
125
+ }
126
+ resourcesRouter.post("/subscriptions/:id/refresh", async (req, res) => {
127
+ const result = await refresh(req.user.id, Number(req.params.id));
128
+ if (!result)
129
+ return void res.status(404).json({ error: "not_found" });
130
+ res.json(result);
131
+ });
132
+ resourcesRouter.post("/refresh", async (req, res) => {
133
+ const subscriptions = listSubscriptionsForUser(req.user.id);
134
+ const { newItems, errors, deferred } = await pollFeeds(subscriptions);
135
+ res.json({ refreshed: subscriptions.length, newItems, errors, deferred });
136
+ });
137
+ resourcesRouter.get("/subscriptions/:id/icon", imageLimiter, async (req, res) => {
138
+ const id = Number(req.params.id);
139
+ const icon = canAccessFeed(req.user.id, id) ? await loadFeedIcon(id) : null;
140
+ if (!icon)
141
+ return void res.status(404).json({ error: "not_found" });
142
+ if (icon.hash) {
143
+ res.setHeader("ETag", `"${icon.hash}"`);
144
+ const ifNoneMatch = req.headers["if-none-match"];
145
+ if (ifNoneMatch === `"${icon.hash}"` || ifNoneMatch === icon.hash) {
146
+ return void res.status(304).end();
147
+ }
148
+ }
149
+ const v = typeof req.query.v === "string" ? req.query.v : "";
150
+ if (v && v === icon.hash) {
151
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
152
+ }
153
+ else {
154
+ res.setHeader("Cache-Control", "private, max-age=86400");
155
+ }
156
+ // SVG icons must never run scripts, even when opened directly.
157
+ res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
158
+ res.type(icon.mime).send(icon.buffer);
159
+ });
160
+ resourcesRouter.post("/discover", async (req, res) => {
161
+ const parsed = z.object({ url: urlField }).safeParse(req.body);
162
+ if (!parsed.success)
163
+ return invalid(res, parsed.error.flatten());
164
+ try {
165
+ res.json({ feeds: await discoverFeeds(parsed.data.url) });
166
+ }
167
+ catch (error) {
168
+ if (error instanceof SsrfBlockedError)
169
+ return void res.status(400).json({ error: "blocked_url", message: error.message });
170
+ res.status(400).json({ error: "discovery_failed", message: error instanceof Error ? error.message : String(error) });
171
+ }
172
+ });
173
+ resourcesRouter.get("/opml", (req, res) => {
174
+ res.setHeader("Content-Type", "text/x-opml; charset=utf-8");
175
+ res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
176
+ res.send(generateOpml(req.user.id));
177
+ });
178
+ resourcesRouter.post("/opml", async (req, res) => {
179
+ const xml = typeof req.body === "string" ? req.body : typeof req.body?.opml === "string" ? req.body.opml : "";
180
+ if (!xml.trim())
181
+ return void res.status(400).json({ error: "missing_opml_content" });
182
+ try {
183
+ res.json(await importOpmlFeeds(req.user.id, xml));
184
+ }
185
+ catch (error) {
186
+ res.status(400).json({ error: "invalid_opml", message: error instanceof Error ? error.message : String(error) });
187
+ }
188
+ });
189
+ // ---- Folders and muted keywords -----------------------------------------
190
+ const serializeFolder = (folder) => ({
191
+ id: folder.id,
192
+ name: folder.name,
193
+ iconSymbol: folder.icon_symbol ?? null,
194
+ subscriptionCount: folder.feed_count,
195
+ unreadCount: folder.unread_count,
196
+ });
197
+ export const SF_SYMBOL_REGEX = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/i;
198
+ const sfSymbolSchema = z
199
+ .string()
200
+ .trim()
201
+ .min(1)
202
+ .max(100)
203
+ .regex(SF_SYMBOL_REGEX, "Invalid SF Symbol name")
204
+ .transform((val) => val.toLowerCase());
205
+ const createFolderSchema = z.object({
206
+ name: z.string().trim().min(1).max(100),
207
+ iconSymbol: sfSymbolSchema.nullable().optional(),
208
+ });
209
+ const patchFolderSchema = z
210
+ .object({
211
+ name: z.string().trim().min(1).max(100).optional(),
212
+ iconSymbol: sfSymbolSchema.nullable().optional(),
213
+ })
214
+ .refine((data) => data.name !== undefined || data.iconSymbol !== undefined, {
215
+ message: "Must provide name or iconSymbol",
216
+ });
217
+ resourcesRouter.get("/folders", (req, res) => {
218
+ res.json(listFoldersForUser(req.user.id).map(serializeFolder));
219
+ });
220
+ resourcesRouter.post("/folders", (req, res) => {
221
+ const parsed = createFolderSchema.safeParse(req.body);
222
+ if (!parsed.success)
223
+ return invalid(res, parsed.error.flatten());
224
+ if (parsed.data.iconSymbol !== undefined && !hasUserCapability(req.user.id, "sync")) {
225
+ return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
226
+ }
227
+ try {
228
+ const folder = createFolder(req.user.id, parsed.data.name, parsed.data.iconSymbol);
229
+ res.status(201).json({
230
+ id: folder.id,
231
+ name: folder.name,
232
+ iconSymbol: folder.icon_symbol ?? null,
233
+ subscriptionCount: 0,
234
+ unreadCount: 0,
235
+ });
236
+ }
237
+ catch {
238
+ res.status(409).json({ error: "folder_name_taken" });
239
+ }
240
+ });
241
+ resourcesRouter.patch("/folders/:id", (req, res) => {
242
+ const id = Number(req.params.id);
243
+ const parsed = patchFolderSchema.safeParse(req.body);
244
+ if (!parsed.success)
245
+ return invalid(res, parsed.error.flatten());
246
+ if (parsed.data.iconSymbol !== undefined && !hasUserCapability(req.user.id, "sync")) {
247
+ return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
248
+ }
249
+ if (!findFolderById(req.user.id, id))
250
+ return void res.status(404).json({ error: "folder_not_found" });
251
+ try {
252
+ updateFolder(req.user.id, id, parsed.data);
253
+ }
254
+ catch {
255
+ return void res.status(409).json({ error: "folder_name_taken" });
256
+ }
257
+ const folder = listFoldersForUser(req.user.id).find((item) => item.id === id);
258
+ if (!folder)
259
+ return void res.status(404).json({ error: "folder_not_found" });
260
+ res.json(serializeFolder(folder));
261
+ });
262
+ resourcesRouter.delete("/folders/:id", (req, res) => {
263
+ const id = Number(req.params.id);
264
+ if (!findFolderById(req.user.id, id))
265
+ return void res.status(404).json({ error: "folder_not_found" });
266
+ deleteFolder(req.user.id, id);
267
+ res.status(204).end();
268
+ });
269
+ resourcesRouter.get("/muted-keywords", (req, res) => {
270
+ res.json(listMutedKeywords(req.user.id).map((keyword) => ({ id: keyword.id, keyword: keyword.keyword })));
271
+ });
272
+ resourcesRouter.post("/muted-keywords", (req, res) => {
273
+ const parsed = z.object({ keyword: z.string().trim().min(1).max(100) }).safeParse(req.body);
274
+ if (!parsed.success)
275
+ return invalid(res, parsed.error.flatten());
276
+ const created = addMutedKeyword(req.user.id, parsed.data.keyword);
277
+ res.status(201).json({ id: created.id, keyword: created.keyword });
278
+ });
279
+ resourcesRouter.delete("/muted-keywords/:id", (req, res) => {
280
+ const id = Number(req.params.id);
281
+ if (!Number.isInteger(id))
282
+ return invalid(res);
283
+ removeMutedKeyword(req.user.id, id);
284
+ res.status(204).end();
285
+ });
286
+ // ---- Articles ------------------------------------------------------------
287
+ const dateInput = z.union([z.string().datetime({ offset: true }), z.string().date()]);
288
+ const flag = z.enum(["true", "false"]).transform((value) => value === "true");
289
+ const itemsQuery = z.object({
290
+ subscriptionId: z.coerce.number().int().positive().optional(),
291
+ folderId: z.coerce.number().int().positive().optional(),
292
+ unread: flag.optional(),
293
+ saved: flag.optional(),
294
+ q: z.string().max(200).optional(),
295
+ since: dateInput.optional(),
296
+ until: dateInput.optional(),
297
+ after: z.string().max(256).optional(),
298
+ before: z.string().max(256).optional(),
299
+ limit: z.coerce.number().int().min(1).max(100).default(50),
300
+ include: z.enum(["content"]).optional(),
301
+ images: z.enum(["original", "proxy"]).default("original"),
302
+ });
303
+ /** Rows ready for a native client: images served by this server where it keeps copies. */
304
+ function prepare(rows, req, images) {
305
+ const archived = withArchivedImages(rows, origin(req), API_BASE);
306
+ return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE) : archived;
307
+ }
308
+ const searchQuery = z.object({
309
+ q: z.string().trim().min(1).max(200),
310
+ subscriptionId: z.coerce.number().int().positive().optional(),
311
+ folderId: z.coerce.number().int().positive().optional(),
312
+ unread: z.enum(["true", "false"]).transform((v) => v === "true").optional(),
313
+ saved: z.enum(["true", "false"]).transform((v) => v === "true").optional(),
314
+ include: z.enum(["content"]).optional(),
315
+ images: z.enum(["original", "proxy"]).default("proxy"),
316
+ limit: z.coerce.number().int().min(1).max(100).default(30),
317
+ offset: z.coerce.number().int().min(0).default(0),
318
+ sort: z.enum(["relevance", "date"]).default("relevance"),
319
+ });
320
+ resourcesRouter.get("/search", requireCapability("search.fts"), (req, res) => {
321
+ const parsed = searchQuery.safeParse(req.query);
322
+ if (!parsed.success)
323
+ return invalid(res, parsed.error.flatten());
324
+ const query = parsed.data;
325
+ const withContent = query.include === "content";
326
+ const limit = withContent ? Math.min(query.limit, 50) : query.limit;
327
+ const { items: rows, total } = searchItemsForUser(req.user.id, {
328
+ query: query.q,
329
+ feedId: query.subscriptionId,
330
+ folderId: query.folderId,
331
+ unreadOnly: query.unread,
332
+ bookmarkedOnly: query.saved,
333
+ sort: query.sort,
334
+ limit,
335
+ offset: query.offset,
336
+ });
337
+ const progress = readingPositions(req.user.id, rows.map((row) => row.id));
338
+ const hasNotes = itemsWithNotes(req.user.id, rows.map((row) => row.id));
339
+ res.json({
340
+ query: query.q,
341
+ total,
342
+ items: prepare(rows, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
343
+ });
344
+ });
345
+ resourcesRouter.get("/items", (req, res) => {
346
+ const parsed = itemsQuery.safeParse(req.query);
347
+ if (!parsed.success)
348
+ return invalid(res, parsed.error.flatten());
349
+ const query = parsed.data;
350
+ const withContent = query.include === "content";
351
+ const limit = withContent ? Math.min(query.limit, 50) : query.limit;
352
+ let rows;
353
+ try {
354
+ rows = listItemsForUser(req.user.id, {
355
+ feedId: query.subscriptionId,
356
+ folderId: query.folderId,
357
+ unreadOnly: query.unread,
358
+ bookmarkedOnly: query.saved,
359
+ search: query.q,
360
+ publishedSince: query.since,
361
+ publishedUntil: query.until,
362
+ before: query.before ? decodeCursor(query.before) : undefined,
363
+ after: query.after ? decodeCursor(query.after) : undefined,
364
+ sortByAdded: true,
365
+ limit: limit + 1,
366
+ });
367
+ }
368
+ catch {
369
+ return void res.status(400).json({ error: "invalid_cursor" });
370
+ }
371
+ const hasMore = rows.length > limit;
372
+ const page = rows.slice(0, limit);
373
+ const progress = readingPositions(req.user.id, page.map((row) => row.id));
374
+ const hasNotes = itemsWithNotes(req.user.id, page.map((row) => row.id));
375
+ res.json({
376
+ items: prepare(page, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
377
+ newestCursor: page[0] ? encodeCursor(page[0]) : null,
378
+ nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null,
379
+ });
380
+ });
381
+ // Several articles with all their content in one request, for storing offline copies.
382
+ resourcesRouter.get("/items/bundle", (req, res) => {
383
+ const parsed = z.object({ ids: z.string().regex(/^\d+(,\d+){0,49}$/), images: z.enum(["original", "proxy"]).default("proxy") }).safeParse(req.query);
384
+ if (!parsed.success)
385
+ return invalid(res, "ids: up to 50 comma separated article ids");
386
+ const ids = [...new Set(parsed.data.ids.split(",").map(Number))];
387
+ const rows = ids.map((id) => findItemForUser(req.user.id, id)).filter((row) => Boolean(row));
388
+ const progress = readingPositions(req.user.id, rows.map((row) => row.id));
389
+ const hasNotes = itemsWithNotes(req.user.id, rows.map((row) => row.id));
390
+ res.json({ items: prepare(rows, req, parsed.data.images).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
391
+ });
392
+ // Explicit extraction side effect; read-only devices continue to consume the shared cache.
393
+ resourcesRouter.post("/items/prepare", requireCapability("fulltext"), async (req, res) => {
394
+ const parsed = z.object({ ids: z.array(z.number().int().positive()).min(1).max(50) }).strict().safeParse(req.body);
395
+ if (!parsed.success)
396
+ return invalid(res);
397
+ try {
398
+ const queued = enqueueArticleContents(req.user.id, parsed.data.ids);
399
+ res.status(202).json({ queued });
400
+ }
401
+ catch (error) {
402
+ const code = error instanceof Error ? error.message : "preparation_failed";
403
+ res.status(code === "item_not_found" ? 404 : code === "capability_not_available" ? 403 : code === "queue_full" ? 503 : 500).json({ error: code });
404
+ }
405
+ });
406
+ resourcesRouter.get("/items/:id", (req, res) => {
407
+ const parsed = z.object({ images: z.enum(["original", "proxy"]).default("original") }).safeParse(req.query);
408
+ const row = parsed.success ? findItemForUser(req.user.id, Number(req.params.id)) : undefined;
409
+ if (!parsed.success)
410
+ return invalid(res);
411
+ if (!row)
412
+ return void res.status(404).json({ error: "item_not_found" });
413
+ const progress = readingPositions(req.user.id, [row.id]);
414
+ const hasNotes = itemsWithNotes(req.user.id, [row.id]);
415
+ res.json(serializeItem(prepare([row], req, parsed.data.images)[0], progress, true, hasNotes.has(row.id)));
416
+ });
417
+ resourcesRouter.post("/items/:id/full-text", requireCapability("fulltext"), async (req, res) => {
418
+ const id = Number(req.params.id);
419
+ const result = await loadFullText(req.user.id, id, { force: req.query.force === "true" });
420
+ if (!result.ok) {
421
+ return void res.status(FULL_TEXT_STATUS[result.error]).json({ error: result.error, ...(result.error === "capability_not_available" ? { capability: "fulltext" } : {}), ...(result.message ? { message: result.message } : {}) });
422
+ }
423
+ const row = findItemForUser(req.user.id, id);
424
+ const hasNotes = itemsWithNotes(req.user.id, [id]);
425
+ res.json(serializeItem(prepare([row], req, req.query.images === "proxy" ? "proxy" : "original")[0], readingPositions(req.user.id, [id]), true, hasNotes.has(id)));
426
+ });
427
+ const readAllSchema = z.object({ subscriptionId: z.number().int().positive().optional(), folderId: z.number().int().positive().optional() });
428
+ resourcesRouter.post("/items/read-all", requireCapability("sync"), (req, res) => {
429
+ const parsed = readAllSchema.safeParse(req.body ?? {});
430
+ if (!parsed.success)
431
+ return invalid(res, parsed.error.flatten());
432
+ res.json({ marked: markAllRead(req.user.id, { feedId: parsed.data.subscriptionId, folderId: parsed.data.folderId }) });
433
+ });
434
+ // ---- Article Notes --------------------------------------------------------
435
+ const noteSchema = z.object({
436
+ content: z.string().max(100_000),
437
+ expectedRevision: z.number().int().nonnegative().optional(),
438
+ });
439
+ resourcesRouter.get("/items/:id/note", (req, res) => {
440
+ const id = Number(req.params.id);
441
+ if (!canAccessItem(req.user.id, id))
442
+ return void res.status(404).json({ error: "item_not_found" });
443
+ const note = findItemNote(req.user.id, id);
444
+ if (!note)
445
+ return void res.status(404).json({ error: "note_not_found", current: noteConflictState(req.user.id, id) });
446
+ res.json({
447
+ itemId: note.item_id,
448
+ content: note.content,
449
+ revision: note.revision,
450
+ createdAt: note.created_at,
451
+ updatedAt: note.updated_at,
452
+ });
453
+ });
454
+ resourcesRouter.put("/items/:id/note", requireCapability("notes"), (req, res) => {
455
+ const id = Number(req.params.id);
456
+ if (!canAccessItem(req.user.id, id))
457
+ return void res.status(404).json({ error: "item_not_found" });
458
+ const parsed = noteSchema.safeParse(req.body);
459
+ if (!parsed.success)
460
+ return invalid(res, parsed.error.flatten());
461
+ const result = setItemNote(req.user.id, id, parsed.data.content, parsed.data.expectedRevision);
462
+ if ("conflict" in result) {
463
+ return void res.status(409).json({ error: "revision_conflict", current: result.conflict });
464
+ }
465
+ const n = result.note;
466
+ res.json({
467
+ itemId: n.item_id,
468
+ content: n.content,
469
+ revision: n.revision,
470
+ createdAt: n.created_at,
471
+ updatedAt: n.updated_at,
472
+ });
473
+ });
474
+ resourcesRouter.delete("/items/:id/note", (req, res) => {
475
+ const id = Number(req.params.id);
476
+ if (!canAccessItem(req.user.id, id))
477
+ return void res.status(404).json({ error: "item_not_found" });
478
+ const parsed = z.object({ expectedRevision: z.number().int().nonnegative().optional() }).safeParse(req.body ?? {});
479
+ if (!parsed.success)
480
+ return invalid(res, parsed.error.flatten());
481
+ const result = deleteItemNote(req.user.id, id, parsed.data.expectedRevision);
482
+ if ("conflict" in result)
483
+ return void res.status(409).json({ error: "revision_conflict", current: result.conflict });
484
+ res.status(204).end();
485
+ });
486
+ resourcesRouter.get("/items/:id/note.md", (req, res) => {
487
+ const id = Number(req.params.id);
488
+ if (!canAccessItem(req.user.id, id))
489
+ return void res.status(404).json({ error: "item_not_found" });
490
+ const note = findItemNote(req.user.id, id);
491
+ if (!note)
492
+ return void res.status(404).json({ error: "note_not_found" });
493
+ const item = findItemForUser(req.user.id, id);
494
+ const title = item?.title ?? `Article #${id}`;
495
+ const url = item?.link ? `Source: ${item.link}\n\n` : "";
496
+ const md = `# ${title}\n\n${url}${note.content}\n`;
497
+ res.setHeader("Content-Type", "text/markdown; charset=utf-8");
498
+ res.setHeader("Content-Disposition", `inline; filename="note-${id}.md"`);
499
+ res.send(md);
500
+ });
501
+ resourcesRouter.get("/notes/export", (req, res) => {
502
+ const notes = listNotesForUser(req.user.id);
503
+ const sections = notes.map((note) => {
504
+ const title = note.item_title ?? `Article #${note.item_id}`;
505
+ const link = note.item_link ? `- Link: ${note.item_link}\n` : "";
506
+ const feed = note.feed_title ? `- Feed: ${note.feed_title}\n` : "";
507
+ const updated = `- Updated: ${note.updated_at}\n`;
508
+ return `## ${title}\n${link}${feed}${updated}\n${note.content}`;
509
+ });
510
+ const md = `# FeedKeeper Notes\n\nExported: ${new Date().toISOString()}\nTotal notes: ${notes.length}\n\n---\n\n${sections.join("\n\n---\n\n")}\n`;
511
+ res.setHeader("Content-Type", "text/markdown; charset=utf-8");
512
+ res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-notes.md"');
513
+ res.send(md);
514
+ });
515
+ // ---- Curated Edition ("Deine Zeitung") ------------------------------------
516
+ function editionResponse(req, edition) {
517
+ const rows = edition.itemIds
518
+ .map((id) => findItemForUser(req.user.id, id))
519
+ .filter((row) => Boolean(row));
520
+ const progress = readingPositions(req.user.id, rows.map((r) => r.id));
521
+ const hasNotes = itemsWithNotes(req.user.id, rows.map((r) => r.id));
522
+ const prepared = prepare(rows, req, "original");
523
+ return {
524
+ ...edition,
525
+ items: prepared.map((row) => serializeItem(row, progress, false, hasNotes.has(row.id))),
526
+ };
527
+ }
528
+ function nativeAction(res, action) {
529
+ res.setHeader("Cache-Control", "private, no-store");
530
+ try {
531
+ action();
532
+ }
533
+ catch (error) {
534
+ if (!(error instanceof NativeError))
535
+ throw error;
536
+ res.status(error.status).json({ error: error.code, ...(error.current !== undefined ? { current: error.current } : {}) });
537
+ }
538
+ }
539
+ resourcesRouter.get("/native-preferences", (req, res) => {
540
+ res.setHeader("Cache-Control", "private, no-store");
541
+ res.json(getNativePreferences(req.user.id));
542
+ });
543
+ resourcesRouter.patch("/native-preferences", requireCapability("sync"), (req, res) => {
544
+ nativeAction(res, () => res.json(patchNativePreferences(req.user.id, req.body)));
545
+ });
546
+ resourcesRouter.get("/edition", (req, res) => {
547
+ nativeAction(res, () => {
548
+ const edition = getEdition(req.user.id);
549
+ if (!edition)
550
+ return void res.status(404).json({ error: "no_edition", current: getEditionState(req.user.id) });
551
+ res.json(editionResponse(req, edition));
552
+ });
553
+ });
554
+ resourcesRouter.get("/edition/state", (req, res) => {
555
+ res.setHeader("Cache-Control", "private, no-store");
556
+ res.json(getEditionState(req.user.id));
557
+ });
558
+ resourcesRouter.get("/edition/candidates", requireCapability("editions"), (req, res) => {
559
+ const parsed = z.object({ limit: z.coerce.number().int().min(1).max(200).default(100) }).safeParse(req.query);
560
+ if (!parsed.success)
561
+ return invalid(res);
562
+ res.setHeader("Cache-Control", "private, no-store");
563
+ res.json({ preferences: getNativePreferences(req.user.id), current: getEditionState(req.user.id), candidates: editionCandidates(req.user.id, new Date(), parsed.data.limit) });
564
+ });
565
+ resourcesRouter.post("/edition/generate", requireCapability("editions"), (req, res) => {
566
+ const parsed = generateEditionSchema.safeParse(req.body);
567
+ if (!parsed.success)
568
+ return invalid(res, parsed.error.flatten());
569
+ nativeAction(res, () => {
570
+ const edition = generateEdition(req.user.id, parsed.data);
571
+ if (!edition)
572
+ return void res.status(404).json({ error: "no_edition", current: getEditionState(req.user.id) });
573
+ res.json(editionResponse(req, edition));
574
+ });
575
+ });
576
+ resourcesRouter.delete("/edition", (req, res) => {
577
+ const parsed = z.object({ expectedRevision: z.number().int().min(0).optional(), requestId: z.uuid().optional() }).strict().safeParse(req.body ?? {});
578
+ if (!parsed.success)
579
+ return invalid(res);
580
+ nativeAction(res, () => {
581
+ dismissEdition(req.user.id, parsed.data);
582
+ res.status(204).end();
583
+ });
584
+ });
585
+ // ---- Universal Inbox ("Save to FeedKeeper") -------------------------------
586
+ const saveInboxSchema = z
587
+ .object({
588
+ url: z.string().url().optional(),
589
+ title: z.string().max(500).optional(),
590
+ contentHtml: z.string().optional(),
591
+ textContent: z.string().optional(),
592
+ note: z.string().max(20000).optional(),
593
+ })
594
+ .refine((data) => Boolean(data.url || data.contentHtml || data.textContent), {
595
+ message: "At least one of url, contentHtml, or textContent must be provided",
596
+ });
597
+ resourcesRouter.post("/inbox", requireCapability("inbox"), async (req, res) => {
598
+ const parsed = saveInboxSchema.safeParse(req.body ?? {});
599
+ if (!parsed.success)
600
+ return invalid(res, parsed.error.flatten());
601
+ try {
602
+ const result = await saveToInbox(req.user.id, parsed.data);
603
+ const progress = readingPositions(req.user.id, [result.itemId]);
604
+ const prepared = prepare([result.item], req, "original");
605
+ res.status(201).json(serializeItem(prepared[0], progress, true, result.hasNote));
606
+ }
607
+ catch (error) {
608
+ if (error instanceof SsrfBlockedError) {
609
+ return void res.status(400).json({ error: error.message });
610
+ }
611
+ throw error;
612
+ }
613
+ });
614
+ resourcesRouter.delete("/inbox/:id", requireCapability("inbox"), (req, res) => {
615
+ const id = Number(req.params.id);
616
+ const deleted = deleteInboxItem(req.user.id, id);
617
+ if (!deleted) {
618
+ return void res.status(404).json({ error: "item_not_found" });
619
+ }
620
+ res.status(204).end();
621
+ });
622
+ // ---- Images --------------------------------------------------------------
623
+ resourcesRouter.get("/items/:id/image", imageLimiter, async (req, res) => {
624
+ const src = typeof req.query.src === "string" ? req.query.src : "";
625
+ try {
626
+ const image = await fetchItemImage(req.user.id, Number(req.params.id), src);
627
+ if (image === "forbidden")
628
+ return void res.status(404).json({ error: "not_found" });
629
+ if (image === "unsupported")
630
+ return void res.status(415).json({ error: "unsupported_image" });
631
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
632
+ res.type(image.mime).send(image.buffer);
633
+ }
634
+ catch (error) {
635
+ if (error instanceof ImageCacheBusyError) {
636
+ res.setHeader("Retry-After", "1");
637
+ res.status(503).json({ error: "image_busy" });
638
+ return;
639
+ }
640
+ res.status(502).json({ error: "image_unavailable" });
641
+ }
642
+ });
643
+ resourcesRouter.get("/archive/images/:imageId", (req, res) => {
644
+ const image = findArchivedImage(req.user.id, Number(req.params.imageId));
645
+ if (!image)
646
+ return void res.status(404).json({ error: "not_found" });
647
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
648
+ res.type(image.mime).sendFile(image.path, (error) => {
649
+ if (error && !res.headersSent)
650
+ res.status(404).json({ error: "not_found" });
651
+ });
652
+ });
653
+ //# sourceMappingURL=resources.js.map