feedkeeper 0.35.4 → 0.35.5
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/package.json +3 -3
- package/server/dist/api/auth.js +1 -1
- package/server/dist/api/feeds.js +1 -1
- package/server/dist/api/folders.js +11 -8
- package/server/dist/api/onboarding.js +2 -1
- package/server/dist/api/v1/resources.js +14 -19
- package/server/dist/auth/middleware.js +2 -2
- package/server/dist/db/migrations/0041_sync_feed_metadata.sql +16 -0
- package/server/dist/feeds/archive.js +3 -2
- package/server/dist/feeds/content/noise/lazyImages.js +7 -6
- package/server/dist/feeds/extractor.js +1 -1
- package/server/dist/feeds/feedIcon.js +4 -2
- package/server/dist/feeds/imageSources.js +69 -0
- package/server/dist/feeds/inbox.js +4 -9
- package/server/dist/feeds/opml.js +122 -27
- package/server/dist/feeds/repository.js +19 -7
- package/server/dist/mcp/server.js +10 -0
- package/server/dist/server.js +1 -1
- package/server/dist/sync/changeLog.js +1 -0
- package/server/package.json +1 -1
- package/web/dist/assets/index-CilgZLF5.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/index-CYi9R_Ds.js +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedkeeper",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.5",
|
|
4
4
|
"description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rss",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"cors": "^2.8.6",
|
|
45
45
|
"dotenv": "^18.0.8",
|
|
46
46
|
"express": "^5.2.1",
|
|
47
|
-
"express-rate-limit": "^8.7.
|
|
47
|
+
"express-rate-limit": "^8.7.2",
|
|
48
48
|
"fast-xml-parser": "^5.11.2",
|
|
49
49
|
"helmet": "^8.3.0",
|
|
50
50
|
"ipaddr.js": "^2.5.0",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"node-cron": "^4.6.0",
|
|
53
53
|
"otpauth": "^9.5.2",
|
|
54
54
|
"rss-parser": "^3.13.0",
|
|
55
|
-
"undici": "^8.12.
|
|
55
|
+
"undici": "^8.12.3",
|
|
56
56
|
"web-push": "^3.6.7",
|
|
57
57
|
"zod": "^4.6.5"
|
|
58
58
|
}
|
package/server/dist/api/auth.js
CHANGED
|
@@ -161,7 +161,7 @@ authRouter.post("/signup", (req, res) => {
|
|
|
161
161
|
return;
|
|
162
162
|
}
|
|
163
163
|
const user = createUser({ ...parsed.data, role: "user" });
|
|
164
|
-
req.session
|
|
164
|
+
req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
|
|
165
165
|
res.status(201).json(toPublicUser(user));
|
|
166
166
|
});
|
|
167
167
|
//# sourceMappingURL=auth.js.map
|
package/server/dist/api/feeds.js
CHANGED
|
@@ -54,7 +54,7 @@ feedsRouter.get("/:feedId/icon", iconLimiter, async (req, res) => {
|
|
|
54
54
|
});
|
|
55
55
|
feedsRouter.get("/opml", (req, res) => {
|
|
56
56
|
const opml = generateOpml(req.user.id);
|
|
57
|
-
res.setHeader("Content-Type", "
|
|
57
|
+
res.setHeader("Content-Type", "text/x-opml; charset=utf-8");
|
|
58
58
|
res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
|
|
59
59
|
res.send(opml);
|
|
60
60
|
});
|
|
@@ -19,11 +19,7 @@ foldersRouter.post("/", requireCapability("sync"), (req, res) => {
|
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
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
|
-
});
|
|
22
|
+
res.status(201).json(listFoldersForUser(req.user.id).find((entry) => entry.id === folder.id));
|
|
27
23
|
});
|
|
28
24
|
foldersRouter.patch("/:folderId", (req, res) => {
|
|
29
25
|
const folderId = Number(req.params.folderId);
|
|
@@ -37,15 +33,22 @@ foldersRouter.patch("/:folderId", (req, res) => {
|
|
|
37
33
|
res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
|
|
38
34
|
return;
|
|
39
35
|
}
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
try {
|
|
37
|
+
const updated = updateFolder(req.user.id, folderId, parsed.data.name);
|
|
38
|
+
res.json(updated);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (!(error instanceof Error && "code" in error && error.code === "SQLITE_CONSTRAINT_UNIQUE"))
|
|
42
|
+
throw error;
|
|
43
|
+
res.status(409).json({ error: "folder_name_taken" });
|
|
44
|
+
}
|
|
42
45
|
});
|
|
43
46
|
foldersRouter.delete("/:folderId", (req, res) => {
|
|
44
47
|
const folderId = Number(req.params.folderId);
|
|
45
48
|
deleteFolder(req.user.id, folderId);
|
|
46
49
|
res.json({ success: true });
|
|
47
50
|
});
|
|
48
|
-
foldersRouter.post("/:folderId/read", (req, res) => {
|
|
51
|
+
foldersRouter.post("/:folderId/read", requireCapability("sync"), (req, res) => {
|
|
49
52
|
const folderId = Number(req.params.folderId);
|
|
50
53
|
const updatedCount = markAllRead(req.user.id, { folderId });
|
|
51
54
|
res.json({ read: updatedCount });
|
|
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { hasAnyUser } from "../db/index.js";
|
|
4
4
|
import { createUser } from "../auth/users.js";
|
|
5
|
+
import { securityVersion } from "../auth/security.js";
|
|
5
6
|
export const onboardingRouter = Router();
|
|
6
7
|
onboardingRouter.get("/status", (_req, res) => {
|
|
7
8
|
res.json({ needsOnboarding: !hasAnyUser() });
|
|
@@ -24,7 +25,7 @@ onboardingRouter.post("/", (req, res) => {
|
|
|
24
25
|
return;
|
|
25
26
|
}
|
|
26
27
|
const user = createUser({ ...parsed.data, role: "admin" });
|
|
27
|
-
req.session
|
|
28
|
+
req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
|
|
28
29
|
res.status(201).json({ id: user.id, email: user.email, displayName: user.display_name, role: user.role });
|
|
29
30
|
});
|
|
30
31
|
//# sourceMappingURL=onboarding.js.map
|
|
@@ -53,7 +53,7 @@ const subscribeSchema = z.object({
|
|
|
53
53
|
label: z.string().max(200).nullable().optional(),
|
|
54
54
|
folderId: z.number().int().positive().nullable().optional(),
|
|
55
55
|
});
|
|
56
|
-
resourcesRouter.post("/subscriptions", async (req, res) => {
|
|
56
|
+
resourcesRouter.post("/subscriptions", requireCapability("sync"), async (req, res) => {
|
|
57
57
|
const parsed = subscribeSchema.safeParse(req.body);
|
|
58
58
|
if (!parsed.success)
|
|
59
59
|
return invalid(res, parsed.error.flatten());
|
|
@@ -97,6 +97,9 @@ resourcesRouter.patch("/subscriptions/:id", async (req, res) => {
|
|
|
97
97
|
if (!parsed.success)
|
|
98
98
|
return invalid(res, parsed.error.flatten());
|
|
99
99
|
const { url, ...settings } = parsed.data;
|
|
100
|
+
if (url && !hasUserCapability(req.user.id, "sync")) {
|
|
101
|
+
return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
|
|
102
|
+
}
|
|
100
103
|
try {
|
|
101
104
|
let targetId = id;
|
|
102
105
|
if (url)
|
|
@@ -132,13 +135,13 @@ async function refresh(userId, id) {
|
|
|
132
135
|
const result = await pollFeed(feed);
|
|
133
136
|
return { subscription: subscriptionOf(userId, id), ...result };
|
|
134
137
|
}
|
|
135
|
-
resourcesRouter.post("/subscriptions/:id/refresh", async (req, res) => {
|
|
138
|
+
resourcesRouter.post("/subscriptions/:id/refresh", requireCapability("sync"), async (req, res) => {
|
|
136
139
|
const result = await refresh(req.user.id, Number(req.params.id));
|
|
137
140
|
if (!result)
|
|
138
141
|
return void res.status(404).json({ error: "not_found" });
|
|
139
142
|
res.json(result);
|
|
140
143
|
});
|
|
141
|
-
resourcesRouter.post("/refresh", async (req, res) => {
|
|
144
|
+
resourcesRouter.post("/refresh", requireCapability("sync"), async (req, res) => {
|
|
142
145
|
const subscriptions = listSubscriptionsForUser(req.user.id);
|
|
143
146
|
const { newItems, errors, deferred } = await pollFeeds(subscriptions);
|
|
144
147
|
res.json({ refreshed: subscriptions.length, newItems, errors, deferred });
|
|
@@ -166,7 +169,7 @@ resourcesRouter.get("/subscriptions/:id/icon", imageLimiter, async (req, res) =>
|
|
|
166
169
|
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
|
167
170
|
res.type(icon.mime).send(icon.buffer);
|
|
168
171
|
});
|
|
169
|
-
resourcesRouter.post("/discover", async (req, res) => {
|
|
172
|
+
resourcesRouter.post("/discover", requireCapability("sync"), async (req, res) => {
|
|
170
173
|
const parsed = z.object({ url: urlField }).safeParse(req.body);
|
|
171
174
|
if (!parsed.success)
|
|
172
175
|
return invalid(res, parsed.error.flatten());
|
|
@@ -184,7 +187,7 @@ resourcesRouter.get("/opml", (req, res) => {
|
|
|
184
187
|
res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
|
|
185
188
|
res.send(generateOpml(req.user.id));
|
|
186
189
|
});
|
|
187
|
-
resourcesRouter.post("/opml", async (req, res) => {
|
|
190
|
+
resourcesRouter.post("/opml", requireCapability("sync"), async (req, res) => {
|
|
188
191
|
const xml = typeof req.body === "string" ? req.body : typeof req.body?.opml === "string" ? req.body.opml : "";
|
|
189
192
|
if (!xml.trim())
|
|
190
193
|
return void res.status(400).json({ error: "missing_opml_content" });
|
|
@@ -233,19 +236,9 @@ resourcesRouter.post("/folders", (req, res) => {
|
|
|
233
236
|
if (parsed.data.iconSymbol !== undefined && !hasUserCapability(req.user.id, "sync")) {
|
|
234
237
|
return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
|
|
235
238
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
id: folder.id,
|
|
240
|
-
name: folder.name,
|
|
241
|
-
iconSymbol: folder.icon_symbol ?? null,
|
|
242
|
-
subscriptionCount: 0,
|
|
243
|
-
unreadCount: 0,
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
res.status(409).json({ error: "folder_name_taken" });
|
|
248
|
-
}
|
|
239
|
+
const folder = createFolder(req.user.id, parsed.data.name, parsed.data.iconSymbol);
|
|
240
|
+
const detail = listFoldersForUser(req.user.id).find((entry) => entry.id === folder.id);
|
|
241
|
+
res.status(201).json(serializeFolder(detail));
|
|
249
242
|
});
|
|
250
243
|
resourcesRouter.patch("/folders/:id", (req, res) => {
|
|
251
244
|
const id = Number(req.params.id);
|
|
@@ -260,7 +253,9 @@ resourcesRouter.patch("/folders/:id", (req, res) => {
|
|
|
260
253
|
try {
|
|
261
254
|
updateFolder(req.user.id, id, parsed.data);
|
|
262
255
|
}
|
|
263
|
-
catch {
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (!(error instanceof Error && "code" in error && error.code === "SQLITE_CONSTRAINT_UNIQUE"))
|
|
258
|
+
throw error;
|
|
264
259
|
return void res.status(409).json({ error: "folder_name_taken" });
|
|
265
260
|
}
|
|
266
261
|
const folder = listFoldersForUser(req.user.id).find((item) => item.id === id);
|
|
@@ -19,7 +19,7 @@ export function requireSession(req, res, next) {
|
|
|
19
19
|
}
|
|
20
20
|
function bearerToken(req) {
|
|
21
21
|
const header = req.headers.authorization;
|
|
22
|
-
return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
|
|
22
|
+
return header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : undefined;
|
|
23
23
|
}
|
|
24
24
|
// MCP auth: Bearer personal access token or OAuth access token (not a paired device), scopes the request to its owner.
|
|
25
25
|
export function requireBearerToken(req, res, next) {
|
|
@@ -27,7 +27,7 @@ export function requireBearerToken(req, res, next) {
|
|
|
27
27
|
}
|
|
28
28
|
// Native app API: a paired device token or a hand-made token; the web UI may call it with its session.
|
|
29
29
|
export function requireSessionOrDevice(req, res, next) {
|
|
30
|
-
if (
|
|
30
|
+
if (req.headers.authorization !== undefined)
|
|
31
31
|
authenticateBearer(["api", "device"])(req, res, next);
|
|
32
32
|
else
|
|
33
33
|
requireSession(req, res, next);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Notify subscribers when a feed's URL, metadata, poll interval, or error state is updated
|
|
2
|
+
DROP TRIGGER IF EXISTS trg_changes_feed_update;
|
|
3
|
+
CREATE TRIGGER trg_changes_feed_update
|
|
4
|
+
AFTER UPDATE OF url, title, site_url, poll_interval_minutes, last_error ON feeds
|
|
5
|
+
WHEN OLD.url IS NOT NEW.url
|
|
6
|
+
OR OLD.title IS NOT NEW.title
|
|
7
|
+
OR OLD.site_url IS NOT NEW.site_url
|
|
8
|
+
OR OLD.poll_interval_minutes IS NOT NEW.poll_interval_minutes
|
|
9
|
+
OR OLD.last_error IS NOT NEW.last_error
|
|
10
|
+
BEGIN
|
|
11
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
12
|
+
SELECT s.user_id, 'subscription', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
13
|
+
FROM subscriptions s
|
|
14
|
+
WHERE s.feed_id = NEW.id
|
|
15
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
16
|
+
END;
|
|
@@ -12,6 +12,7 @@ import { ImageCache, ImageCacheBusyError } from "./imageCache.js";
|
|
|
12
12
|
import { detectImageType, isVectorImageUrl } from "./imageTypes.js";
|
|
13
13
|
export { detectImageType } from "./imageTypes.js";
|
|
14
14
|
import { comparableImageKey } from "./heroImage.js";
|
|
15
|
+
import { normalizeImageSources } from "./imageSources.js";
|
|
15
16
|
const MAX_IMAGES_PER_ARTICLE = 30;
|
|
16
17
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
17
18
|
const FILE_NAME = /^\d+-[0-9a-f]{16}\.(jpg|png|webp|gif|avif)$/;
|
|
@@ -100,7 +101,7 @@ function resolveImageUrl(raw, baseUrl) {
|
|
|
100
101
|
/** Image URLs an article shows, in order, without tracking pixels. */
|
|
101
102
|
export function articleImageUrls(html, baseUrl) {
|
|
102
103
|
const urls = new Set();
|
|
103
|
-
for (const [tag] of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
104
|
+
for (const [tag] of normalizeImageSources(html).matchAll(/<img\b[^>]*>/gi)) {
|
|
104
105
|
if (/\swidth\s*=\s*["']?1["'\s>]/i.test(tag) && /\sheight\s*=\s*["']?1["'\s>]/i.test(tag))
|
|
105
106
|
continue;
|
|
106
107
|
const src = srcAttribute(tag);
|
|
@@ -112,7 +113,7 @@ export function articleImageUrls(html, baseUrl) {
|
|
|
112
113
|
}
|
|
113
114
|
/** Replace the `src` of every image for which `replacement` knows a new address. */
|
|
114
115
|
function rewriteImageSources(html, baseUrl, replacement) {
|
|
115
|
-
return html.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
116
|
+
return normalizeImageSources(html).replace(/<img\b[^>]*>/gi, (tag) => {
|
|
116
117
|
const src = srcAttribute(tag);
|
|
117
118
|
const url = src && resolveImageUrl(src.value, baseUrl);
|
|
118
119
|
const next = url ? replacement(url) : undefined;
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
export const SCRIPT_IMAGE_SOURCE = /\bthis\.src\s*=/i;
|
|
1
|
+
import { firstSrcsetSource, imageSource, usableImageSource } from "../../imageSources.js";
|
|
3
2
|
export const lazyImages = {
|
|
4
3
|
name: "lazy-images",
|
|
5
4
|
description: "Lazy image addresses replace missing, data placeholders or script fragments parsed as image URLs.",
|
|
6
5
|
apply(root, stats) {
|
|
7
6
|
let changed = false;
|
|
8
7
|
for (const image of root.querySelectorAll("img")) {
|
|
9
|
-
|
|
10
|
-
if (current && !/^data:/i.test(current) && !SCRIPT_IMAGE_SOURCE.test(current))
|
|
8
|
+
if (usableImageSource(image.getAttribute("src")))
|
|
11
9
|
continue;
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
let source = imageSource((name) => image.getAttribute(name));
|
|
11
|
+
for (const candidate of image.closest("picture")?.querySelectorAll("source") ?? []) {
|
|
12
|
+
source ??= firstSrcsetSource(candidate.getAttribute("data-lazy-srcset") ?? candidate.getAttribute("data-srcset") ?? candidate.getAttribute("srcset"));
|
|
13
|
+
}
|
|
14
|
+
if (!source)
|
|
14
15
|
continue;
|
|
15
16
|
image.setAttribute("src", source);
|
|
16
17
|
stats["lazy-images"] = (stats["lazy-images"] ?? 0) + 1;
|
|
@@ -24,12 +24,14 @@ export function detectIconType(buffer) {
|
|
|
24
24
|
const head = Buffer.from(buffer.subarray(0, 512)).toString("utf8").trimStart();
|
|
25
25
|
return /^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)?<svg[\s>]/i.test(head) ? "image/svg+xml" : null;
|
|
26
26
|
}
|
|
27
|
-
/** Whether the user subscribes to the feed or has saved or
|
|
27
|
+
/** Whether the user subscribes to the feed or has saved, annotated, or curated one of its articles. */
|
|
28
28
|
export function canAccessFeed(userId, feedId) {
|
|
29
29
|
return Boolean(db.prepare(`SELECT 1 FROM feeds f WHERE f.id = ?
|
|
30
30
|
AND (EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = f.id AND s.user_id = ?)
|
|
31
31
|
OR EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = f.id AND b.user_id = ?)
|
|
32
|
-
OR EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = f.id AND n.user_id = ?)
|
|
32
|
+
OR EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = f.id AND n.user_id = ?)
|
|
33
|
+
OR EXISTS (SELECT 1 FROM items i, editions e, json_each(e.item_ids) selected WHERE i.feed_id = f.id AND e.user_id = ?
|
|
34
|
+
AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(feedId, userId, userId, userId, userId));
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* Declared icons first, then the stored one, then the well-known files of the site, and last the feed's own logo (`<image>` of an RSS channel):
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** A script accidentally parsed as a URL is never an image, even after it was made absolute. */
|
|
2
|
+
export const SCRIPT_IMAGE_SOURCE = /\bthis\.src\s*=/i;
|
|
3
|
+
export function usableImageSource(raw) {
|
|
4
|
+
const source = raw?.trim();
|
|
5
|
+
if (!source || SCRIPT_IMAGE_SOURCE.test(source))
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(source, "https://image-source.invalid/");
|
|
9
|
+
return url.protocol === "https:" || url.protocol === "http:" ? source : null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** URLs may contain commas (notably data placeholders); descriptors end at the next comma. */
|
|
16
|
+
export function firstSrcsetSource(srcset) {
|
|
17
|
+
let rest = srcset?.trim() ?? "";
|
|
18
|
+
while (rest) {
|
|
19
|
+
const match = /^([^\s]+)\s*/.exec(rest);
|
|
20
|
+
const source = match[1].replace(/,+$/, "");
|
|
21
|
+
rest = rest.slice(match[0].length);
|
|
22
|
+
if (!match[1].endsWith(",")) {
|
|
23
|
+
const comma = rest.indexOf(",");
|
|
24
|
+
rest = comma < 0 ? "" : rest.slice(comma + 1).trim();
|
|
25
|
+
}
|
|
26
|
+
const usable = usableImageSource(source);
|
|
27
|
+
if (usable)
|
|
28
|
+
return usable;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function imageSource(attribute) {
|
|
33
|
+
for (const name of ["src", "data-lazy-src", "data-src", "data-original", "data-actualsrc"]) {
|
|
34
|
+
const source = usableImageSource(attribute(name));
|
|
35
|
+
if (source)
|
|
36
|
+
return source;
|
|
37
|
+
}
|
|
38
|
+
for (const name of ["data-lazy-srcset", "data-srcset", "srcset"]) {
|
|
39
|
+
const source = firstSrcsetSource(attribute(name));
|
|
40
|
+
if (source)
|
|
41
|
+
return source;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function attribute(tag, name) {
|
|
46
|
+
const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
|
|
47
|
+
return match ? match[1] ?? match[2] ?? match[3] : null;
|
|
48
|
+
}
|
|
49
|
+
/** Keep one fallback image before archive/proxy rewriting, including raw HTML stored by older versions. */
|
|
50
|
+
export function normalizeImageSources(html) {
|
|
51
|
+
const normalize = (tag, fallback = null) => {
|
|
52
|
+
const source = imageSource((name) => attribute(tag, name)) ?? fallback;
|
|
53
|
+
if (!source)
|
|
54
|
+
return tag;
|
|
55
|
+
const escaped = source.replace(/"/g, """);
|
|
56
|
+
const withoutSources = tag.replace(/\s+(?:(?:data-(?:lazy-)?)?src(?:set)?|sizes|data-sizes|data-original|data-actualsrc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+)/gi, "");
|
|
57
|
+
return withoutSources.replace(/\s*\/?>$/, (end) => ` src="${escaped}"${end}`);
|
|
58
|
+
};
|
|
59
|
+
const pictures = html.replace(/<picture\b[^>]*>[\s\S]*?<\/picture\s*>/gi, (picture) => {
|
|
60
|
+
let fallback = null;
|
|
61
|
+
const withoutSources = picture.replace(/<source\b[^>]*>/gi, (tag) => {
|
|
62
|
+
fallback ??= firstSrcsetSource(attribute(tag, "data-lazy-srcset") ?? attribute(tag, "data-srcset") ?? attribute(tag, "srcset"));
|
|
63
|
+
return "";
|
|
64
|
+
});
|
|
65
|
+
return withoutSources.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag, fallback));
|
|
66
|
+
});
|
|
67
|
+
return pictures.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag));
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=imageSources.js.map
|
|
@@ -119,18 +119,13 @@ export async function saveToInbox(userId, input) {
|
|
|
119
119
|
return { itemId, item, hasNote };
|
|
120
120
|
}
|
|
121
121
|
export function deleteInboxItem(userId, itemId) {
|
|
122
|
-
const inbox =
|
|
122
|
+
const inbox = findFeedByUrl(`feedkeeper://inbox/${userId}`);
|
|
123
123
|
const item = findItemById(itemId);
|
|
124
|
-
if (!item || item.feed_id !== inbox.id) {
|
|
124
|
+
if (!inbox || !item || item.feed_id !== inbox.id) {
|
|
125
125
|
return false;
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
db.prepare("DELETE FROM item_reads WHERE item_id = ?").run(itemId);
|
|
130
|
-
db.prepare("DELETE FROM item_progress WHERE item_id = ?").run(itemId);
|
|
131
|
-
db.prepare("DELETE FROM item_notes WHERE item_id = ?").run(itemId);
|
|
132
|
-
db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
|
|
133
|
-
})();
|
|
127
|
+
// Cascades run after the parent disappears, so state/note triggers do not announce stale updates.
|
|
128
|
+
db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
|
|
134
129
|
pruneArchive();
|
|
135
130
|
return true;
|
|
136
131
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { XMLParser } from "fast-xml-parser";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { config } from "../config.js";
|
|
4
|
-
import { listSubscriptionsForUser, findFeedByUrl, createFeed, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
|
|
5
|
-
import { SsrfBlockedError } from "./ssrfGuard.js";
|
|
4
|
+
import { listSubscriptionsForUser, findFeedByUrl, createFeed, seedFeedMetadata, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
|
|
5
|
+
import { SsrfBlockedError, normalizeUrlCandidate } from "./ssrfGuard.js";
|
|
6
6
|
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
7
7
|
import { requestFeedPoll } from "./poller.js";
|
|
8
|
-
import { decodeEntities } from "./text.js";
|
|
8
|
+
import { decodeEntities, plainTitle } from "./text.js";
|
|
9
|
+
const MAX_FOLDER_NAME_LENGTH = 100;
|
|
10
|
+
const MAX_FEED_LABEL_LENGTH = 200;
|
|
9
11
|
function escapeXml(unsafe) {
|
|
10
12
|
return unsafe.replace(/[<>&'"]/g, (char) => {
|
|
11
13
|
switch (char) {
|
|
@@ -26,9 +28,10 @@ function escapeXml(unsafe) {
|
|
|
26
28
|
}
|
|
27
29
|
function feedToOutlineXml(s, indent = " ") {
|
|
28
30
|
const text = escapeXml(s.label || s.title || s.url);
|
|
31
|
+
const title = escapeXml(s.title || s.label || s.url);
|
|
29
32
|
const xmlUrl = escapeXml(s.url);
|
|
30
33
|
const htmlUrl = s.site_url ? ` htmlUrl="${escapeXml(s.site_url)}"` : "";
|
|
31
|
-
return `${indent}<outline text="${text}" title="${
|
|
34
|
+
return `${indent}<outline text="${text}" title="${title}" type="rss" xmlUrl="${xmlUrl}"${htmlUrl} />`;
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
37
|
* Generates standard OPML 2.0 XML representation of all subscribed feeds for a user.
|
|
@@ -74,7 +77,26 @@ ${outlines}
|
|
|
74
77
|
`;
|
|
75
78
|
}
|
|
76
79
|
/**
|
|
77
|
-
*
|
|
80
|
+
* Reads an attribute from a parsed XML node case-insensitively in priority order.
|
|
81
|
+
*/
|
|
82
|
+
function getAttr(record, ...names) {
|
|
83
|
+
const entries = Object.entries(record);
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const target = `@_${name.toLowerCase()}`;
|
|
86
|
+
for (const [key, value] of entries) {
|
|
87
|
+
if (key.toLowerCase() === target) {
|
|
88
|
+
if (typeof value === "string")
|
|
89
|
+
return value;
|
|
90
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
91
|
+
return String(value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Recursively extracts outline elements containing a feed URL from parsed OPML.
|
|
99
|
+
* Nested subfolders are flattened to the top level so each subfolder becomes its own folder.
|
|
78
100
|
*/
|
|
79
101
|
function extractOutlines(node, items = [], currentFolder) {
|
|
80
102
|
if (!node || typeof node !== "object")
|
|
@@ -86,19 +108,30 @@ function extractOutlines(node, items = [], currentFolder) {
|
|
|
86
108
|
return items;
|
|
87
109
|
}
|
|
88
110
|
const record = node;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
const hasChildren = Boolean(record.outline);
|
|
112
|
+
// Leaf outlines without xmlUrl may use url="..." in older OPML 1.0 exports.
|
|
113
|
+
const rawXmlUrl = getAttr(record, "xmlUrl") ?? (!hasChildren ? getAttr(record, "url") : undefined);
|
|
114
|
+
const rawText = getAttr(record, "text");
|
|
115
|
+
const rawTitle = getAttr(record, "title");
|
|
116
|
+
const rawHtmlUrl = getAttr(record, "htmlUrl");
|
|
117
|
+
const label = plainTitle(rawText ?? rawTitle)?.slice(0, MAX_FEED_LABEL_LENGTH);
|
|
118
|
+
const title = plainTitle(rawTitle ?? rawText)?.slice(0, MAX_FEED_LABEL_LENGTH);
|
|
119
|
+
const siteUrl = rawHtmlUrl?.trim() ? decodeEntities(rawHtmlUrl.trim()) : undefined;
|
|
120
|
+
let childFolder = currentFolder;
|
|
121
|
+
if (rawXmlUrl && rawXmlUrl.trim()) {
|
|
122
|
+
const url = decodeEntities(rawXmlUrl.trim());
|
|
123
|
+
const item = { url, label, folder: currentFolder };
|
|
124
|
+
if (title && title !== label)
|
|
125
|
+
item.title = title;
|
|
126
|
+
if (siteUrl)
|
|
127
|
+
item.siteUrl = siteUrl;
|
|
128
|
+
items.push(item);
|
|
96
129
|
}
|
|
97
|
-
else if (
|
|
98
|
-
|
|
130
|
+
else if (label && hasChildren) {
|
|
131
|
+
childFolder = label.slice(0, MAX_FOLDER_NAME_LENGTH);
|
|
99
132
|
}
|
|
100
|
-
if (
|
|
101
|
-
extractOutlines(record.outline, items,
|
|
133
|
+
if (hasChildren) {
|
|
134
|
+
extractOutlines(record.outline, items, childFolder);
|
|
102
135
|
}
|
|
103
136
|
return items;
|
|
104
137
|
}
|
|
@@ -107,22 +140,77 @@ export const MAX_OPML_FEEDS = 2000;
|
|
|
107
140
|
* Parses an OPML document string and returns a list of feed URLs and their labels.
|
|
108
141
|
*/
|
|
109
142
|
export function parseOpml(xmlContent) {
|
|
143
|
+
const cleanedXml = xmlContent.replace(/^\uFEFF/, "").trim();
|
|
144
|
+
if (!cleanedXml) {
|
|
145
|
+
throw new Error("invalid_opml_format");
|
|
146
|
+
}
|
|
110
147
|
const parser = new XMLParser({
|
|
111
148
|
ignoreAttributes: false,
|
|
112
149
|
attributeNamePrefix: "@_",
|
|
113
150
|
trimValues: true,
|
|
114
151
|
});
|
|
115
|
-
const parsed = parser.parse(
|
|
116
|
-
if (!parsed ||
|
|
152
|
+
const parsed = parser.parse(cleanedXml);
|
|
153
|
+
if (!parsed || typeof parsed.opml !== "object" || parsed.opml === null || !("body" in parsed.opml)) {
|
|
117
154
|
throw new Error("invalid_opml_format");
|
|
118
155
|
}
|
|
119
|
-
const
|
|
156
|
+
const body = parsed.opml.body;
|
|
157
|
+
const outlineRoot = body && typeof body === "object" ? body.outline : undefined;
|
|
158
|
+
const feeds = extractOutlines(outlineRoot);
|
|
120
159
|
if (feeds.length > MAX_OPML_FEEDS) {
|
|
121
160
|
throw new Error("too_many_opml_feeds");
|
|
122
161
|
}
|
|
123
162
|
return feeds;
|
|
124
163
|
}
|
|
125
164
|
const urlSchema = z.string().url();
|
|
165
|
+
function normalizeOpmlUrl(rawUrl) {
|
|
166
|
+
const trimmed = rawUrl.trim();
|
|
167
|
+
if (/^feeds?:https?:\/\//i.test(trimmed)) {
|
|
168
|
+
return trimmed.replace(/^feeds?:/i, "");
|
|
169
|
+
}
|
|
170
|
+
if (/^feeds?:\/\//i.test(trimmed)) {
|
|
171
|
+
const rest = trimmed.replace(/^feeds?:\/\//i, "");
|
|
172
|
+
return /^https?:\/\//i.test(rest) ? rest : `https://${rest}`;
|
|
173
|
+
}
|
|
174
|
+
if (trimmed.startsWith("//"))
|
|
175
|
+
return `https:${trimmed}`;
|
|
176
|
+
return trimmed;
|
|
177
|
+
}
|
|
178
|
+
function sanitizeHttpUrl(raw) {
|
|
179
|
+
if (!raw)
|
|
180
|
+
return null;
|
|
181
|
+
const trimmed = raw.trim();
|
|
182
|
+
if (!trimmed)
|
|
183
|
+
return null;
|
|
184
|
+
try {
|
|
185
|
+
const parsed = new URL(trimmed.startsWith("//") ? `https:${trimmed}` : trimmed);
|
|
186
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
187
|
+
return null;
|
|
188
|
+
return parsed.toString();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function isUrlFallbackLabel(label, rawUrl, normalizedUrl) {
|
|
195
|
+
if (!label)
|
|
196
|
+
return true;
|
|
197
|
+
const trimmed = label.trim();
|
|
198
|
+
if (!trimmed)
|
|
199
|
+
return true;
|
|
200
|
+
if (trimmed === rawUrl || trimmed === normalizedUrl)
|
|
201
|
+
return true;
|
|
202
|
+
try {
|
|
203
|
+
return new URL(normalizeOpmlUrl(trimmed)).toString() === normalizedUrl;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
try {
|
|
207
|
+
return new URL(normalizeUrlCandidate(trimmed)).toString() === normalizedUrl;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
126
214
|
/**
|
|
127
215
|
* Imports feeds from an OPML document string for the given user.
|
|
128
216
|
*/
|
|
@@ -139,7 +227,8 @@ export async function importOpmlFeeds(userId, xmlContent) {
|
|
|
139
227
|
for (const item of feedItems) {
|
|
140
228
|
if (!item.url)
|
|
141
229
|
continue;
|
|
142
|
-
const
|
|
230
|
+
const candidateUrl = normalizeOpmlUrl(item.url);
|
|
231
|
+
const urlCheck = urlSchema.safeParse(candidateUrl);
|
|
143
232
|
if (!urlCheck.success) {
|
|
144
233
|
result.failed++;
|
|
145
234
|
result.errors.push({ url: item.url, reason: "invalid_url_format" });
|
|
@@ -147,7 +236,7 @@ export async function importOpmlFeeds(userId, xmlContent) {
|
|
|
147
236
|
}
|
|
148
237
|
let normalizedUrl;
|
|
149
238
|
try {
|
|
150
|
-
const validated = await assertPublicFeedUrl(
|
|
239
|
+
const validated = await assertPublicFeedUrl(candidateUrl);
|
|
151
240
|
normalizedUrl = validated.toString();
|
|
152
241
|
}
|
|
153
242
|
catch (error) {
|
|
@@ -161,22 +250,28 @@ export async function importOpmlFeeds(userId, xmlContent) {
|
|
|
161
250
|
if (!feed) {
|
|
162
251
|
feed = createFeed(normalizedUrl, Math.max(config.minPollIntervalMinutes, 15));
|
|
163
252
|
}
|
|
253
|
+
const rawTitle = item.title ?? item.label;
|
|
254
|
+
const seedTitle = isUrlFallbackLabel(rawTitle, item.url, normalizedUrl) ? null : (rawTitle ?? null);
|
|
255
|
+
const seedSiteUrl = sanitizeHttpUrl(item.siteUrl);
|
|
256
|
+
seedFeedMetadata(feed.id, { title: seedTitle, siteUrl: seedSiteUrl });
|
|
164
257
|
if (isUserSubscribed(userId, feed.id)) {
|
|
165
258
|
result.skipped++;
|
|
166
259
|
continue;
|
|
167
260
|
}
|
|
168
261
|
let folderId = null;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
262
|
+
const folderName = item.folder?.trim().slice(0, MAX_FOLDER_NAME_LENGTH);
|
|
263
|
+
if (folderName) {
|
|
264
|
+
if (folderCache.has(folderName)) {
|
|
265
|
+
folderId = folderCache.get(folderName);
|
|
172
266
|
}
|
|
173
267
|
else {
|
|
174
|
-
const folder = createFolder(userId,
|
|
268
|
+
const folder = createFolder(userId, folderName);
|
|
175
269
|
folderId = folder.id;
|
|
176
|
-
folderCache.set(
|
|
270
|
+
folderCache.set(folderName, folder.id);
|
|
177
271
|
}
|
|
178
272
|
}
|
|
179
|
-
|
|
273
|
+
const customLabel = isUrlFallbackLabel(item.label, item.url, normalizedUrl) ? null : (item.label ?? null);
|
|
274
|
+
subscribe(userId, feed.id, customLabel, folderId);
|
|
180
275
|
result.imported++;
|
|
181
276
|
feedsToPoll.push(feed);
|
|
182
277
|
}
|