From cf7306e766ddb49fa165d63d4260fdb6f1e41e89 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Thu, 13 Nov 2025 11:17:44 +1000 Subject: [PATCH] Tweaks to showing new version available - Added frontend translation for english - Moved frontend api logic to hook and backend api space - Added swagger schema for the new api endpoint - Moved backend logic to its own internal file - Added user agent header to github api check - Added cypress integration test for version check api - Added a memory cache item from github check to avoid hitting it too much --- backend/internal/remote-version.js | 84 +++++++++++++++++++ backend/logger.js | 3 +- backend/routes/version.js | 73 ++-------------- .../components/check-version-object.json | 25 ++++++ backend/schema/paths/version/check/get.json | 26 ++++++ backend/schema/swagger.json | 5 ++ frontend/src/api/backend/checkVersion.ts | 8 ++ frontend/src/api/backend/index.ts | 1 + frontend/src/api/backend/responseTypes.ts | 6 ++ frontend/src/components/SiteFooter.tsx | 33 ++------ frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useCheckVersion.ts | 18 ++++ frontend/src/locale/lang/en.json | 1 + frontend/src/locale/src/en.json | 3 + test/cypress/e2e/api/Health.cy.js | 8 ++ 15 files changed, 200 insertions(+), 95 deletions(-) create mode 100644 backend/internal/remote-version.js create mode 100644 backend/schema/components/check-version-object.json create mode 100644 backend/schema/paths/version/check/get.json create mode 100644 frontend/src/api/backend/checkVersion.ts create mode 100644 frontend/src/hooks/useCheckVersion.ts diff --git a/backend/internal/remote-version.js b/backend/internal/remote-version.js new file mode 100644 index 00000000..dd9c9277 --- /dev/null +++ b/backend/internal/remote-version.js @@ -0,0 +1,84 @@ +import https from "node:https"; +import { ProxyAgent } from "proxy-agent"; +import { debug, remoteVersion as logger } from "../logger.js"; +import pjson from "../package.json" with { type: "json" }; + +const VERSION_URL = "https://api.github.com/repos/NginxProxyManager/nginx-proxy-manager/releases/latest"; + +const internalRemoteVersion = { + cache_timeout: 1000 * 60 * 15, // 15 minutes + last_result: null, + last_fetch_time: null, + + /** + * Fetch the latest version info, using a cached result if within the cache timeout period. + * @return {Promise<{current: string, latest: string, update_available: boolean}>} Version info + */ + get: async () => { + if ( + !internalRemoteVersion.last_result || + !internalRemoteVersion.last_fetch_time || + Date.now() - internalRemoteVersion.last_fetch_time > internalRemoteVersion.cache_timeout + ) { + const raw = await internalRemoteVersion.fetchUrl(VERSION_URL); + const data = JSON.parse(raw); + internalRemoteVersion.last_result = data; + internalRemoteVersion.last_fetch_time = Date.now(); + } else { + debug(logger, "Using cached remote version result"); + } + + const latestVersion = internalRemoteVersion.last_result.tag_name; + const version = pjson.version.split("-").shift().split("."); + const currentVersion = `v${version[0]}.${version[1]}.${version[2]}`; + return { + current: currentVersion, + latest: latestVersion, + update_available: internalRemoteVersion.compareVersions(currentVersion, latestVersion), + }; + }, + + fetchUrl: (url) => { + const agent = new ProxyAgent(); + const headers = { + "User-Agent": `NginxProxyManager v${pjson.version}`, + }; + + return new Promise((resolve, reject) => { + logger.info(`Fetching ${url}`); + return https + .get(url, { agent, headers }, (res) => { + res.setEncoding("utf8"); + let raw_data = ""; + res.on("data", (chunk) => { + raw_data += chunk; + }); + res.on("end", () => { + resolve(raw_data); + }); + }) + .on("error", (err) => { + reject(err); + }); + }); + }, + + compareVersions: (current, latest) => { + const cleanCurrent = current.replace(/^v/, ""); + const cleanLatest = latest.replace(/^v/, ""); + + const currentParts = cleanCurrent.split(".").map(Number); + const latestParts = cleanLatest.split(".").map(Number); + + for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { + const curr = currentParts[i] || 0; + const lat = latestParts[i] || 0; + + if (lat > curr) return true; + if (lat < curr) return false; + } + return false; + }, +}; + +export default internalRemoteVersion; diff --git a/backend/logger.js b/backend/logger.js index 7bf4ee05..2b60dbff 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -15,6 +15,7 @@ const certbot = new signale.Signale({ scope: "Certbot ", ...opts }); const importer = new signale.Signale({ scope: "Importer ", ...opts }); const setup = new signale.Signale({ scope: "Setup ", ...opts }); const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); +const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); const debug = (logger, ...args) => { if (isDebugMode()) { @@ -22,4 +23,4 @@ const debug = (logger, ...args) => { } }; -export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges }; +export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion }; diff --git a/backend/routes/version.js b/backend/routes/version.js index ac1ac4d7..266e56ff 100644 --- a/backend/routes/version.js +++ b/backend/routes/version.js @@ -1,8 +1,6 @@ import express from "express"; +import internalRemoteVersion from "../internal/remote-version.js"; import { debug, express as logger } from "../logger.js"; -import pjson from "../package.json" with { type: "json" }; -import https from "node:https"; -import { ProxyAgent } from "proxy-agent"; const router = express.Router({ caseSensitive: true, @@ -24,78 +22,19 @@ router * * Check for available updates */ - .get(async (req, res, next) => { + .get(async (req, res, _next) => { try { - const agent = new ProxyAgent(); - const url = "https://api.github.com/repos/NginxProxyManager/nginx-proxy-manager/releases/latest"; - - const data = await new Promise((resolve, reject) => { - https - .get(url, { agent }, (response) => { - if (response.statusCode !== 200) { - reject(new Error(`GitHub API returned ${response.statusCode}`)); - return; - } - - response.setEncoding("utf8"); - let raw_data = ""; - - response.on("data", (chunk) => { - raw_data += chunk; - }); - - response.on("end", () => { - try { - resolve(JSON.parse(raw_data)); - } catch (err) { - reject(err); - } - }); - }) - .on("error", (err) => { - reject(err); - }); - }); - - const latestVersion = data.tag_name; - - const version = pjson.version.split("-").shift().split("."); - const currentVersion = `v${version[0]}.${version[1]}.${version[2]}`; - - res.status(200).send({ - current: currentVersion, - latest: latestVersion, - updateAvailable: compareVersions(currentVersion, latestVersion), - }); + const data = await internalRemoteVersion.get(); + res.status(200).send(data); } catch (error) { debug(logger, `${req.method.toUpperCase()} ${req.path}: ${error}`); + // Send 200 even though there's an error to avoid triggering update checks repeatedly res.status(200).send({ current: null, latest: null, - updateAvailable: false, + update_available: false, }); } }); -/** - * Compare two version strings - * - */ -function compareVersions(current, latest) { - const cleanCurrent = current.replace(/^v/, ""); - const cleanLatest = latest.replace(/^v/, ""); - - const currentParts = cleanCurrent.split(".").map(Number); - const latestParts = cleanLatest.split(".").map(Number); - - for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { - const curr = currentParts[i] || 0; - const lat = latestParts[i] || 0; - - if (lat > curr) return true; - if (lat < curr) return false; - } - return false; -} - export default router; diff --git a/backend/schema/components/check-version-object.json b/backend/schema/components/check-version-object.json new file mode 100644 index 00000000..faacccf3 --- /dev/null +++ b/backend/schema/components/check-version-object.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "description": "Check Version object", + "additionalProperties": false, + "required": ["current", "latest", "update_available"], + "properties": { + "current": { + "type": "string", + "description": "Current version string", + "example": "v2.10.1", + "nullable": true + }, + "latest": { + "type": "string", + "description": "Latest version string", + "example": "v2.13.4", + "nullable": true + }, + "update_available": { + "type": "boolean", + "description": "Whether there's an update available", + "example": true + } + } +} diff --git a/backend/schema/paths/version/check/get.json b/backend/schema/paths/version/check/get.json new file mode 100644 index 00000000..cbc576a7 --- /dev/null +++ b/backend/schema/paths/version/check/get.json @@ -0,0 +1,26 @@ +{ + "operationId": "checkVersion", + "summary": "Returns any new version data from github", + "tags": ["public"], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "current": "v2.12.0", + "latest": "v2.13.4", + "update_available": true + } + } + }, + "schema": { + "$ref": "../../../components/check-version-object.json" + } + } + } + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index e7234d4e..9afb51f2 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -293,6 +293,11 @@ "$ref": "./paths/tokens/post.json" } }, + "/version/check": { + "get": { + "$ref": "./paths/version/check/get.json" + } + }, "/users": { "get": { "$ref": "./paths/users/get.json" diff --git a/frontend/src/api/backend/checkVersion.ts b/frontend/src/api/backend/checkVersion.ts new file mode 100644 index 00000000..5095526e --- /dev/null +++ b/frontend/src/api/backend/checkVersion.ts @@ -0,0 +1,8 @@ +import * as api from "./base"; +import type { VersionCheckResponse } from "./responseTypes"; + +export async function checkVersion(): Promise { + return await api.get({ + url: "/version/check", + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 6d42a6f4..9ff0bbd8 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -1,3 +1,4 @@ +export * from "./checkVersion"; export * from "./createAccessList"; export * from "./createCertificate"; export * from "./createDeadHost"; diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 7169fc54..1b0bc16b 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -19,3 +19,9 @@ export interface ValidatedCertificateResponse { export interface LoginAsTokenResponse extends TokenResponse { user: User; } + +export interface VersionCheckResponse { + current: string | null; + latest: string | null; + updateAvailable: boolean; +} diff --git a/frontend/src/components/SiteFooter.tsx b/frontend/src/components/SiteFooter.tsx index 2135d2c6..57bb2194 100644 --- a/frontend/src/components/SiteFooter.tsx +++ b/frontend/src/components/SiteFooter.tsx @@ -1,11 +1,9 @@ -import { useEffect, useState } from "react"; -import { useHealth } from "src/hooks"; +import { useCheckVersion, useHealth } from "src/hooks"; import { T } from "src/locale"; export function SiteFooter() { const health = useHealth(); - const [latestVersion, setLatestVersion] = useState(null); - const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false); + const { data: versionData } = useCheckVersion(); const getVersion = () => { if (!health.data) { @@ -15,25 +13,6 @@ export function SiteFooter() { return `v${v.major}.${v.minor}.${v.revision}`; }; - useEffect(() => { - const checkForUpdates = async () => { - try { - const response = await fetch("/api/version/check"); - if (response.ok) { - const data = await response.json(); - setLatestVersion(data.latest); - setIsNewVersionAvailable(data.updateAvailable); - } - } catch (error) { - console.debug("Could not check for updates:", error); - } - }; - - if (health.data) { - checkForUpdates(); - } - }, [health.data]); - return (