diff --git a/src/__tests__/pages/api/widgets/glances.test.js b/src/__tests__/pages/api/widgets/glances.test.js index 6d37ac7b6..da41c3abb 100644 --- a/src/__tests__/pages/api/widgets/glances.test.js +++ b/src/__tests__/pages/api/widgets/glances.test.js @@ -92,6 +92,23 @@ describe("pages/api/widgets/glances", () => { expect(res.statusCode).toBe(200); }); + it("falls back to version 3 when version is invalid", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" }); + + httpProxy + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) + .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]); + + const req = { query: { index: "0", version: "3/../../secret-endpoint" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(httpProxy).toHaveBeenCalledWith("http://glances/api/3/cpu", expect.any(Object)); + expect(res.statusCode).toBe(200); + }); + it("returns 400 when glances returns 401", async () => { getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" }); httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]); diff --git a/src/pages/api/widgets/glances.js b/src/pages/api/widgets/glances.js index f0a3a7d9b..7234ba77d 100644 --- a/src/pages/api/widgets/glances.js +++ b/src/pages/api/widgets/glances.js @@ -1,5 +1,6 @@ import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; import createLogger from "utils/logger"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import { httpProxy } from "utils/proxy/http"; const logger = createLogger("glances"); @@ -45,7 +46,7 @@ export default async function handler(req, res) { const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query; const privateWidgetOptions = await getPrivateWidgetOptions("glances", index); - privateWidgetOptions.version = version ?? 3; + privateWidgetOptions.version = parseVersionForUrl(version, 3); try { const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, "cpu"); diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index f9651af5d..f68916f74 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -10,6 +10,7 @@ import { getKubeConfig } from "utils/config/kubernetes"; import * as shvl from "utils/config/shvl"; import kubernetes from "utils/kubernetes/export"; import createLogger from "utils/logger"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; const logger = createLogger("service-helpers"); @@ -113,7 +114,7 @@ export async function servicesFromDocker() { } let substitutedVal = substituteEnvironmentVars(containerLabels[label]); if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) { - substitutedVal = parseInt(substitutedVal, 10); + substitutedVal = parseVersionForUrl(substitutedVal); } shvl.set(constructedService, value, substitutedVal); } @@ -590,7 +591,7 @@ export function cleanServiceGroups(groups) { "vikunja", ].includes(type) ) { - if (version) widget.version = parseInt(version, 10); + widget.version = parseVersionForUrl(version); } if (type === "glances") { if (metric) widget.metric = metric; diff --git a/src/utils/proxy/api-helpers.js b/src/utils/proxy/api-helpers.js index ddeb0c5c0..bc5a93397 100644 --- a/src/utils/proxy/api-helpers.js +++ b/src/utils/proxy/api-helpers.js @@ -12,6 +12,22 @@ export function formatApiCall(url, args) { return url.replace(find, replace).replace(find, replace); } +export function parseVersionForUrl(version, defaultValue = null) { + if (version === undefined || version === null || version === "") { + return defaultValue; + } + + if (typeof version === "number") { + return Number.isInteger(version) && version >= 0 ? version : defaultValue; + } + + if (typeof version === "string" && /^\d+$/.test(version)) { + return Number(version); + } + + return defaultValue; +} + export function getURLSearchParams(widget, endpoint) { const params = new URLSearchParams({ group: widget.service_group, diff --git a/src/utils/proxy/api-helpers.test.js b/src/utils/proxy/api-helpers.test.js index 67031632e..ad9143e82 100644 --- a/src/utils/proxy/api-helpers.test.js +++ b/src/utils/proxy/api-helpers.test.js @@ -7,6 +7,7 @@ import { getURLSearchParams, jsonArrayFilter, jsonArrayTransform, + parseVersionForUrl, sanitizeErrorURL, } from "./api-helpers"; @@ -21,6 +22,20 @@ describe("utils/proxy/api-helpers", () => { expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-"); }); + it("parseVersionForUrl accepts canonical non-negative integers", () => { + expect(parseVersionForUrl("3")).toBe(3); + expect(parseVersionForUrl(4)).toBe(4); + expect(parseVersionForUrl(undefined, 3)).toBe(3); + }); + + it("parseVersionForUrl rejects non-canonical values", () => { + expect(parseVersionForUrl("3/../../path", 3)).toBe(3); + expect(parseVersionForUrl("1e2", 3)).toBe(3); + expect(parseVersionForUrl("0x10", 3)).toBe(3); + expect(parseVersionForUrl(-1, 3)).toBe(3); + expect(parseVersionForUrl(Number.NaN, 3)).toBe(3); + }); + it("getURLSearchParams includes group/service/index and optionally endpoint", () => { const widget = { service_group: "g", service_name: "s", index: "0" }; diff --git a/src/utils/proxy/handlers/synology.js b/src/utils/proxy/handlers/synology.js index 9e3dc4459..10398a807 100644 --- a/src/utils/proxy/handlers/synology.js +++ b/src/utils/proxy/handlers/synology.js @@ -2,7 +2,7 @@ import cache from "memory-cache"; import getServiceWidget from "utils/config/service-helpers"; import createLogger from "utils/logger"; -import { asJson, formatApiCall } from "utils/proxy/api-helpers"; +import { asJson, formatApiCall, parseVersionForUrl } from "utils/proxy/api-helpers"; import { httpProxy } from "utils/proxy/http"; import widgets from "widgets/widgets"; @@ -56,7 +56,7 @@ async function getApiInfo(serviceWidget, apiName, serviceName) { const json = asJson(data); if (json?.data?.[apiName]) { cgiPath = json.data[apiName].path; - maxVersion = json.data[apiName].maxVersion; + maxVersion = parseVersionForUrl(json.data[apiName].maxVersion); logger.debug( `Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`, ); diff --git a/src/widgets/glances/metrics/containers.jsx b/src/widgets/glances/metrics/containers.jsx index 8968305a8..567ba3756 100644 --- a/src/widgets/glances/metrics/containers.jsx +++ b/src/widgets/glances/metrics/containers.jsx @@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const statusMap = { @@ -19,11 +20,12 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; const { chart, refreshInterval = defaultInterval, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); - const idKey = version === 3 ? "Id" : "id"; - const statusKey = version === 3 ? "Status" : "status"; + const idKey = apiVersion === 3 ? "Id" : "id"; + const statusKey = apiVersion === 3 ? "Status" : "status"; - const { data, error } = useWidgetAPI(service.widget, `${version}/containers`, { + const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/containers`, { refreshInterval: Math.max(defaultInterval, refreshInterval), }); diff --git a/src/widgets/glances/metrics/cpu.jsx b/src/widgets/glances/metrics/cpu.jsx index 3debf11a4..788025763 100644 --- a/src/widgets/glances/metrics/cpu.jsx +++ b/src/widgets/glances/metrics/cpu.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const Chart = dynamic(() => import("../components/chart"), { ssr: false }); @@ -16,14 +17,15 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); - const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, { + const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/cpu`, { refreshInterval: Math.max(defaultInterval, refreshInterval), }); - const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`); + const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`); useEffect(() => { if (data) { diff --git a/src/widgets/glances/metrics/disk.jsx b/src/widgets/glances/metrics/disk.jsx index 69dd2d999..0be6aaac5 100644 --- a/src/widgets/glances/metrics/disk.jsx +++ b/src/widgets/glances/metrics/disk.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); @@ -16,6 +17,7 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); const [, diskName] = widget.metric.split(":"); const [dataPoints, setDataPoints] = useState( @@ -23,7 +25,7 @@ export default function Component({ service }) { ); const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit)); - const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, { + const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/diskio`, { refreshInterval: Math.max(defaultInterval, refreshInterval), }); diff --git a/src/widgets/glances/metrics/fs.jsx b/src/widgets/glances/metrics/fs.jsx index 317a781fe..a6194a61a 100644 --- a/src/widgets/glances/metrics/fs.jsx +++ b/src/widgets/glances/metrics/fs.jsx @@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const defaultInterval = 1000; @@ -11,10 +12,11 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; const { chart, refreshInterval = defaultInterval, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); const [, fsName] = widget.metric.split("fs:"); const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes"; - const { data, error } = useWidgetAPI(widget, `${version}/fs`, { + const { data, error } = useWidgetAPI(widget, `${apiVersion}/fs`, { refreshInterval: Math.max(defaultInterval, refreshInterval), }); diff --git a/src/widgets/glances/metrics/gpu.jsx b/src/widgets/glances/metrics/gpu.jsx index 7eab536ca..b831f8175 100644 --- a/src/widgets/glances/metrics/gpu.jsx +++ b/src/widgets/glances/metrics/gpu.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); @@ -16,11 +17,12 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); const [, gpuName] = widget.metric.split(":"); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit)); - const { data, error } = useWidgetAPI(widget, `${version}/gpu`, { + const { data, error } = useWidgetAPI(widget, `${apiVersion}/gpu`, { refreshInterval: Math.max(defaultInterval, refreshInterval), }); diff --git a/src/widgets/glances/metrics/info.jsx b/src/widgets/glances/metrics/info.jsx index b612c1c2b..dd93f9408 100644 --- a/src/widgets/glances/metrics/info.jsx +++ b/src/widgets/glances/metrics/info.jsx @@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; function Swap({ quicklookData, className = "" }) { @@ -75,12 +76,13 @@ const defaultSystemInterval = 30000; // This data (OS, hostname, distribution) i export default function Component({ service }) { const { widget } = service; const { chart, refreshInterval = defaultInterval, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); - const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, { + const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`, { refreshInterval, }); - const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, { + const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${apiVersion}/system`, { refreshInterval: defaultSystemInterval, }); diff --git a/src/widgets/glances/metrics/memory.jsx b/src/widgets/glances/metrics/memory.jsx index 279286d0b..b7f021edc 100644 --- a/src/widgets/glances/metrics/memory.jsx +++ b/src/widgets/glances/metrics/memory.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); @@ -17,10 +18,11 @@ export default function Component({ service }) { const { widget } = service; const { chart } = widget; const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); - const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, { + const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/mem`, { refreshInterval: Math.max(defaultInterval(chart), refreshInterval), }); diff --git a/src/widgets/glances/metrics/net.jsx b/src/widgets/glances/metrics/net.jsx index 2bdd491ce..daf416d87 100644 --- a/src/widgets/glances/metrics/net.jsx +++ b/src/widgets/glances/metrics/net.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false }); @@ -17,15 +18,16 @@ export default function Component({ service }) { const { widget } = service; const { chart, metric } = widget; const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); - const rxKey = version === 3 ? "rx" : "bytes_recv"; - const txKey = version === 3 ? "tx" : "bytes_sent"; + const rxKey = apiVersion === 3 ? "rx" : "bytes_recv"; + const txKey = apiVersion === 3 ? "tx" : "bytes_sent"; const [, interfaceName] = metric.split(":"); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); - const { data, error } = useWidgetAPI(widget, `${version}/network`, { + const { data, error } = useWidgetAPI(widget, `${apiVersion}/network`, { refreshInterval: Math.max(defaultInterval(chart), refreshInterval), }); diff --git a/src/widgets/glances/metrics/process.jsx b/src/widgets/glances/metrics/process.jsx index a7eedde1c..220713516 100644 --- a/src/widgets/glances/metrics/process.jsx +++ b/src/widgets/glances/metrics/process.jsx @@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const statusMap = { @@ -22,10 +23,11 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; const { chart, refreshInterval = defaultInterval, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); - const memoryInfoKey = version === 3 ? 0 : "rss"; + const memoryInfoKey = apiVersion === 3 ? 0 : "rss"; - const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, { + const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/processlist`, { refreshInterval: Math.max(defaultInterval, refreshInterval), }); diff --git a/src/widgets/glances/metrics/sensor.jsx b/src/widgets/glances/metrics/sensor.jsx index b5a16d10c..90374fede 100644 --- a/src/widgets/glances/metrics/sensor.jsx +++ b/src/widgets/glances/metrics/sensor.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Block from "../components/block"; import Container from "../components/container"; +import { parseVersionForUrl } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; const Chart = dynamic(() => import("../components/chart"), { ssr: false }); @@ -16,11 +17,12 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget; + const apiVersion = parseVersionForUrl(version, 3); const [, sensorName] = widget.metric.split(":"); const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit)); - const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, { + const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/sensors`, { refreshInterval: Math.max(defaultInterval, refreshInterval), });