diff --git a/.version b/.version index 965a689ec0..8bbab56270 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.13.4 +2.13.5 diff --git a/README.md b/README.md index 2a4c817120..5ba3650bf7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@



- + diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json index daf2d0a8a4..102734d2a0 100644 --- a/backend/certbot/dns-plugins.json +++ b/backend/certbot/dns-plugins.json @@ -26,8 +26,8 @@ "azure": { "name": "Azure", "package_name": "certbot-dns-azure", - "version": "~=1.2.0", - "dependencies": "", + "version": "~=2.6.1", + "dependencies": "azure-mgmt-dns==8.2.0", "credentials": "# This plugin supported API authentication using either Service Principals or utilizing a Managed Identity assigned to the virtual machine.\n# Regardless which authentication method used, the identity will need the “DNS Zone Contributor” role assigned to it.\n# As multiple Azure DNS Zones in multiple resource groups can exist, the config file needs a mapping of zone to resource group ID. Multiple zones -> ID mappings can be listed by using the key dns_azure_zoneX where X is a unique number. At least 1 zone mapping is required.\n\n# Using a service principal (option 1)\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = E-xqXU83Y-jzTI6xe9fs2YC~mck3ZzUih9\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n\n# Using used assigned MSI (option 2)\n# dns_azure_msi_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\n\n# Using system assigned MSI (option 3)\n# dns_azure_msi_system_assigned = true\n\n# Zones (at least one always required)\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/dns1\ndns_azure_zone2 = example.org:/subscriptions/99800903-fb14-4992-9aff-12eaf2744622/resourceGroups/dns2", "full_plugin_name": "dns-azure" }, @@ -482,7 +482,7 @@ "porkbun": { "name": "Porkbun", "package_name": "certbot-dns-porkbun", - "version": "~=0.9", + "version": "~=0.11.0", "dependencies": "", "credentials": "dns_porkbun_key=your-porkbun-api-key\ndns_porkbun_secret=your-porkbun-api-secret", "full_plugin_name": "dns-porkbun" diff --git a/backend/internal/remote-version.js b/backend/internal/remote-version.js new file mode 100644 index 0000000000..dd9c927797 --- /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 7bf4ee0525..2b60dbff7b 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/main.js b/backend/routes/main.js index 7bc4323d8b..94682cfba4 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -14,6 +14,7 @@ import schemaRoutes from "./schema.js"; import settingsRoutes from "./settings.js"; import tokensRoutes from "./tokens.js"; import usersRoutes from "./users.js"; +import versionRoutes from "./version.js"; const router = express.Router({ caseSensitive: true, @@ -46,6 +47,7 @@ router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); router.use("/reports", reportsRoutes); router.use("/settings", settingsRoutes); +router.use("/version", versionRoutes); router.use("/nginx/proxy-hosts", proxyHostsRoutes); router.use("/nginx/redirection-hosts", redirectionHostsRoutes); router.use("/nginx/dead-hosts", deadHostsRoutes); diff --git a/backend/routes/version.js b/backend/routes/version.js new file mode 100644 index 0000000000..266e56fff7 --- /dev/null +++ b/backend/routes/version.js @@ -0,0 +1,40 @@ +import express from "express"; +import internalRemoteVersion from "../internal/remote-version.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * /api/version/check + */ +router + .route("/check") + .options((_, res) => { + res.sendStatus(204); + }) + + /** + * GET /api/version/check + * + * Check for available updates + */ + .get(async (req, res, _next) => { + try { + 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, + update_available: 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 0000000000..ef2ffac418 --- /dev/null +++ b/backend/schema/components/check-version-object.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "description": "Check Version object", + "additionalProperties": false, + "required": ["current", "latest", "update_available"], + "properties": { + "current": { + "type": ["string", "null"], + "description": "Current version string", + "example": "v2.10.1" + }, + "latest": { + "type": ["string", "null"], + "description": "Latest version string", + "example": "v2.13.4" + }, + "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 0000000000..cbc576a7f5 --- /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 e7234d4ec3..9afb51f293 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/backend/yarn.lock b/backend/yarn.lock index f5db77966d..b6f7a94885 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1430,9 +1430,9 @@ isexe@^2.0.0: integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml index ce8913fe2b..ba2c5d3fed 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -24,9 +24,13 @@ services: interval: 10s timeout: 3s expose: - - "80-81/tcp" + - "80/tcp" + - "81/tcp" - "443/tcp" - - "1500-1503/tcp" + - "1500/tcp" + - "1501/tcp" + - "1502/tcp" + - "1503/tcp" networks: fulltest: aliases: diff --git a/frontend/.gitignore b/frontend/.gitignore index 8b7e50214d..a9b91bc1a0 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,3 +1,5 @@ +src/locale/lang + # Logs logs *.log diff --git a/frontend/check-locales.cjs b/frontend/check-locales.cjs index 2429cbc223..bd871169fb 100755 --- a/frontend/check-locales.cjs +++ b/frontend/check-locales.cjs @@ -8,12 +8,16 @@ const allLocales = [ ["en", "en-US"], - ["es", "es-ES"], ["de", "de-DE"], + ["es", "es-ES"], + ["it", "it-IT"], + ["ja", "ja-JP"], + ["nl", "nl-NL"], + ["pl", "pl-PL"], ["ru", "ru-RU"], ["sk", "sk-SK"], + ["vi", "vi-VN"], ["zh", "zh-CN"], - ["pl", "pl-PL"], ]; const ignoreUnused = [ diff --git a/frontend/index.html b/frontend/index.html index 8081804472..c6a2012266 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,7 @@ Nginx Proxy Manager + { + return await api.get({ + url: "/version/check", + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 6d42a6f408..9ff0bbd81c 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 7169fc54ba..1b0bc16b0f 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/Form/AccessClientFields.tsx b/frontend/src/components/Form/AccessClientFields.tsx index 820907dc64..9dda8c3de0 100644 --- a/frontend/src/components/Form/AccessClientFields.tsx +++ b/frontend/src/components/Form/AccessClientFields.tsx @@ -3,7 +3,7 @@ import cn from "classnames"; import { useFormikContext } from "formik"; import { useState } from "react"; import type { AccessListClient } from "src/api/backend"; -import { T } from "src/locale"; +import { intl, T } from "src/locale"; interface Props { initialValues: AccessListClient[]; @@ -65,8 +65,8 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) { value={client.directive} onChange={(e) => handleChange(idx, "directive", e.target.value)} > - - + + handleChange(idx, "address", e.target.value)} - placeholder="192.168.1.100 or 192.168.1.0/24 or 2001:0db8::/32" + placeholder={intl.formatMessage({ id: "access-list.rule-source.placeholder" })} /> @@ -112,7 +112,7 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) { value="deny" disabled > - + -

- {localeOptions.map((item) => { - return ( - { - e.preventDefault(); - changeTo(item[0]); - }} - > - - - ); +
+ {localeOptions.map((item: any) => ( + { + e.preventDefault(); + changeTo(item[0]); + }} + > + + + ))}
); diff --git a/frontend/src/components/SiteContainer.tsx b/frontend/src/components/SiteContainer.tsx index 1722d88a40..01a9cb52ba 100644 --- a/frontend/src/components/SiteContainer.tsx +++ b/frontend/src/components/SiteContainer.tsx @@ -2,5 +2,5 @@ interface Props { children: React.ReactNode; } export function SiteContainer({ children }: Props) { - return
{children}
; + return
{children}
; } diff --git a/frontend/src/components/SiteFooter.tsx b/frontend/src/components/SiteFooter.tsx index a177839568..57bb21946b 100644 --- a/frontend/src/components/SiteFooter.tsx +++ b/frontend/src/components/SiteFooter.tsx @@ -1,8 +1,9 @@ -import { useHealth } from "src/hooks"; +import { useCheckVersion, useHealth } from "src/hooks"; import { T } from "src/locale"; export function SiteFooter() { const health = useHealth(); + const { data: versionData } = useCheckVersion(); const getVersion = () => { if (!health.data) { @@ -55,6 +56,19 @@ export function SiteFooter() { {getVersion()}{" "} + {versionData?.updateAvailable && versionData?.latest && ( +
  • + + + +
  • + )} diff --git a/frontend/src/components/SiteHeader.tsx b/frontend/src/components/SiteHeader.tsx index 07d52f98d9..3e4193066b 100644 --- a/frontend/src/components/SiteHeader.tsx +++ b/frontend/src/components/SiteHeader.tsx @@ -25,7 +25,7 @@ export function SiteHeader() { > -
    +
    -
    +
    @@ -70,6 +70,22 @@ export function SiteHeader() {
    +
    + {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: This div is not interactive. */} +
    e.stopPropagation()}> +
    +
    {currentUser?.nickname}
    +
    + +
    +
    +
    + + +
    +
    +
    +
    void) => { }; export function SiteMenu() { - // This is hacky AF. But that's the price of using a non-react UI kit. - const closeMenus = () => { - const navMenus = document.querySelectorAll(".nav-item.dropdown"); - navMenus.forEach((menu) => { - menu.classList.remove("show"); - const dropdown = menu.querySelector(".dropdown-menu"); - if (dropdown) { - dropdown.classList.remove("show"); - } - }); - }; + const closeMenu = () => setTimeout(() => { + const navbarToggler = document.querySelector(".navbar-toggler"); + const navbarMenu = document.querySelector("#navbar-menu"); + if (navbarToggler && navbarMenu?.classList.contains("show")) { + navbarToggler.click(); + } + }, 300); return (
    -