From 934ad3a6f1b8630f9752f29a2646242f2cff74f8 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 16 Mar 2025 15:33:38 -0700 Subject: [PATCH 01/17] Fix: remove medusa widget trailing slash (#5007) --- src/widgets/medusa/widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/medusa/widget.js b/src/widgets/medusa/widget.js index 3619d16b..fbfd8af2 100644 --- a/src/widgets/medusa/widget.js +++ b/src/widgets/medusa/widget.js @@ -1,7 +1,7 @@ import genericProxyHandler from "utils/proxy/handlers/generic"; const widget = { - api: "{url}/api/v1/{key}/{endpoint}/", + api: "{url}/api/v1/{key}/{endpoint}", proxyHandler: genericProxyHandler, mappings: { From b4dc53c7c0e07ba4eaa59a4b3dd698ae11fa1f32 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 16 Mar 2025 20:09:34 -0700 Subject: [PATCH 02/17] Feature: allow disable ipv6 in proxy, refactor cacheFetch to use proxy (#5011) --- docs/troubleshooting/index.md | 13 ++------- src/pages/api/releases.js | 4 +-- src/pages/api/search/searchSuggestion.js | 4 +-- src/pages/api/widgets/openmeteo.js | 4 +-- src/pages/api/widgets/openweathermap.js | 4 +-- src/pages/api/widgets/stocks.js | 4 +-- src/pages/api/widgets/weather.js | 4 +-- src/utils/config/widget-helpers.js | 29 ++++++++++---------- src/utils/proxy/cached-fetch.js | 25 ----------------- src/utils/proxy/http.js | 35 +++++++++++++++++++++--- 10 files changed, 61 insertions(+), 65 deletions(-) delete mode 100644 src/utils/proxy/cached-fetch.js diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md index 7f73f2dd..81550439 100644 --- a/docs/troubleshooting/index.md +++ b/docs/troubleshooting/index.md @@ -70,7 +70,9 @@ If, after correctly adding and mapping your custom icons via the [Icons](../conf ## Disabling IPv6 -If you are having issues with certain widgets that are unable to reach public APIs (e.g. weather), you may need to disable IPv6 on your host machine. This can be done by adding the following to your `docker-compose.yml` file (or for docker run, the equivalent flag): +If you are having issues with certain widgets that are unable to reach public APIs (e.g. weather), in certain setups you may need to disable IPv6. You can set the environment variable `HOMEPAGE_PROXY_DISABLE_IPV6` to `true` to disable IPv6 for the homepage proxy. + +Alternatively, you can use the `sysctls` option in your docker-compose file to disable IPv6 for the homepage container completely: ```yaml services: @@ -79,12 +81,3 @@ services: sysctls: - net.ipv6.conf.all.disable_ipv6=1 ``` - -or disable IPv6 for the docker network: - -```yaml -networks: - some_network: - driver: bridge - enable_ipv6: false -``` diff --git a/src/pages/api/releases.js b/src/pages/api/releases.js index f15930c2..372ace9d 100644 --- a/src/pages/api/releases.js +++ b/src/pages/api/releases.js @@ -1,4 +1,4 @@ -import cachedFetch from "utils/proxy/cached-fetch"; +import { cachedRequest } from "utils/proxy/http"; import createLogger from "utils/logger"; const logger = createLogger("releases"); @@ -6,7 +6,7 @@ const logger = createLogger("releases"); export default async function handler(req, res) { const releasesURL = "https://api.github.com/repos/gethomepage/homepage/releases"; try { - return res.send(await cachedFetch(releasesURL, 5)); + return res.send(await cachedRequest(releasesURL, 5)); } catch (e) { logger.error(`Error checking GitHub releases: ${e}`); return res.send([]); diff --git a/src/pages/api/search/searchSuggestion.js b/src/pages/api/search/searchSuggestion.js index dbe072ea..209d1f2c 100644 --- a/src/pages/api/search/searchSuggestion.js +++ b/src/pages/api/search/searchSuggestion.js @@ -1,7 +1,7 @@ import { searchProviders } from "components/widgets/search/search"; import { getSettings } from "utils/config/config"; -import cachedFetch from "utils/proxy/cached-fetch"; +import { cachedRequest } from "utils/proxy/http"; import { widgetsFromConfig } from "utils/config/widget-helpers"; export default async function handler(req, res) { @@ -29,5 +29,5 @@ export default async function handler(req, res) { return res.json([query, []]); // Responde with the same array format but with no suggestions. } - return res.send(await cachedFetch(`${provider.suggestionUrl}${encodeURIComponent(query)}`, 5, "Mozilla/5.0")); + return res.send(await cachedRequest(`${provider.suggestionUrl}${encodeURIComponent(query)}`, 5, "Mozilla/5.0")); } diff --git a/src/pages/api/widgets/openmeteo.js b/src/pages/api/widgets/openmeteo.js index e63847b4..28f2e4f0 100644 --- a/src/pages/api/widgets/openmeteo.js +++ b/src/pages/api/widgets/openmeteo.js @@ -1,9 +1,9 @@ -import cachedFetch from "utils/proxy/cached-fetch"; +import { cachedRequest } from "utils/proxy/http"; export default async function handler(req, res) { const { latitude, longitude, units, cache, timezone } = req.query; const degrees = units === "metric" ? "celsius" : "fahrenheit"; const timezeone = timezone ?? "auto"; const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=sunrise,sunset¤t_weather=true&temperature_unit=${degrees}&timezone=${timezeone}`; - return res.send(await cachedFetch(apiUrl, cache)); + return res.send(await cachedRequest(apiUrl, cache)); } diff --git a/src/pages/api/widgets/openweathermap.js b/src/pages/api/widgets/openweathermap.js index 089ee804..3bdc7a82 100644 --- a/src/pages/api/widgets/openweathermap.js +++ b/src/pages/api/widgets/openweathermap.js @@ -1,4 +1,4 @@ -import cachedFetch from "utils/proxy/cached-fetch"; +import { cachedRequest } from "utils/proxy/http"; import { getSettings } from "utils/config/config"; import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; @@ -26,5 +26,5 @@ export default async function handler(req, res) { const apiUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=${units}&lang=${lang}`; - return res.send(await cachedFetch(apiUrl, cache)); + return res.send(await cachedRequest(apiUrl, cache)); } diff --git a/src/pages/api/widgets/stocks.js b/src/pages/api/widgets/stocks.js index 3941a773..4e9f3f55 100644 --- a/src/pages/api/widgets/stocks.js +++ b/src/pages/api/widgets/stocks.js @@ -1,4 +1,4 @@ -import cachedFetch from "utils/proxy/cached-fetch"; +import { cachedRequest } from "utils/proxy/http"; import { getSettings } from "utils/config/config"; import createLogger from "utils/logger"; @@ -60,7 +60,7 @@ export default async function handler(req, res) { const apiUrl = `https://finnhub.io/api/v1/quote?symbol=${ticker}&token=${apiKey}`; // Finnhub free accounts allow up to 60 calls/minute // https://finnhub.io/pricing - const { c, dp } = await cachedFetch(apiUrl, cache || 1); + const { c, dp } = await cachedRequest(apiUrl, cache || 1); logger.debug("Finnhub API response for %s: %o", ticker, { c, dp }); // API sometimes returns 200, but values returned are `null` diff --git a/src/pages/api/widgets/weather.js b/src/pages/api/widgets/weather.js index 9d0451ce..9e63e48d 100644 --- a/src/pages/api/widgets/weather.js +++ b/src/pages/api/widgets/weather.js @@ -1,4 +1,4 @@ -import cachedFetch from "utils/proxy/cached-fetch"; +import { cachedRequest } from "utils/proxy/http"; import { getSettings } from "utils/config/config"; import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; @@ -26,5 +26,5 @@ export default async function handler(req, res) { const apiUrl = `http://api.weatherapi.com/v1/current.json?q=${latitude},${longitude}&key=${apiKey}&lang=${lang}`; - return res.send(await cachedFetch(apiUrl, cache)); + return res.send(await cachedRequest(apiUrl, cache)); } diff --git a/src/utils/config/widget-helpers.js b/src/utils/config/widget-helpers.js index 3b1355d6..93f71194 100644 --- a/src/utils/config/widget-helpers.js +++ b/src/utils/config/widget-helpers.js @@ -56,21 +56,22 @@ export async function cleanWidgetGroups(widgets) { export async function getPrivateWidgetOptions(type, widgetIndex) { const widgets = await widgetsFromConfig(); - const privateOptions = widgets.map((widget) => { - const { index, url, username, password, key, apiKey } = widget.options; + const privateOptions = + widgets.map((widget) => { + const { index, url, username, password, key, apiKey } = widget.options; - return { - type: widget.type, - options: { - index, - url, - username, - password, - key, - apiKey, - }, - }; - }); + return { + type: widget.type, + options: { + index, + url, + username, + password, + key, + apiKey, + }, + }; + }) || {}; return type !== undefined && widgetIndex !== undefined ? privateOptions.find((o) => o.type === type && o.options.index === parseInt(widgetIndex, 10))?.options diff --git a/src/utils/proxy/cached-fetch.js b/src/utils/proxy/cached-fetch.js deleted file mode 100644 index ae3c4610..00000000 --- a/src/utils/proxy/cached-fetch.js +++ /dev/null @@ -1,25 +0,0 @@ -import cache from "memory-cache"; - -const defaultDuration = 5; - -export default async function cachedFetch(url, duration, ua) { - const cached = cache.get(url); - - // eslint-disable-next-line no-param-reassign - duration = duration || defaultDuration; - - if (cached) { - return cached; - } - - // wrapping text in JSON.parse to handle utf-8 issues - const options = {}; - if (ua) { - options.headers = { - "User-Agent": ua, - }; - } - const data = await fetch(url, options).then((res) => res.json()); - cache.put(url, data, duration * 1000 * 60); - return data; -} diff --git a/src/utils/proxy/http.js b/src/utils/proxy/http.js index 3743515b..f8d2dcce 100644 --- a/src/utils/proxy/http.js +++ b/src/utils/proxy/http.js @@ -3,6 +3,7 @@ import { createUnzip, constants as zlibConstants } from "node:zlib"; import { http, https } from "follow-redirects"; +import cache from "memory-cache"; import { addCookieToJar, setCookieHeader } from "./cookie-jar"; import { sanitizeErrorURL } from "./api-helpers"; @@ -81,20 +82,46 @@ export function httpRequest(url, params) { return handleRequest(http, url, params); } +export async function cachedRequest(url, duration = 5, ua = "homepage") { + const cached = cache.get(url); + + if (cached) { + return cached; + } + + const options = { + headers: { + "User-Agent": ua, + Accept: "application/json", + }, + }; + let [, , data] = await httpProxy(url, options); + if (Buffer.isBuffer(data)) { + try { + data = JSON.parse(Buffer.from(data).toString()); + } catch (e) { + logger.debug("Error parsing cachedRequest data for %s: %s %s", url, Buffer.from(data).toString(), e); + data = Buffer.from(data).toString(); + } + } + cache.put(url, data, duration * 1000 * 60); + return data; +} + export async function httpProxy(url, params = {}) { const constructedUrl = new URL(url); + const disableIpv6 = process.env.HOMEPAGE_PROXY_DISABLE_IPV6 === "true"; + const agentOptions = disableIpv6 ? { family: 4, autoSelectFamily: false } : {}; let request = null; if (constructedUrl.protocol === "https:") { request = httpsRequest(constructedUrl, { - agent: new https.Agent({ - rejectUnauthorized: false, - }), + agent: new https.Agent({ ...agentOptions, rejectUnauthorized: false }), ...params, }); } else { request = httpRequest(constructedUrl, { - agent: new http.Agent(), + agent: new http.Agent(agentOptions), ...params, }); } From de9c015f7fef10d7cbc9b951b7462982026fe781 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 17 Mar 2025 07:24:12 -0700 Subject: [PATCH 03/17] Fix: fix minecraft players after move to minecraftstatuspinger (#5017) --- src/widgets/minecraft/proxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/minecraft/proxy.js b/src/widgets/minecraft/proxy.js index ab01ae12..5f238acb 100644 --- a/src/widgets/minecraft/proxy.js +++ b/src/widgets/minecraft/proxy.js @@ -18,7 +18,7 @@ export default async function minecraftProxyHandler(req, res) { res.status(200).send({ version: pingResponse.status.version.name, online: true, - players: pingResponse.status.players.online, + players: pingResponse.status.players, }); } catch (e) { if (e) logger.error(e); From 8d20f22932a3e22aec4f8e4baac90c1557317828 Mon Sep 17 00:00:00 2001 From: morliont Date: Mon, 17 Mar 2025 19:30:01 +0100 Subject: [PATCH 04/17] Enhancement: support dynamic list rendering in custom api widget (#5012) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/widgets/services/customapi.md | 52 +++++++++++++++++++- src/widgets/customapi/component.jsx | 74 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/docs/widgets/services/customapi.md b/docs/widgets/services/customapi.md index 0deb8294..79345008 100644 --- a/docs/widgets/services/customapi.md +++ b/docs/widgets/services/customapi.md @@ -138,7 +138,15 @@ You can manipulate data with the following tools `remap`, `scale`, `prefix` and prefix: "$" ``` -## List View +## Display Options + +The widget supports different display modes that can be set using the `display` property. + +### Block View (Default) + +The default display mode is `block`, which shows fields in a block format. + +### List View You can change the default block view to a list view by setting the `display` option to `list`. @@ -169,6 +177,48 @@ The list view can optionally display an additional field next to the primary fie format: date ``` +### Dynamic List View + +To display a list of items from an array in the API response, set the `display` property to `dynamic-list` and configure the `mappings` object with the following properties: + +```yaml +widget: + type: customapi + url: https://example.com/api/servers + display: dynamic-list + mappings: + items: data # optional, the path to the array in the API response. Omit this option if the array is at the root level + name: id # required, field in each item to use as the item name (left side) + label: ip_address # required, field in each item to use as the item label (right side) + limit: 5 # optional, limit the number of items to display + target: https://example.com/server/{id} # optional, makes items clickable with template support +``` + +This configuration would work with an API that returns a response like: + +```json +{ + "data": [ + { "id": "server1", "name": "Server 1", "ip_address": "192.168.0.1" }, + { "id": "server2", "name": "Server 2", "ip_address": "192.168.0.2" } + ] +} +``` + +The widget would display a list with two items: + +- "Server 1" on the left and "192.168.0.1" on the right, clickable to "https://example.com/server/server1" +- "Server 2" on the left and "192.168.0.2" on the right, clickable to "https://example.com/server/server2" + +For nested fields in the items, you can use dot notation: + +```yaml +mappings: + items: data.results.servers + name: details.id + label: details.name +``` + ## Custom Headers Pass custom headers using the `headers` option, for example: diff --git a/src/widgets/customapi/component.jsx b/src/widgets/customapi/component.jsx index 6d0a63cd..3a6ac069 100644 --- a/src/widgets/customapi/component.jsx +++ b/src/widgets/customapi/component.jsx @@ -1,8 +1,10 @@ import { useTranslation } from "next-i18next"; import Container from "components/services/widget/container"; import Block from "components/services/widget/block"; +import classNames from "classnames"; import useWidgetAPI from "utils/proxy/use-widget-api"; +import * as shvl from "utils/config/shvl"; function getValue(field, data) { let value = data; @@ -165,6 +167,16 @@ export default function Component({ service }) { if (!customData) { switch (display) { + case "dynamic-list": + return ( + +
+
+
Loading...
+
+
+
+ ); case "list": return ( @@ -196,6 +208,68 @@ export default function Component({ service }) { } switch (display) { + case "dynamic-list": + let listItems = customData; + if (mappings.items) listItems = shvl.get(customData, mappings.items, null); + let error; + if (!listItems || !Array.isArray(listItems)) { + error = { message: "Unable to find items" }; + } + const name = mappings.name; + const label = mappings.label; + if (!name || !label) { + error = { message: "Name and label properties are required" }; + } + if (error) { + return ; + } + + const target = mappings.target; + if (mappings.limit && parseInt(mappings.limit, 10) > 0) { + listItems.splice(mappings.limit); + } + + return ( + +
+ {listItems.length === 0 ? ( +
+
No items found
+
+ ) : ( + listItems.map((item, index) => { + const itemName = shvl.get(item, name, ""); + const itemLabel = shvl.get(item, label, ""); + const itemUrl = target ? target.replace(/\{([^}]+)\}/g, (_, key) => item[key] || "") : null; + const className = + "bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs"; + + return itemUrl ? ( + +
{itemName}
+
+
{itemLabel}
+
+
+ ) : ( +
+
{itemName}
+
+
{itemLabel}
+
+
+ ); + }) + )} +
+
+ ); case "list": return ( From dca23e85478437775842a99a069922d15277ca4d Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 17 Mar 2025 11:47:55 -0700 Subject: [PATCH 05/17] Enhancement: support shvl syntax for customapi fields (#5020) --- docs/widgets/services/customapi.md | 28 +++++++++++++--------------- src/widgets/customapi/component.jsx | 5 +++++ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/widgets/services/customapi.md b/docs/widgets/services/customapi.md index 79345008..f11e21a5 100644 --- a/docs/widgets/services/customapi.md +++ b/docs/widgets/services/customapi.md @@ -22,15 +22,10 @@ widget: - field: key # needs to be YAML string or object label: Field 1 format: text # optional - defaults to text - - field: # needs to be YAML string or object - path: - to: key2 + - field: path.to.key2 format: number # optional - defaults to text label: Field 2 - - field: # needs to be YAML string or object - path: - to: - another: key3 + - field: path.to.another.key3 label: Field 3 format: percent # optional - defaults to text - field: key # needs to be YAML string or object @@ -49,9 +44,7 @@ widget: label: Field 6 format: text additionalField: # optional - field: - hourly: - time: other key + field: hourly.time.key color: theme # optional - defaults to "". Allowed values: `["theme", "adaptive", "black", "white"]`. format: date # optional - field: key @@ -103,9 +96,16 @@ mappings: label: Name - field: status # Alive label: Status - - field: - origin: name # Earth (C-137) + - field: origin.name # Earth (C-137) label: Origin + - field: locations.1.name # Citadel of Ricks + label: Location +``` + +Note that older versions of the widget accepted fields as a yaml object, which is still supported. E.g.: + +```yaml +mappings: - field: locations: 1: name # Citadel of Ricks @@ -170,9 +170,7 @@ The list view can optionally display an additional field next to the primary fie - any: true # will map all other values to: Unknown additionalField: - field: - hourly: - time: key + field: hourly.time.key color: theme format: date ``` diff --git a/src/widgets/customapi/component.jsx b/src/widgets/customapi/component.jsx index 3a6ac069..b8147caa 100644 --- a/src/widgets/customapi/component.jsx +++ b/src/widgets/customapi/component.jsx @@ -16,6 +16,11 @@ function getValue(field, data) { return value; } + // shvl is easier, everything else is kept for backwards compatibility. + if (typeof field === "string") { + return shvl.get(data, field, null); + } + while (typeof lastField === "object") { key = Object.keys(lastField)[0] ?? null; From 42f1ed3c473e0bf6400fd481d472c4aec3ad8697 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 17 Mar 2025 11:49:35 -0700 Subject: [PATCH 06/17] Update customapi.md --- docs/widgets/services/customapi.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/widgets/services/customapi.md b/docs/widgets/services/customapi.md index f11e21a5..70947a10 100644 --- a/docs/widgets/services/customapi.md +++ b/docs/widgets/services/customapi.md @@ -19,7 +19,7 @@ widget: requestBody: # optional, can be string or object, see below display: # optional, default to block, see below mappings: - - field: key # needs to be YAML string or object + - field: key label: Field 1 format: text # optional - defaults to text - field: path.to.key2 @@ -28,13 +28,13 @@ widget: - field: path.to.another.key3 label: Field 3 format: percent # optional - defaults to text - - field: key # needs to be YAML string or object + - field: key label: Field 4 format: date # optional - defaults to text locale: nl # optional dateStyle: long # optional - defaults to "long". Allowed values: `["full", "long", "medium", "short"]`. timeStyle: medium # optional - Allowed values: `["full", "long", "medium", "short"]`. - - field: key # needs to be YAML string or object + - field: key label: Field 5 format: relativeDate # optional - defaults to text locale: nl # optional From ce0102eb6fa9cf2835f221d216ac2a919fcf3ba4 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 17 Mar 2025 12:25:10 -0700 Subject: [PATCH 07/17] Enhancement: support full width container (#5021) --- docs/configs/settings.md | 8 ++++++++ src/pages/index.jsx | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/configs/settings.md b/docs/configs/settings.md index 93aa7f8f..d56d0f75 100644 --- a/docs/configs/settings.md +++ b/docs/configs/settings.md @@ -254,6 +254,14 @@ layout: columns: 4 ``` +### Full Width + +You can make homepage take up the entire window width by adding: + +```yaml +fullWidth: true +``` + ### Five Columns You can add a fifth column to services (when `style: columns` which is default) by adding: diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 5055a22b..51f5ead3 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -417,7 +417,12 @@ function Home({ initialSettings }) {