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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.35.4",
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.1",
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.2",
55
+ "undici": "^8.12.3",
56
56
  "web-push": "^3.6.7",
57
57
  "zod": "^4.6.5"
58
58
  }
@@ -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.userId = user.id;
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
@@ -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", "application/xml; charset=utf-8");
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
- const updated = updateFolder(req.user.id, folderId, parsed.data.name);
41
- res.json(updated);
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.userId = user.id;
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
- try {
237
- const folder = createFolder(req.user.id, parsed.data.name, parsed.data.iconSymbol);
238
- res.status(201).json({
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 (bearerToken(req))
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
- /** 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;
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
- const current = image.getAttribute("src") ?? "";
10
- if (current && !/^data:/i.test(current) && !SCRIPT_IMAGE_SOURCE.test(current))
8
+ if (usableImageSource(image.getAttribute("src")))
11
9
  continue;
12
- const source = image.getAttribute("data-lazy-src") || image.getAttribute("data-src") || image.getAttribute("data-original");
13
- if (!source || !/^(?:https?:\/\/|\/)/i.test(source) || SCRIPT_IMAGE_SOURCE.test(source))
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;
@@ -1,4 +1,4 @@
1
- import { SCRIPT_IMAGE_SOURCE } from "./content/noise/lazyImages.js";
1
+ import { SCRIPT_IMAGE_SOURCE } from "./imageSources.js";
2
2
  import { Readability } from "@mozilla/readability";
3
3
  import { JSDOM } from "jsdom";
4
4
  import { fetch as undiciFetch } from "undici";
@@ -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 annotated one of its articles. */
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 = ?))`).get(feedId, userId, userId, userId));
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, "&quot;");
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 = ensureUserInbox(userId);
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
- db.transaction(() => {
128
- db.prepare("DELETE FROM item_bookmarks WHERE item_id = ?").run(itemId);
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="${text}" type="rss" xmlUrl="${xmlUrl}"${htmlUrl} />`;
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
- * Recursively extracts outline elements containing an xmlUrl attribute from parsed OPML.
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 xmlUrl = (typeof record["@_xmlUrl"] === "string" ? record["@_xmlUrl"] : undefined) ??
90
- (typeof record["@_xmlurl"] === "string" ? record["@_xmlurl"] : undefined);
91
- const rawLabel = (typeof record["@_text"] === "string" ? record["@_text"] : undefined) ??
92
- (typeof record["@_title"] === "string" ? record["@_title"] : undefined);
93
- if (xmlUrl) {
94
- const label = rawLabel?.trim() ? decodeEntities(rawLabel.trim()) : undefined;
95
- items.push({ url: xmlUrl.trim(), label, folder: currentFolder });
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 (rawLabel && record.outline) {
98
- currentFolder = decodeEntities(rawLabel.trim());
130
+ else if (label && hasChildren) {
131
+ childFolder = label.slice(0, MAX_FOLDER_NAME_LENGTH);
99
132
  }
100
- if (record.outline) {
101
- extractOutlines(record.outline, items, currentFolder);
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(xmlContent);
116
- if (!parsed || !parsed.opml || !parsed.opml.body) {
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 feeds = extractOutlines(parsed.opml.body.outline);
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 urlCheck = urlSchema.safeParse(item.url);
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(item.url);
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
- if (item.folder) {
170
- if (folderCache.has(item.folder)) {
171
- folderId = folderCache.get(item.folder);
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, item.folder);
268
+ const folder = createFolder(userId, folderName);
175
269
  folderId = folder.id;
176
- folderCache.set(item.folder, folder.id);
270
+ folderCache.set(folderName, folder.id);
177
271
  }
178
272
  }
179
- subscribe(userId, feed.id, item.label ?? null, folderId);
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
  }