From 8619f93ab433b8a6a9bca16abb7d5bb818d5d701 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:50:08 -0700 Subject: [PATCH] Initial backend, widget endpoint, basic parsing and proxy --- src/widgets/feed/proxy.js | 51 ++++++++++++++ src/widgets/feed/proxy.test.js | 113 ++++++++++++++++++++++++++++++++ src/widgets/feed/utils.js | 80 ++++++++++++++++++++++ src/widgets/feed/utils.test.js | 111 +++++++++++++++++++++++++++++++ src/widgets/feed/widget.js | 8 +++ src/widgets/feed/widget.test.js | 12 ++++ src/widgets/widgets.js | 2 + 7 files changed, 377 insertions(+) create mode 100644 src/widgets/feed/proxy.js create mode 100644 src/widgets/feed/proxy.test.js create mode 100644 src/widgets/feed/utils.js create mode 100644 src/widgets/feed/utils.test.js create mode 100644 src/widgets/feed/widget.js create mode 100644 src/widgets/feed/widget.test.js diff --git a/src/widgets/feed/proxy.js b/src/widgets/feed/proxy.js new file mode 100644 index 000000000..ac4cccb9a --- /dev/null +++ b/src/widgets/feed/proxy.js @@ -0,0 +1,51 @@ +import { parseFeed } from "./utils"; + +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import { sanitizeErrorURL } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; + +const logger = createLogger("feedProxyHandler"); + +export default async function feedProxyHandler(req, res) { + const { group, service, index } = req.query; + const widget = await getServiceWidget(group, service, index); + + if (!widget?.url) { + return res.status(400).json({ error: "Missing feed URL" }); + } + + let url; + try { + url = new URL(widget.url); + } catch { + return res.status(400).json({ error: "Invalid feed URL" }); + } + + const [status, , data] = await httpProxy(url, { + headers: { + "User-Agent": `gethomepage/${process.env.NEXT_PUBLIC_VERSION || "dev"}`, + Accept: "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.9, */*;q=0.8", + }, + }); + + if (status !== 200) { + logger.debug("HTTP %d retrieving feed %s//%s%s", status, url.protocol, url.host, url.pathname); + return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url) } }); + } + + let items; + try { + items = parseFeed(Buffer.from(data).toString(), url.href); + } catch (e) { + logger.debug("Error parsing feed %s//%s%s: %s", url.protocol, url.host, url.pathname, e.message); + return res.status(500).json({ error: { message: "Invalid feed", url: sanitizeErrorURL(url) } }); + } + + const maxItems = parseInt(widget.maxItems, 10) || 5; + const showImages = widget.images !== false && widget.images !== "false"; + + return res.status(200).json({ + items: items.slice(0, maxItems).map(({ image, ...item }) => (showImages && image ? { ...item, image } : item)), + }); +} diff --git a/src/widgets/feed/proxy.test.js b/src/widgets/feed/proxy.test.js new file mode 100644 index 000000000..79526b98a --- /dev/null +++ b/src/widgets/feed/proxy.test.js @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + logger: { + debug: vi.fn(), + }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +import feedProxyHandler from "./proxy"; + +const items = Array.from( + { length: 7 }, + (_, i) => `Item ${i}https://example.com/${i} + `, +).join(""); +const feed = `${items}`; + +const req = { query: { group: "g", service: "svc", index: "0" } }; + +describe("widgets/feed/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when the url is missing or invalid", async () => { + getServiceWidget.mockResolvedValueOnce({ type: "feed" }); + let res = createMockRes(); + await feedProxyHandler(req, res); + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Missing feed URL" }); + + getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "nope" }); + res = createMockRes(); + await feedProxyHandler(req, res); + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Invalid feed URL" }); + expect(httpProxy).not.toHaveBeenCalled(); + }); + + it("returns the first 5 items by default", async () => { + getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" }); + httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); + + const res = createMockRes(); + await feedProxyHandler(req, res); + + expect(httpProxy.mock.calls[0][0].href).toBe("https://example.com/feed.xml"); + expect(res.statusCode).toBe(200); + expect(res.body.items).toHaveLength(5); + expect(res.body.items[0]).toEqual({ + title: "Item 0", + link: "https://example.com/0", + date: null, + image: "https://example.com/0.jpg", + }); + }); + + it("respects maxItems and drops images when disabled", async () => { + getServiceWidget.mockResolvedValue({ + type: "feed", + url: "https://example.com/feed.xml", + maxItems: "2", + images: false, + }); + httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); + + const res = createMockRes(); + await feedProxyHandler(req, res); + + expect(res.body.items).toEqual([ + { title: "Item 0", link: "https://example.com/0", date: null }, + { title: "Item 1", link: "https://example.com/1", date: null }, + ]); + }); + + it("passes through http errors without leaking the url", async () => { + getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml?token=secret" }); + httpProxy.mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")]); + + const res = createMockRes(); + await feedProxyHandler(req, res); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: { message: "HTTP Error", url: "example.com (see logs for details)" } }); + }); + + it("returns 500 for unparseable feeds", async () => { + getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" }); + httpProxy.mockResolvedValueOnce([200, "text/html", Buffer.from("")]); + + const res = createMockRes(); + await feedProxyHandler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: { message: "Invalid feed", url: "example.com (see logs for details)" } }); + }); +}); diff --git a/src/widgets/feed/utils.js b/src/widgets/feed/utils.js new file mode 100644 index 000000000..756168900 --- /dev/null +++ b/src/widgets/feed/utils.js @@ -0,0 +1,80 @@ +import { xml2js } from "xml-js"; + +const asArray = (value) => (value === undefined || value === null ? [] : [].concat(value)); + +function getText(node) { + const first = asArray(node)[0]; + if (first === undefined) return ""; + if (typeof first !== "object") return String(first); + return asArray(first._cdata ?? first._text).join(""); +} + +export function httpUrl(value, baseUrl) { + if (!value) return null; + try { + const url = new URL(value.trim(), baseUrl); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; + } catch { + return null; + } +} + +function parseDate(value) { + const time = Date.parse(value); + return Number.isNaN(time) ? null : new Date(time).toISOString(); +} + +const attrs = (node) => node?._attributes ?? {}; +const isImage = (node) => attrs(node).medium === "image" || attrs(node).type?.startsWith("image/"); + +function findImage(item) { + const groups = [item, ...asArray(item["media:group"])]; + const candidates = [ + ...groups.flatMap((group) => asArray(group["media:thumbnail"])), + ...groups.flatMap((group) => asArray(group["media:content"])).filter(isImage), + ...asArray(item.enclosure).filter(isImage), + ...asArray(item.link).filter((link) => attrs(link).rel === "enclosure" && isImage(link)), + ]; + const image = candidates.find((node) => attrs(node).url || attrs(node).href); + return image ? attrs(image).url || attrs(image).href : null; +} + +function parseRssItem(item) { + const guid = asArray(item.guid)[0]; + const guidLink = attrs(guid).isPermaLink !== "false" ? getText(guid) : null; + return { + title: getText(item.title), + link: getText(item.link) || guidLink, + date: getText(item.pubDate) || getText(item["dc:date"]), + image: findImage(item), + }; +} + +function parseAtomEntry(entry) { + const links = asArray(entry.link); + const link = links.find((l) => (attrs(l).rel ?? "alternate") === "alternate") ?? links[0]; + return { + title: getText(entry.title), + link: attrs(link).href, + date: getText(entry.published) || getText(entry.updated), + image: findImage(entry), + }; +} + +export function parseFeed(xml, baseUrl) { + const doc = xml2js(xml, { compact: true }); + + let items; + if (doc.rss) items = asArray(doc.rss.channel?.item).map(parseRssItem); + else if (doc.feed) items = asArray(doc.feed.entry).map(parseAtomEntry); + else throw new Error("Unsupported feed format"); + + return items + .map((item) => ({ + title: item.title.trim(), + link: httpUrl(item.link, baseUrl), + date: parseDate(item.date), + image: httpUrl(item.image, baseUrl), + })) + .filter((item) => item.title); +} diff --git a/src/widgets/feed/utils.test.js b/src/widgets/feed/utils.test.js new file mode 100644 index 000000000..e6115d082 --- /dev/null +++ b/src/widgets/feed/utils.test.js @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { httpUrl, parseFeed } from "./utils"; + +const rss = ` + + + Example + + <![CDATA[Tom & Jerry]]> + https://example.com/one + Tue, 22 Sep 2026 10:00:00 GMT + + + + Enclosure image + https://example.com/two + 2026-09-21T10:00:00Z + + + + Audio only + javascript:alert(1) + abc-123 + + + + No title, skipped + + +`; + +const atom = ` + + Example + + Fish & chips + + + + 2026-09-22T12:00:00Z + + + YouTube style + + 2026-09-20T12:00:00Z + not a date + + + + + +`; + +describe("widgets/feed/utils", () => { + it("parses rss 2.0 items", () => { + expect(parseFeed(rss, "https://example.com/feed.xml")).toEqual([ + { + title: "Tom & Jerry", + link: "https://example.com/one", + date: "2026-09-22T10:00:00.000Z", + image: "https://img.example.com/one.jpg", + }, + { + title: "Enclosure image", + link: "https://example.com/two", + date: "2026-09-21T10:00:00.000Z", + image: "https://example.com/two.png", + }, + { title: "Audio only", link: null, date: null, image: null }, + ]); + }); + + it("parses atom entries", () => { + expect(parseFeed(atom, "https://example.com/atom.xml")).toEqual([ + { + title: "Fish & chips", + link: "https://example.com/one", + date: "2026-09-22T12:00:00.000Z", + image: "https://example.com/one.jpg", + }, + { + title: "YouTube style", + link: "https://example.com/two", + date: "2026-09-20T12:00:00.000Z", + image: "https://example.com/two.jpg", + }, + ]); + }); + + it("handles single-item and empty feeds", () => { + const single = "Only"; + expect(parseFeed(single)).toEqual([{ title: "Only", link: null, date: null, image: null }]); + expect(parseFeed("")).toEqual([]); + }); + + it("rejects unsupported documents", () => { + expect(() => parseFeed("nope")).toThrow("Unsupported feed format"); + expect(() => parseFeed("not xml <")).toThrow(); + }); + + it.each([ + ["https://a.com/x", undefined, "https://a.com/x"], + ["/x", "https://a.com/feed", "https://a.com/x"], + ["data:image/png;base64,AAAA", undefined, null], + ["not a url", undefined, null], + ["", undefined, null], + ])("httpUrl(%j, %j) is %j", (value, base, expected) => { + expect(httpUrl(value, base)).toBe(expected); + }); +}); diff --git a/src/widgets/feed/widget.js b/src/widgets/feed/widget.js new file mode 100644 index 000000000..6b69cc71e --- /dev/null +++ b/src/widgets/feed/widget.js @@ -0,0 +1,8 @@ +import feedProxyHandler from "./proxy"; + +const widget = { + api: "{url}", + proxyHandler: feedProxyHandler, +}; + +export default widget; diff --git a/src/widgets/feed/widget.test.js b/src/widgets/feed/widget.test.js new file mode 100644 index 000000000..ac1c67acc --- /dev/null +++ b/src/widgets/feed/widget.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { expectWidgetConfigShape } from "test-utils/widget-config"; + +import widget from "./widget"; + +describe("feed widget config", () => { + it("exports a valid widget config", () => { + expectWidgetConfigShape(widget); + expect(widget.api).toBe("{url}"); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index aaa024f8d..5716675d0 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -31,6 +31,7 @@ import duplicati from "./duplicati/widget"; import emby from "./emby/widget"; import esphome from "./esphome/widget"; import evcc from "./evcc/widget"; +import feed from "./feed/widget"; import filebrowser from "./filebrowser/widget"; import fileflows from "./fileflows/widget"; import firefly from "./firefly/widget"; @@ -191,6 +192,7 @@ const widgets = { emby, esphome, evcc, + feed, filebrowser, fileflows, firefly,