Skip to content

Commit cf7306e

Browse files
committed
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
1 parent 8838dab commit cf7306e

File tree

15 files changed

+200
-95
lines changed

15 files changed

+200
-95
lines changed

backend/internal/remote-version.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import https from "node:https";
2+
import { ProxyAgent } from "proxy-agent";
3+
import { debug, remoteVersion as logger } from "../logger.js";
4+
import pjson from "../package.json" with { type: "json" };
5+
6+
const VERSION_URL = "https://api.github.com/repos/NginxProxyManager/nginx-proxy-manager/releases/latest";
7+
8+
const internalRemoteVersion = {
9+
cache_timeout: 1000 * 60 * 15, // 15 minutes
10+
last_result: null,
11+
last_fetch_time: null,
12+
13+
/**
14+
* Fetch the latest version info, using a cached result if within the cache timeout period.
15+
* @return {Promise<{current: string, latest: string, update_available: boolean}>} Version info
16+
*/
17+
get: async () => {
18+
if (
19+
!internalRemoteVersion.last_result ||
20+
!internalRemoteVersion.last_fetch_time ||
21+
Date.now() - internalRemoteVersion.last_fetch_time > internalRemoteVersion.cache_timeout
22+
) {
23+
const raw = await internalRemoteVersion.fetchUrl(VERSION_URL);
24+
const data = JSON.parse(raw);
25+
internalRemoteVersion.last_result = data;
26+
internalRemoteVersion.last_fetch_time = Date.now();
27+
} else {
28+
debug(logger, "Using cached remote version result");
29+
}
30+
31+
const latestVersion = internalRemoteVersion.last_result.tag_name;
32+
const version = pjson.version.split("-").shift().split(".");
33+
const currentVersion = `v${version[0]}.${version[1]}.${version[2]}`;
34+
return {
35+
current: currentVersion,
36+
latest: latestVersion,
37+
update_available: internalRemoteVersion.compareVersions(currentVersion, latestVersion),
38+
};
39+
},
40+
41+
fetchUrl: (url) => {
42+
const agent = new ProxyAgent();
43+
const headers = {
44+
"User-Agent": `NginxProxyManager v${pjson.version}`,
45+
};
46+
47+
return new Promise((resolve, reject) => {
48+
logger.info(`Fetching ${url}`);
49+
return https
50+
.get(url, { agent, headers }, (res) => {
51+
res.setEncoding("utf8");
52+
let raw_data = "";
53+
res.on("data", (chunk) => {
54+
raw_data += chunk;
55+
});
56+
res.on("end", () => {
57+
resolve(raw_data);
58+
});
59+
})
60+
.on("error", (err) => {
61+
reject(err);
62+
});
63+
});
64+
},
65+
66+
compareVersions: (current, latest) => {
67+
const cleanCurrent = current.replace(/^v/, "");
68+
const cleanLatest = latest.replace(/^v/, "");
69+
70+
const currentParts = cleanCurrent.split(".").map(Number);
71+
const latestParts = cleanLatest.split(".").map(Number);
72+
73+
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
74+
const curr = currentParts[i] || 0;
75+
const lat = latestParts[i] || 0;
76+
77+
if (lat > curr) return true;
78+
if (lat < curr) return false;
79+
}
80+
return false;
81+
},
82+
};
83+
84+
export default internalRemoteVersion;

backend/logger.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ const certbot = new signale.Signale({ scope: "Certbot ", ...opts });
1515
const importer = new signale.Signale({ scope: "Importer ", ...opts });
1616
const setup = new signale.Signale({ scope: "Setup ", ...opts });
1717
const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts });
18+
const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts });
1819

1920
const debug = (logger, ...args) => {
2021
if (isDebugMode()) {
2122
logger.debug(...args);
2223
}
2324
};
2425

25-
export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges };
26+
export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion };

backend/routes/version.js

Lines changed: 6 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import express from "express";
2+
import internalRemoteVersion from "../internal/remote-version.js";
23
import { debug, express as logger } from "../logger.js";
3-
import pjson from "../package.json" with { type: "json" };
4-
import https from "node:https";
5-
import { ProxyAgent } from "proxy-agent";
64

75
const router = express.Router({
86
caseSensitive: true,
@@ -24,78 +22,19 @@ router
2422
*
2523
* Check for available updates
2624
*/
27-
.get(async (req, res, next) => {
25+
.get(async (req, res, _next) => {
2826
try {
29-
const agent = new ProxyAgent();
30-
const url = "https://api.github.com/repos/NginxProxyManager/nginx-proxy-manager/releases/latest";
31-
32-
const data = await new Promise((resolve, reject) => {
33-
https
34-
.get(url, { agent }, (response) => {
35-
if (response.statusCode !== 200) {
36-
reject(new Error(`GitHub API returned ${response.statusCode}`));
37-
return;
38-
}
39-
40-
response.setEncoding("utf8");
41-
let raw_data = "";
42-
43-
response.on("data", (chunk) => {
44-
raw_data += chunk;
45-
});
46-
47-
response.on("end", () => {
48-
try {
49-
resolve(JSON.parse(raw_data));
50-
} catch (err) {
51-
reject(err);
52-
}
53-
});
54-
})
55-
.on("error", (err) => {
56-
reject(err);
57-
});
58-
});
59-
60-
const latestVersion = data.tag_name;
61-
62-
const version = pjson.version.split("-").shift().split(".");
63-
const currentVersion = `v${version[0]}.${version[1]}.${version[2]}`;
64-
65-
res.status(200).send({
66-
current: currentVersion,
67-
latest: latestVersion,
68-
updateAvailable: compareVersions(currentVersion, latestVersion),
69-
});
27+
const data = await internalRemoteVersion.get();
28+
res.status(200).send(data);
7029
} catch (error) {
7130
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${error}`);
31+
// Send 200 even though there's an error to avoid triggering update checks repeatedly
7232
res.status(200).send({
7333
current: null,
7434
latest: null,
75-
updateAvailable: false,
35+
update_available: false,
7636
});
7737
}
7838
});
7939

80-
/**
81-
* Compare two version strings
82-
*
83-
*/
84-
function compareVersions(current, latest) {
85-
const cleanCurrent = current.replace(/^v/, "");
86-
const cleanLatest = latest.replace(/^v/, "");
87-
88-
const currentParts = cleanCurrent.split(".").map(Number);
89-
const latestParts = cleanLatest.split(".").map(Number);
90-
91-
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
92-
const curr = currentParts[i] || 0;
93-
const lat = latestParts[i] || 0;
94-
95-
if (lat > curr) return true;
96-
if (lat < curr) return false;
97-
}
98-
return false;
99-
}
100-
10140
export default router;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"type": "object",
3+
"description": "Check Version object",
4+
"additionalProperties": false,
5+
"required": ["current", "latest", "update_available"],
6+
"properties": {
7+
"current": {
8+
"type": "string",
9+
"description": "Current version string",
10+
"example": "v2.10.1",
11+
"nullable": true
12+
},
13+
"latest": {
14+
"type": "string",
15+
"description": "Latest version string",
16+
"example": "v2.13.4",
17+
"nullable": true
18+
},
19+
"update_available": {
20+
"type": "boolean",
21+
"description": "Whether there's an update available",
22+
"example": true
23+
}
24+
}
25+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"operationId": "checkVersion",
3+
"summary": "Returns any new version data from github",
4+
"tags": ["public"],
5+
"responses": {
6+
"200": {
7+
"description": "200 response",
8+
"content": {
9+
"application/json": {
10+
"examples": {
11+
"default": {
12+
"value": {
13+
"current": "v2.12.0",
14+
"latest": "v2.13.4",
15+
"update_available": true
16+
}
17+
}
18+
},
19+
"schema": {
20+
"$ref": "../../../components/check-version-object.json"
21+
}
22+
}
23+
}
24+
}
25+
}
26+
}

backend/schema/swagger.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,11 @@
293293
"$ref": "./paths/tokens/post.json"
294294
}
295295
},
296+
"/version/check": {
297+
"get": {
298+
"$ref": "./paths/version/check/get.json"
299+
}
300+
},
296301
"/users": {
297302
"get": {
298303
"$ref": "./paths/users/get.json"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as api from "./base";
2+
import type { VersionCheckResponse } from "./responseTypes";
3+
4+
export async function checkVersion(): Promise<VersionCheckResponse> {
5+
return await api.get({
6+
url: "/version/check",
7+
});
8+
}

frontend/src/api/backend/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./checkVersion";
12
export * from "./createAccessList";
23
export * from "./createCertificate";
34
export * from "./createDeadHost";

frontend/src/api/backend/responseTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ export interface ValidatedCertificateResponse {
1919
export interface LoginAsTokenResponse extends TokenResponse {
2020
user: User;
2121
}
22+
23+
export interface VersionCheckResponse {
24+
current: string | null;
25+
latest: string | null;
26+
updateAvailable: boolean;
27+
}

frontend/src/components/SiteFooter.tsx

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { useEffect, useState } from "react";
2-
import { useHealth } from "src/hooks";
1+
import { useCheckVersion, useHealth } from "src/hooks";
32
import { T } from "src/locale";
43

54
export function SiteFooter() {
65
const health = useHealth();
7-
const [latestVersion, setLatestVersion] = useState<string | null>(null);
8-
const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false);
6+
const { data: versionData } = useCheckVersion();
97

108
const getVersion = () => {
119
if (!health.data) {
@@ -15,25 +13,6 @@ export function SiteFooter() {
1513
return `v${v.major}.${v.minor}.${v.revision}`;
1614
};
1715

18-
useEffect(() => {
19-
const checkForUpdates = async () => {
20-
try {
21-
const response = await fetch("/api/version/check");
22-
if (response.ok) {
23-
const data = await response.json();
24-
setLatestVersion(data.latest);
25-
setIsNewVersionAvailable(data.updateAvailable);
26-
}
27-
} catch (error) {
28-
console.debug("Could not check for updates:", error);
29-
}
30-
};
31-
32-
if (health.data) {
33-
checkForUpdates();
34-
}
35-
}, [health.data]);
36-
3716
return (
3817
<footer className="footer d-print-none py-3">
3918
<div className="container-xl">
@@ -77,16 +56,16 @@ export function SiteFooter() {
7756
{getVersion()}{" "}
7857
</a>
7958
</li>
80-
{isNewVersionAvailable && latestVersion && (
59+
{versionData?.updateAvailable && versionData?.latest && (
8160
<li className="list-inline-item">
8261
<a
83-
href={`https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/${latestVersion}`}
62+
href={`https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/${versionData.latest}`}
8463
className="link-warning fw-bold"
8564
target="_blank"
8665
rel="noopener"
87-
title={`New version ${latestVersion} is available`}
66+
title={`New version ${versionData.latest} is available`}
8867
>
89-
Update Available: ({latestVersion})
68+
<T id="update-available" data={{ latestVersion: versionData.latest }} />
9069
</a>
9170
</li>
9271
)}

0 commit comments

Comments
 (0)