diff --git a/docs/assets/widget_feed_grid.webp b/docs/assets/widget_feed_grid.webp new file mode 100644 index 000000000..15ffc2e8a Binary files /dev/null and b/docs/assets/widget_feed_grid.webp differ diff --git a/docs/assets/widget_feed_list.webp b/docs/assets/widget_feed_list.webp new file mode 100644 index 000000000..3d07d2ea9 Binary files /dev/null and b/docs/assets/widget_feed_list.webp differ diff --git a/docs/assets/widget_feed_thumbnails.webp b/docs/assets/widget_feed_thumbnails.webp new file mode 100644 index 000000000..d23c55228 Binary files /dev/null and b/docs/assets/widget_feed_thumbnails.webp differ diff --git a/docs/widgets/services/feed.md b/docs/widgets/services/feed.md new file mode 100644 index 000000000..b5cfedc46 --- /dev/null +++ b/docs/widgets/services/feed.md @@ -0,0 +1,46 @@ +--- +title: Feed +description: RSS / Atom feed widget +--- + +This widget shows the latest items from an RSS 2.0 or Atom feed. + +```yaml +widget: + type: feed + url: https://feeds.bbci.co.uk/news/rss.xml + maxItems: 5 # optional - defaults to 5 + layout: list # optional - possible values list, grid - defaults to list + images: true # optional - set to false to hide images - defaults to true +``` + +## Layouts + +`list` shows one row per item. Items with an image get a small thumbnail. + +![Feed widget list layout](../../assets/widget_feed_list.webp) + +![Feed widget list layout with thumbnails](../../assets/widget_feed_thumbnails.webp) + +`grid` shows image tiles that re-flow to fit the width of the widget, e.g.: + +```yaml +- NASA Image of the Day: + widget: + type: feed + url: https://www.nasa.gov/feeds/iotd-feed/ + layout: grid + maxItems: 8 +``` + +![Feed widget grid layout](../../assets/widget_feed_grid.webp) + +## Images + +Images are taken from the feed's `media:thumbnail` / `media:content` tags or image enclosures, falling back to the first image in the item's content. Some feeds do not include images at all. + +Images are loaded by your browser directly from the feed's host, at whatever size the feed provides. Feeds that link full-size originals can be slow to load; set `images: false` for those. + +## Notes + +Feeds are fetched by homepage, not your browser, and cached for 10 minutes. diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md index e982fb9b9..d99752e0f 100644 --- a/docs/widgets/services/index.md +++ b/docs/widgets/services/index.md @@ -39,6 +39,7 @@ You can also find a list of all available service widgets in the sidebar navigat - [Emby](emby.md) - [ESPHome](esphome.md) - [EVCC](evcc.md) +- [Feed](feed.md) - [Filebrowser](filebrowser.md) - [Fileflows](fileflows.md) - [Firefly III](firefly.md) diff --git a/mkdocs.yml b/mkdocs.yml index 0af27d41b..6c38350a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -65,6 +65,7 @@ nav: - widgets/services/emby.md - widgets/services/esphome.md - widgets/services/evcc.md + - widgets/services/feed.md - widgets/services/filebrowser.md - widgets/services/fileflows.md - widgets/services/firefly.md diff --git a/package.json b/package.json index 94f3fb729..1922d9b03 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "react-i18next": "^17.0.12", "react-icons": "^5.6.0", "recharts": "^3.1.2", + "sax": "^1.6.1", "swr": "^2.5.1", "systeminformation": "^5.33.1", "tough-cookie": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58e45faaa..467952842 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: recharts: specifier: ^3.1.2 version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1) + sax: + specifier: ^1.6.1 + version: 1.6.1 swr: specifier: ^2.5.1 version: 2.5.1(react@19.2.8) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ba4ee5f55..b29a4452a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1258,5 +1258,8 @@ "wanted": "Wanted", "queued": "Queued", "leagues": "Leagues" + }, + "feed": { + "noItems": "No items" } } diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 9a9270d3d..20303af7d 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -353,6 +353,9 @@ export function cleanServiceGroups(groups) { expandOneStreamToTwoRows, showEpisodeNumber, + // feed + layout, + // frigate enableRecentEvents, @@ -675,6 +678,9 @@ export function cleanServiceGroups(groups) { if (showTime) widget.showTime = showTime; if (timezone) widget.timezone = timezone; } + if (type === "feed") { + if (layout) widget.layout = layout; + } if (type === "dockhand") { if (environment) widget.environment = environment; } diff --git a/src/utils/config/service-helpers.test.js b/src/utils/config/service-helpers.test.js index 7109b9abf..6d6174526 100644 --- a/src/utils/config/service-helpers.test.js +++ b/src/utils/config/service-helpers.test.js @@ -524,6 +524,38 @@ describe("utils/config/service-helpers", () => { ]); }); + it("cleanServiceGroups keeps feed layout and drops server-side options", async () => { + const mod = await import("./service-helpers"); + const { cleanServiceGroups } = mod; + + const rawGroups = [ + { + name: "Core", + services: [ + { + name: "News", + widgets: [ + { + type: "feed", + url: "https://example.com/feed.xml?token=secret", + maxItems: 3, + images: false, + layout: "grid", + }, + ], + }, + ], + groups: [], + }, + ]; + + const feedWidget = cleanServiceGroups(rawGroups)[0].services[0].widgets[0]; + expect(feedWidget).toEqual(expect.objectContaining({ type: "feed", layout: "grid" })); + expect(feedWidget).not.toHaveProperty("url"); + expect(feedWidget).not.toHaveProperty("maxItems"); + expect(feedWidget).not.toHaveProperty("images"); + }); + it("findGroupByName deep-searches and annotates parent", async () => { const mod = await import("./service-helpers"); const { findGroupByName } = mod; diff --git a/src/widgets/components.js b/src/widgets/components.js index dc1c87c9f..ae8327578 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -37,6 +37,7 @@ const components = { emby: dynamic(() => import("./emby/component")), esphome: dynamic(() => import("./esphome/component")), evcc: dynamic(() => import("./evcc/component")), + feed: dynamic(() => import("./feed/component")), filebrowser: dynamic(() => import("./filebrowser/component")), fileflows: dynamic(() => import("./fileflows/component")), firefly: dynamic(() => import("./firefly/component")), diff --git a/src/widgets/feed/component.jsx b/src/widgets/feed/component.jsx new file mode 100644 index 000000000..479511711 --- /dev/null +++ b/src/widgets/feed/component.jsx @@ -0,0 +1,125 @@ +/* eslint-disable @next/next/no-img-element */ +import classNames from "classnames"; +import { useTranslation } from "next-i18next/pages"; +import { useContext } from "react"; + +import Container from "components/services/widget/container"; +import { SettingsContext } from "utils/contexts/settings"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +const rowClassName = "rounded-md bg-theme-200/50 dark:bg-theme-900/20 text-theme-700 dark:text-theme-200 text-xs"; +const hideBrokenImage = (e) => { + e.currentTarget.style.visibility = "hidden"; +}; + +function FeedLink({ item, target, className, children }) { + if (!item.link) return
{children}
; + return ( + + {children} + + ); +} + +function RelativeDate({ date }) { + const { t } = useTranslation(); + if (!date) return null; + return ( + + {t("common.relativeDate", { value: date, formatParams: { value: { style: "narrow", numeric: "auto" } } })} + + ); +} + +function ListItem({ item, target }) { + return ( + + {item.image && ( + + )} + {item.title} + + + ); +} + +function GridItem({ item, target }) { + return ( + +
+ {item.image && ( + + )} +
+
+ {item.title} + +
+
+ ); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + const { settings } = useContext(SettingsContext); + const { widget } = service; + + const { data, error } = useWidgetAPI(widget, undefined, undefined, { refreshInterval: 10 * 60 * 1000 }); + + if (error) { + return ; + } + + const target = service.target ?? settings?.target ?? "_blank"; + const isGrid = widget.layout === "grid"; + const Item = isGrid ? GridItem : ListItem; + + let content; + if (!data) { + content = [0, 1, 2].map((i) => ( +
+ )); + } else if (!data.items?.length) { + content =
{t("feed.noItems")}
; + } else { + content = data.items.map((item, i) => ); + } + + return ( + +
+ {content} +
+
+ ); +} diff --git a/src/widgets/feed/component.test.jsx b/src/widgets/feed/component.test.jsx new file mode 100644 index 000000000..480a9affc --- /dev/null +++ b/src/widgets/feed/component.test.jsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "test-utils/render-with-providers"; + +const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); +vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); + +import Component from "./component"; + +const items = [ + { + title: "With image", + link: "https://example.com/1", + date: "2020-01-01T00:00:00Z", + image: "https://example.com/1.jpg", + }, + { title: "No link", link: null, date: null }, +]; + +function render(widget = {}, settings = {}) { + return renderWithProviders(, { + settings: { hideErrors: false, ...settings }, + }); +} + +describe("widgets/feed/component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders placeholders while loading", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse")).toHaveLength(3); + }); + + it("renders an error", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "Invalid feed" } }); + render(); + expect(screen.getAllByText(/Invalid feed/).length).toBeGreaterThan(0); + }); + + it("renders an empty feed", () => { + useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined }); + render(); + expect(screen.getByText("feed.noItems")).toBeInTheDocument(); + }); + + it("renders list items with links and images", () => { + useWidgetAPI.mockReturnValue({ data: { items }, error: undefined }); + const { container } = render({}, { target: "_self" }); + + const link = screen.getByText("With image").closest("a"); + expect(link).toHaveAttribute("href", "https://example.com/1"); + expect(link).toHaveAttribute("target", "_self"); + expect(link.querySelector("img")).toHaveAttribute("src", "https://example.com/1.jpg"); + expect(screen.getByText("No link").closest("a")).toBeNull(); + expect(container.querySelectorAll("img")).toHaveLength(1); + }); + + it("renders the grid layout with image placeholders", () => { + useWidgetAPI.mockReturnValue({ data: { items }, error: undefined }); + const { container } = render({ layout: "grid" }); + + expect(container.querySelector(".grid")).not.toBeNull(); + expect(container.querySelectorAll(".aspect-video")).toHaveLength(2); + expect(container.querySelectorAll("img")).toHaveLength(1); + }); + + it("hides images that fail to load", () => { + useWidgetAPI.mockReturnValue({ data: { items }, error: undefined }); + const { container } = render(); + const img = container.querySelector("img"); + img.dispatchEvent(new Event("error")); + expect(img.style.visibility).toBe("hidden"); + }); +}); diff --git a/src/widgets/feed/proxy.js b/src/widgets/feed/proxy.js new file mode 100644 index 000000000..3d2a95d7c --- /dev/null +++ b/src/widgets/feed/proxy.js @@ -0,0 +1,61 @@ +import cache from "memory-cache"; + +import { httpUrl, 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"); +const CACHE_MS = 10 * 60 * 1000; + +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" }); + } + + const href = httpUrl(widget.url); + if (!href) { + return res.status(400).json({ error: "Invalid feed URL" }); + } + const url = new URL(href); + + const cacheKey = `feed:${url.href}`; + let items = cache.get(cacheKey); + + if (!items) { + 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) } }); + } + + try { + items = parseFeed(Buffer.from(data).toString()); + } 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) } }); + } + + // only successful fetches are cached + cache.put(cacheKey, items, CACHE_MS); + } + + const limit = parseInt(widget.maxItems, 10); + const maxItems = limit > 0 ? limit : 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..a8d7f1f2b --- /dev/null +++ b/src/widgets/feed/proxy.test.js @@ -0,0 +1,183 @@ +import cache from "memory-cache"; +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(); + cache.clear(); + }); + + 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" }); + + getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "file:///etc/passwd" }); + 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.each([-1, 0, "abc"])("falls back to 5 items for maxItems %j", async (maxItems) => { + getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml", maxItems }); + httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); + + const res = createMockRes(); + await feedProxyHandler(req, res); + + expect(res.body.items).toHaveLength(5); + }); + + 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("never includes any part of the configured url in the response", async () => { + getServiceWidget.mockResolvedValue({ + type: "feed", + url: "https://user:hunter2@nas.lan/feeds/s3cr3t/rss.xml?token=abc123", + }); + const relative = ` + Pathitem/1 + Root/x + Fragment#top + `; + httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(relative)]); + + const res = createMockRes(); + await feedProxyHandler(req, res); + + expect(res.body.items.map((item) => item.link)).toEqual([null, null, null]); + expect(JSON.stringify(res.body)).not.toMatch(/user|hunter2|nas\.lan|s3cr3t|token|abc123/); + }); + + 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)" } }); + }); + + it("shares one cached fetch across widgets with different options", async () => { + getServiceWidget + .mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", maxItems: 1 }) + .mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", images: false }); + httpProxy.mockResolvedValue([200, "application/rss+xml", Buffer.from(feed)]); + + const first = createMockRes(); + await feedProxyHandler(req, first); + const second = createMockRes(); + await feedProxyHandler(req, second); + + expect(httpProxy).toHaveBeenCalledTimes(1); + expect(first.body.items).toHaveLength(1); + expect(second.body.items).toHaveLength(5); + expect(second.body.items[0].image).toBeUndefined(); + }); + + it("does not cache failures", async () => { + getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" }); + httpProxy + .mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")]) + .mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]); + + const failed = createMockRes(); + await feedProxyHandler(req, failed); + const recovered = createMockRes(); + await feedProxyHandler(req, recovered); + + expect(failed.statusCode).toBe(404); + expect(recovered.statusCode).toBe(200); + expect(httpProxy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/widgets/feed/utils.js b/src/widgets/feed/utils.js new file mode 100644 index 000000000..3ef8f5371 --- /dev/null +++ b/src/widgets/feed/utils.js @@ -0,0 +1,114 @@ +import sax from "sax"; +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) { + const trimmed = value?.trim(); + if (!trimmed) return null; + try { + const url = new URL(trimmed, 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; +} + +const isPixel = ({ width, height }) => ["0", "1"].includes(width) || ["0", "1"].includes(height); + +// first usable in item html using sax tokenizer +function findHtmlImage(htmls, baseUrl) { + for (const html of htmls) { + if (!html) continue; + let image = null; + const parser = sax.parser(false, { lowercase: true }); + parser.onopentag = ({ name, attributes }) => { + if (!image && name === "img" && !isPixel(attributes)) image = httpUrl(attributes.src, baseUrl); + }; + parser.onerror = () => { + parser.error = null; + parser.resume(); + }; + parser.write(html).close(); + if (image) return image; + } + return 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), + html: [getText(item["content:encoded"]), getText(item.description)], + }; +} + +const alternateLink = (node) => asArray(node.link).find((l) => (attrs(l).rel ?? "alternate") === "alternate"); + +function parseAtomEntry(entry) { + const link = alternateLink(entry) ?? asArray(entry.link)[0]; + return { + title: getText(entry.title), + link: attrs(link).href, + date: getText(entry.published) || getText(entry.updated), + image: findImage(entry), + html: [getText(entry.content), getText(entry.summary)], + }; +} + +export function parseFeed(xml) { + const doc = xml2js(xml, { compact: true }); + + let items; + let site; + if (doc.rss) { + items = asArray(doc.rss.channel?.item).map(parseRssItem); + site = getText(doc.rss.channel?.link); + } else if (doc.feed) { + items = asArray(doc.feed.entry).map(parseAtomEntry); + site = attrs(alternateLink(doc.feed)).href; + } else throw new Error("Unsupported feed format"); + + // resolve against the feed's own site link, never the configured url + const baseUrl = httpUrl(site) ?? undefined; + + return items + .map((item) => ({ + title: item.title.trim(), + link: httpUrl(item.link, baseUrl), + date: parseDate(item.date), + image: httpUrl(item.image, baseUrl) ?? findHtmlImage(item.html, 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..51e8bdcb2 --- /dev/null +++ b/src/widgets/feed/utils.test.js @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; + +import { httpUrl, parseFeed } from "./utils"; + +const rss = ` + + + Example + https://example.com/ + + <![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)).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)).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("falls back to the first usable image in item html", () => { + const xml = ` + https://example.com/ + + Content image + Hi + ]]> + + + Description image + <img src="https://example.com/desc.png"> unclosed <b> + + + Media wins + + ]]> + + + No usable image + ]]> + + `; + + expect(parseFeed(xml).map((item) => item.image)).toEqual([ + "https://example.com/big.jpg?w=925&h=925", + "https://example.com/desc.png", + "https://example.com/thumb.jpg", + null, + ]); + }); + + it("finds images in atom html content and summary", () => { + const xml = ` + Content<img src="https://example.com/c.jpg"> + Summary<img src="https://example.com/s.jpg"> + `; + + expect(parseFeed(xml).map((item) => item.image)).toEqual([ + "https://example.com/c.jpg", + "https://example.com/s.jpg", + ]); + }); + + it("resolves relative urls against the feed's alternate link, never its self link", () => { + const entry = `Relative`; + const selfOnly = ` + ${entry}`; + const withSite = ` + + ${entry}`; + + expect(parseFeed(selfOnly)[0].link).toBeNull(); + expect(parseFeed(withSite)[0].link).toBe("https://example.com/posts/1"); + }); + + 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], + [" \n ", "https://a.com/feed", null], + [" /x ", "https://a.com/feed", "https://a.com/x"], + ])("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,