Compare commits

..

27 Commits

Author SHA1 Message Date
jc21
2578105f86 Merge pull request #4907 from NginxProxyManager/develop
v2.13.3
2025-11-11 16:54:38 +10:00
jc21
39c9bbb167 Merge branch 'master' into develop
All checks were successful
Close stale issues and PRs / stale (push) Successful in 18s
2025-11-11 16:06:05 +10:00
jc21
30c2781a02 Merge pull request #4765 from mamasch19/develop
add MC-HOST24 DNS plugin
2025-11-11 16:05:32 +10:00
Jamie Curnow
53e78dcc17 Bump version 2025-11-11 16:01:06 +10:00
jc21
62092b2ddc Merge pull request #4859 from 7heMech/develop
Fix hamburger menu on mobile
2025-11-11 15:37:12 +10:00
Jamie Curnow
2c26ed8b11 Revert "Fix #4831 mobile header menu not working"
This reverts commit 4bd545c88e.
2025-11-11 15:36:46 +10:00
jc21
e3f5cd9a58 Merge pull request #4871 from prospo/develop
chore: Bump certbot-dns-leaseweb to 1.0.3
2025-11-11 15:24:11 +10:00
jc21
fba14817e7 Merge pull request #4894 from eduardpaul/feat-fix-pass_auth-template
Update _access.conf to fix access_list.pass_auth logic
2025-11-11 15:23:22 +10:00
Jamie Curnow
6825a9773b Fix #4854 Added missing forward http code for redirections 2025-11-11 15:17:43 +10:00
Jamie Curnow
8bc3078d87 Fix initial setup user bug, taking the fix from #4836 2025-11-11 14:52:39 +10:00
Jamie Curnow
8aeb2fa661 Fix #4692, #4856 - stick with auto for scheme in db, change it to $scheme when rendering 2025-11-11 14:46:25 +10:00
Jamie Curnow
4bd545c88e Fix #4831 mobile header menu not working 2025-11-11 14:05:26 +10:00
Jamie Curnow
7f0cce944d Relax the email validation in frontend 2025-11-11 08:54:48 +10:00
Jamie Curnow
311d6a1541 Tweaks to CI stack for postgres
All checks were successful
Close stale issues and PRs / stale (push) Successful in 20s
2025-11-10 10:30:16 +10:00
mamasch19
5e7276e65b Add MC-HOST24 DNS plugin configuration
added the MC-HOST24 configuration to the new plugin file
2025-11-09 22:31:48 +01:00
Eduard Paul
2bcb942f93 Update _access.conf to ensure Authorization header remove when pass_auth = false or 0
Fixing prev commit as it's negative logic.
2025-11-09 21:02:18 +01:00
Eduard Paul
b3dac3df08 Update _access.conf to fix access_list.pass_auth logic
Wrong logic to pass auth as header: when disabled (pass_auth=0) credentials are included in Authorization header. However as soon as you enable (pass_auth=1) they are not.
2025-11-09 20:11:33 +01:00
jc21
64c5a863f8 Merge pull request #4878 from NginxProxyManager/develop
v2.13.2
2025-11-09 21:16:26 +10:00
Jamie Curnow
cd94863850 Bump version
All checks were successful
Close stale issues and PRs / stale (push) Successful in 25s
2025-11-09 20:25:10 +10:00
Emil
fd1d33444a chore: Bump certbot-dns-leaseweb to 1.0.3 2025-11-08 14:39:23 +01:00
7heMech
6fa2d6a98a Fix hamburger menu on mobile 2025-11-07 19:34:43 +00:00
Jamie Curnow
3c252db46f Fixes #4844 with more defensive date parsing
All checks were successful
Close stale issues and PRs / stale (push) Successful in 23s
2025-11-07 21:37:22 +10:00
Jamie Curnow
8eba31913f Remove pebble certs, they removed the dockerhub image that had armv7 support.
The ghcr image doesn't have it, so it was causing builds to fail.
2025-11-07 11:18:53 +10:00
Jamie Curnow
e4e3415120 Safer handling of backend date formats
and add frontend testing
2025-11-07 11:15:15 +10:00
Jamie Curnow
a03bb7ebce Remove Jenkinsfile, managed in other repo now 2025-11-07 10:54:21 +10:00
Jamie Curnow
51e25d1a40 Attempt to fix race condition with database instantiation 2025-11-07 09:46:00 +10:00
jc21
e88d55f1d2 Merge pull request #4839 from NginxProxyManager/develop
v2.13.1
2025-11-05 15:40:32 +10:00
30 changed files with 317 additions and 377 deletions

View File

@@ -1 +1 @@
2.13.1
2.13.3

285
Jenkinsfile vendored
View File

@@ -1,285 +0,0 @@
import groovy.transform.Field
@Field
def shOutput = ""
def buildxPushTags = ""
pipeline {
agent {
label 'docker-multiarch'
}
options {
buildDiscarder(logRotator(numToKeepStr: '5'))
disableConcurrentBuilds()
ansiColor('xterm')
}
environment {
IMAGE = 'nginx-proxy-manager'
BUILD_VERSION = getVersion()
MAJOR_VERSION = '2'
BRANCH_LOWER = "${BRANCH_NAME.toLowerCase().replaceAll('\\\\', '-').replaceAll('/', '-').replaceAll('\\.', '-')}"
BUILDX_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}"
COMPOSE_INTERACTIVE_NO_CLI = 1
}
stages {
stage('Environment') {
parallel {
stage('Master') {
when {
branch 'master'
}
steps {
script {
buildxPushTags = "-t docker.io/jc21/${IMAGE}:${BUILD_VERSION} -t docker.io/jc21/${IMAGE}:${MAJOR_VERSION} -t docker.io/jc21/${IMAGE}:latest"
}
}
}
stage('Other') {
when {
not {
branch 'master'
}
}
steps {
script {
// Defaults to the Branch name, which is applies to all branches AND pr's
buildxPushTags = "-t docker.io/nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}"
}
}
}
stage('Versions') {
steps {
sh 'cat frontend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge frontend/package.json'
sh 'echo -e "\\E[1;36mFrontend Version is:\\E[1;33m $(cat frontend/package.json | jq -r .version)\\E[0m"'
sh 'cat backend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge backend/package.json'
sh 'echo -e "\\E[1;36mBackend Version is:\\E[1;33m $(cat backend/package.json | jq -r .version)\\E[0m"'
sh 'sed -i -E "s/(version-)[0-9]+\\.[0-9]+\\.[0-9]+(-green)/\\1${BUILD_VERSION}\\2/" README.md'
}
}
stage('Docker Login') {
steps {
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
sh 'docker login -u "${duser}" -p "${dpass}"'
}
}
}
}
}
stage('Builds') {
parallel {
stage('Project') {
steps {
script {
// Frontend and Backend
def shStatusCode = sh(label: 'Checking and Building', returnStatus: true, script: '''
set -e
./scripts/ci/frontend-build > ${WORKSPACE}/tmp-sh-build 2>&1
./scripts/ci/test-and-build > ${WORKSPACE}/tmp-sh-build 2>&1
''')
shOutput = readFile "${env.WORKSPACE}/tmp-sh-build"
if (shStatusCode != 0) {
error "${shOutput}"
}
}
}
post {
always {
sh 'rm -f ${WORKSPACE}/tmp-sh-build'
}
failure {
npmGithubPrComment("CI Error:\n\n```\n${shOutput}\n```", true)
}
}
}
stage('Docs') {
steps {
dir(path: 'docs') {
sh 'yarn install'
sh 'yarn build'
}
}
}
}
}
stage('Test Sqlite') {
environment {
COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_sqlite"
COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.sqlite.yml'
}
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh 'rm -rf ./test/results/junit/*'
sh './scripts/ci/fulltest-cypress'
}
post {
always {
// Dumps to analyze later
sh 'mkdir -p debug/sqlite'
sh 'docker logs $(docker compose ps --all -q fullstack) > debug/sqlite/docker_fullstack.log 2>&1'
sh 'docker logs $(docker compose ps --all -q stepca) > debug/sqlite/docker_stepca.log 2>&1'
sh 'docker logs $(docker compose ps --all -q pdns) > debug/sqlite/docker_pdns.log 2>&1'
sh 'docker logs $(docker compose ps --all -q pdns-db) > debug/sqlite/docker_pdns-db.log 2>&1'
sh 'docker logs $(docker compose ps --all -q dnsrouter) > debug/sqlite/docker_dnsrouter.log 2>&1'
junit 'test/results/junit/*'
sh 'docker compose down --remove-orphans --volumes -t 30 || true'
}
unstable {
dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
}
}
}
}
stage('Test Mysql') {
environment {
COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_mysql"
COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.mysql.yml'
}
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh 'rm -rf ./test/results/junit/*'
sh './scripts/ci/fulltest-cypress'
}
post {
always {
// Dumps to analyze later
sh 'mkdir -p debug/mysql'
sh 'docker logs $(docker compose ps --all -q fullstack) > debug/mysql/docker_fullstack.log 2>&1'
sh 'docker logs $(docker compose ps --all -q stepca) > debug/mysql/docker_stepca.log 2>&1'
sh 'docker logs $(docker compose ps --all -q pdns) > debug/mysql/docker_pdns.log 2>&1'
sh 'docker logs $(docker compose ps --all -q pdns-db) > debug/mysql/docker_pdns-db.log 2>&1'
sh 'docker logs $(docker compose ps --all -q dnsrouter) > debug/mysql/docker_dnsrouter.log 2>&1'
junit 'test/results/junit/*'
sh 'docker compose down --remove-orphans --volumes -t 30 || true'
}
unstable {
dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
}
}
}
}
stage('Test Postgres') {
environment {
COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_postgres"
COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.postgres.yml'
}
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh 'rm -rf ./test/results/junit/*'
sh './scripts/ci/fulltest-cypress'
}
post {
always {
// Dumps to analyze later
sh 'mkdir -p debug/postgres'
sh 'docker logs $(docker compose ps --all -q fullstack) > debug/postgres/docker_fullstack.log 2>&1'
sh 'docker logs $(docker compose ps --all -q stepca) > debug/postgres/docker_stepca.log 2>&1'
sh 'docker logs $(docker compose ps --all -q pdns) > debug/postgres/docker_pdns.log 2>&1'
sh 'docker logs $(docker compose ps --all -q pdns-db) > debug/postgres/docker_pdns-db.log 2>&1'
sh 'docker logs $(docker compose ps --all -q dnsrouter) > debug/postgres/docker_dnsrouter.log 2>&1'
sh 'docker logs $(docker compose ps --all -q db-postgres) > debug/postgres/docker_db-postgres.log 2>&1'
sh 'docker logs $(docker compose ps --all -q authentik) > debug/postgres/docker_authentik.log 2>&1'
sh 'docker logs $(docker compose ps --all -q authentik-redis) > debug/postgres/docker_authentik-redis.log 2>&1'
sh 'docker logs $(docke rcompose ps --all -q authentik-ldap) > debug/postgres/docker_authentik-ldap.log 2>&1'
junit 'test/results/junit/*'
sh 'docker compose down --remove-orphans --volumes -t 30 || true'
}
unstable {
dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
}
}
}
}
stage('MultiArch Build') {
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh "./scripts/buildx --push ${buildxPushTags}"
}
}
stage('Docs / Comment') {
parallel {
stage('Docs Job') {
when {
allOf {
branch pattern: "^(develop|master)\$", comparator: "REGEXP"
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
}
steps {
build wait: false, job: 'nginx-proxy-manager-docs', parameters: [string(name: 'docs_branch', value: "$BRANCH_NAME")]
}
}
stage('PR Comment') {
when {
allOf {
changeRequest()
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
}
steps {
script {
npmGithubPrComment("""Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/nginxproxymanager/${IMAGE}-dev):
```
nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}
```
> [!NOTE]
> Ensure you backup your NPM instance before testing this image! Especially if there are database changes.
> This is a different docker image namespace than the official image.
> [!WARNING]
> Changes and additions to DNS Providers require verification by at least 2 members of the community!
""", true)
}
}
}
}
}
}
post {
always {
sh 'echo Reverting ownership'
sh 'docker run --rm -v "$(pwd):/data" jc21/ci-tools chown -R "$(id -u):$(id -g)" /data'
printResult(true)
}
failure {
archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true)
}
unstable {
archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true)
}
}
}
def getVersion() {
ver = sh(script: 'cat .version', returnStdout: true)
return ver.trim()
}
def getCommit() {
ver = sh(script: 'git log -n 1 --format=%h', returnStdout: true)
return ver.trim()
}

View File

@@ -1,7 +1,7 @@
<p align="center">
<img src="https://nginxproxymanager.com/github.png">
<br><br>
<img src="https://img.shields.io/badge/version-2.13.1-green.svg?style=for-the-badge">
<img src="https://img.shields.io/badge/version-2.13.3-green.svg?style=for-the-badge">
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a>

View File

@@ -370,7 +370,7 @@
"leaseweb": {
"name": "LeaseWeb",
"package_name": "certbot-dns-leaseweb",
"version": "~=1.0.1",
"version": "~=1.0.3",
"dependencies": "",
"credentials": "dns_leaseweb_api_token = 01234556789",
"full_plugin_name": "dns-leaseweb"
@@ -399,6 +399,14 @@
"credentials": "dns_luadns_email = user@example.com\ndns_luadns_token = 0123456789abcdef0123456789abcdef",
"full_plugin_name": "dns-luadns"
},
"mchost24": {
"name": "MC-HOST24",
"package_name": "certbot-dns-mchost24",
"version": "",
"dependencies": "",
"credentials": "# Obtain API token using https://github.com/JoeJoeTV/mchost24-api-python\ndns_mchost24_api_token=<insert obtained API token here>",
"full_plugin_name": "dns-mchost24"
},
"mijnhost": {
"name": "mijn.host",
"package_name": "certbot-dns-mijn-host",

View File

@@ -216,6 +216,11 @@ const internalNginx = {
}
}
// For redirection hosts, if the scheme is not http or https, set it to $scheme
if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) {
host.forward_scheme = "$scheme";
}
if (host.locations) {
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
origLocations = [].concat(host.locations);

View File

@@ -0,0 +1,50 @@
import { migrate as logger } from "../logger.js";
const migrateName = "redirect_auto_scheme";
/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema
.table("redirection_host", async (table) => {
// change the column default from $scheme to auto
await table.string("forward_scheme").notNull().defaultTo("auto").alter();
await knex('redirection_host')
.where('forward_scheme', '$scheme')
.update({ forward_scheme: 'auto' });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);
return knex.schema
.table("redirection_host", async (table) => {
await table.string("forward_scheme").notNull().defaultTo("$scheme").alter();
await knex('redirection_host')
.where('forward_scheme', 'auto')
.update({ forward_scheme: '$scheme' });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
});
};
export { up, down };

View File

@@ -37,7 +37,7 @@ const setupDefaultUser = async () => {
const data = {
is_deleted: 0,
email: email,
email: initialAdminEmail,
name: "Administrator",
nickname: "Admin",
avatar: "",
@@ -53,7 +53,7 @@ const setupDefaultUser = async () => {
.insert({
user_id: user.id,
type: "password",
secret: password,
secret: initialAdminPassword,
meta: {},
});

View File

@@ -4,7 +4,7 @@
auth_basic "Authorization required";
auth_basic_user_file /data/access/{{ access_list_id }};
{% if access_list.pass_auth == 0 or access_list.pass_auth == true %}
{% if access_list.pass_auth == 0 or access_list.pass_auth == false %}
proxy_set_header Authorization "";
{% endif %}

View File

@@ -1847,9 +1847,9 @@ netmask@^2.0.2:
integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==
node-abi@^3.3.0:
version "3.80.0"
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.80.0.tgz#d7390951f27caa129cceeec01e1c20fc9f07581c"
integrity sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==
version "3.78.0"
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.78.0.tgz#fd0ecbd0aa89857b98da06bd3909194abb0821ba"
integrity sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==
dependencies:
semver "^7.3.5"

View File

@@ -1,6 +1,6 @@
AUTHENTIK_SECRET_KEY=gl8woZe8L6IIX8SC0c5Ocsj0xPkX5uJo5DVZCFl+L/QGbzuplfutYuua2ODNLEiDD3aFd9H2ylJmrke0
AUTHENTIK_REDIS__HOST=authentik-redis
AUTHENTIK_POSTGRESQL__HOST=db-postgres
AUTHENTIK_POSTGRESQL__HOST=pgdb.internal
AUTHENTIK_POSTGRESQL__USER=authentik
AUTHENTIK_POSTGRESQL__NAME=authentik
AUTHENTIK_POSTGRESQL__PASSWORD=07EKS5NLI6Tpv68tbdvrxfvj

View File

@@ -6,7 +6,7 @@ services:
fullstack:
environment:
DB_POSTGRES_HOST: "db-postgres"
DB_POSTGRES_HOST: "pgdb.internal"
DB_POSTGRES_PORT: "5432"
DB_POSTGRES_USER: "npm"
DB_POSTGRES_PASSWORD: "npmpass"
@@ -27,7 +27,9 @@ services:
- psql_vol:/var/lib/postgresql/data
- ./ci/postgres:/docker-entrypoint-initdb.d
networks:
- fulltest
fulltest:
aliases:
- pgdb.internal
authentik-redis:
image: "redis:alpine"
@@ -41,6 +43,8 @@ services:
timeout: 3s
volumes:
- redis_vol:/data
networks:
- fulltest
authentik:
image: ghcr.io/goauthentik/server:2024.10.1
@@ -51,6 +55,8 @@ services:
depends_on:
- authentik-redis
- db-postgres
networks:
- fulltest
authentik-worker:
image: ghcr.io/goauthentik/server:2024.10.1
@@ -61,6 +67,8 @@ services:
depends_on:
- authentik-redis
- db-postgres
networks:
- fulltest
authentik-ldap:
image: ghcr.io/goauthentik/ldap:2024.10.1
@@ -71,6 +79,8 @@ services:
restart: unless-stopped
depends_on:
- authentik
networks:
- fulltest
volumes:
psql_vol:

View File

@@ -3,31 +3,30 @@
# This is a base compose file, it should be extended with a
# docker-compose.ci.*.yml file
services:
fullstack:
image: "${IMAGE}:${BRANCH_LOWER}-ci-${BUILD_NUMBER}"
environment:
TZ: "${TZ:-Australia/Brisbane}"
DEBUG: 'true'
CI: 'true'
DEBUG: "true"
CI: "true"
FORCE_COLOR: 1
# Required for DNS Certificate provisioning in CI
LE_SERVER: 'https://ca.internal/acme/acme/directory'
REQUESTS_CA_BUNDLE: '/etc/ssl/certs/NginxProxyManager.crt'
LE_SERVER: "https://ca.internal/acme/acme/directory"
REQUESTS_CA_BUNDLE: "/etc/ssl/certs/NginxProxyManager.crt"
volumes:
- 'npm_data_ci:/data'
- 'npm_le_ci:/etc/letsencrypt'
- './dev/letsencrypt.ini:/etc/letsencrypt.ini:ro'
- './dev/resolv.conf:/etc/resolv.conf:ro'
- '/etc/localtime:/etc/localtime:ro'
- "npm_data_ci:/data"
- "npm_le_ci:/etc/letsencrypt"
- "./dev/letsencrypt.ini:/etc/letsencrypt.ini:ro"
- "./dev/resolv.conf:/etc/resolv.conf:ro"
- "/etc/localtime:/etc/localtime:ro"
healthcheck:
test: ["CMD", "/usr/bin/check-health"]
interval: 10s
timeout: 3s
expose:
- '80-81/tcp'
- '443/tcp'
- '1500-1503/tcp'
- "80-81/tcp"
- "443/tcp"
- "1500-1503/tcp"
networks:
fulltest:
aliases:
@@ -38,8 +37,8 @@ services:
stepca:
image: jc21/testca
volumes:
- './dev/resolv.conf:/etc/resolv.conf:ro'
- '/etc/localtime:/etc/localtime:ro'
- "./dev/resolv.conf:/etc/resolv.conf:ro"
- "/etc/localtime:/etc/localtime:ro"
networks:
fulltest:
aliases:
@@ -48,18 +47,18 @@ services:
pdns:
image: pschiffe/pdns-mysql:4.8
volumes:
- '/etc/localtime:/etc/localtime:ro'
- "/etc/localtime:/etc/localtime:ro"
environment:
PDNS_master: 'yes'
PDNS_api: 'yes'
PDNS_api_key: 'npm'
PDNS_webserver: 'yes'
PDNS_webserver_address: '0.0.0.0'
PDNS_webserver_password: 'npm'
PDNS_webserver-allow-from: '127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8'
PDNS_version_string: 'anonymous'
PDNS_master: "yes"
PDNS_api: "yes"
PDNS_api_key: "npm"
PDNS_webserver: "yes"
PDNS_webserver_address: "0.0.0.0"
PDNS_webserver_password: "npm"
PDNS_webserver-allow-from: "127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8"
PDNS_version_string: "anonymous"
PDNS_default_ttl: 1500
PDNS_allow_axfr_ips: '127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8'
PDNS_allow_axfr_ips: "127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8"
PDNS_gmysql_host: pdns-db
PDNS_gmysql_port: 3306
PDNS_gmysql_user: pdns
@@ -76,14 +75,14 @@ services:
pdns-db:
image: mariadb
environment:
MYSQL_ROOT_PASSWORD: 'pdns'
MYSQL_DATABASE: 'pdns'
MYSQL_USER: 'pdns'
MYSQL_PASSWORD: 'pdns'
MYSQL_ROOT_PASSWORD: "pdns"
MYSQL_DATABASE: "pdns"
MYSQL_USER: "pdns"
MYSQL_PASSWORD: "pdns"
volumes:
- 'pdns_mysql_vol:/var/lib/mysql'
- '/etc/localtime:/etc/localtime:ro'
- './dev/pdns-db.sql:/docker-entrypoint-initdb.d/01_init.sql:ro'
- "pdns_mysql_vol:/var/lib/mysql"
- "/etc/localtime:/etc/localtime:ro"
- "./dev/pdns-db.sql:/docker-entrypoint-initdb.d/01_init.sql:ro"
networks:
- fulltest
@@ -100,12 +99,12 @@ services:
context: ../
dockerfile: test/cypress/Dockerfile
environment:
HTTP_PROXY: 'squid:3128'
HTTPS_PROXY: 'squid:3128'
HTTP_PROXY: "squid:3128"
HTTPS_PROXY: "squid:3128"
volumes:
- 'cypress_logs:/test/results'
- './dev/resolv.conf:/etc/resolv.conf:ro'
- '/etc/localtime:/etc/localtime:ro'
- "cypress_logs:/test/results"
- "./dev/resolv.conf:/etc/resolv.conf:ro"
- "/etc/localtime:/etc/localtime:ro"
command: cypress run --browser chrome --config-file=cypress/config/ci.js
networks:
- fulltest
@@ -113,9 +112,9 @@ services:
squid:
image: ubuntu/squid
volumes:
- './dev/squid.conf:/etc/squid/squid.conf:ro'
- './dev/resolv.conf:/etc/resolv.conf:ro'
- '/etc/localtime:/etc/localtime:ro'
- "./dev/squid.conf:/etc/squid/squid.conf:ro"
- "./dev/resolv.conf:/etc/resolv.conf:ro"
- "/etc/localtime:/etc/localtime:ro"
networks:
- fulltest

View File

@@ -32,7 +32,7 @@ services:
# DB_MYSQL_PASSWORD: 'npm'
# DB_MYSQL_NAME: 'npm'
# db-postgres:
DB_POSTGRES_HOST: "db-postgres"
DB_POSTGRES_HOST: "pgdb.internal"
DB_POSTGRES_PORT: "5432"
DB_POSTGRES_USER: "npm"
DB_POSTGRES_PASSWORD: "npmpass"
@@ -81,8 +81,6 @@ services:
db-postgres:
image: postgres:17
container_name: npm2dev.db-postgres
networks:
- nginx_proxy_manager
environment:
POSTGRES_USER: "npm"
POSTGRES_PASSWORD: "npmpass"
@@ -90,6 +88,10 @@ services:
volumes:
- psql_data:/var/lib/postgresql/data
- ./ci/postgres:/docker-entrypoint-initdb.d
networks:
nginx_proxy_manager:
aliases:
- pgdb.internal
stepca:
image: jc21/testca

View File

@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
import type { AccessList } from "src/api/backend";
import { useAccessLists } from "src/hooks";
import { DateTimeFormat, intl, T } from "src/locale";
import { formatDateTime, intl, T } from "src/locale";
interface AccessOption {
readonly value: number;
@@ -48,7 +48,7 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
{
users: item?.items?.length,
rules: item?.clients?.length,
date: item?.createdOn ? DateTimeFormat(item?.createdOn) : "N/A",
date: item?.createdOn ? formatDateTime(item?.createdOn) : "N/A",
},
),
icon: <IconLock size={14} className="text-lime" />,

View File

@@ -3,7 +3,7 @@ import { Field, useFormikContext } from "formik";
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
import type { Certificate } from "src/api/backend";
import { useCertificates } from "src/hooks";
import { DateTimeFormat, intl, T } from "src/locale";
import { formatDateTime, intl, T } from "src/locale";
interface CertOption {
readonly value: number | "new";
@@ -75,7 +75,7 @@ export function SSLCertificateField({
data?.map((cert: Certificate) => ({
value: cert.id,
label: cert.niceName,
subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} &mdash; ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? DateTimeFormat(cert.expiresOn) : "N/A" })}`,
subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`,
icon: <IconShield size={14} className="text-pink" />,
})) || [];

View File

@@ -190,7 +190,7 @@ export function SiteMenu() {
return (
<header className="navbar-expand-md">
<div className="collapse navbar-collapse">
<div className="collapse navbar-collapse" id="navbar-menu">
<div className="navbar">
<div className="container-xl">
<div className="row flex-column flex-md-row flex-fill align-items-center">

View File

@@ -1,6 +1,6 @@
import cn from "classnames";
import { differenceInDays, isPast, parseISO } from "date-fns";
import { DateTimeFormat } from "src/locale";
import { differenceInDays, isPast } from "date-fns";
import { formatDateTime, parseDate } from "src/locale";
interface Props {
value: string;
@@ -8,11 +8,12 @@ interface Props {
highlistNearlyExpired?: boolean;
}
export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: Props) {
const dateIsPast = isPast(parseISO(value));
const days = differenceInDays(parseISO(value), new Date());
const d = parseDate(value);
const dateIsPast = d ? isPast(d) : false;
const days = d ? differenceInDays(d, new Date()) : 0;
const cl = cn({
"text-danger": highlightPast && dateIsPast,
"text-warning": highlistNearlyExpired && !dateIsPast && days <= 30 && days >= 0,
});
return <span className={cl}>{DateTimeFormat(value)}</span>;
return <span className={cl}>{formatDateTime(value)}</span>;
}

View File

@@ -1,6 +1,6 @@
import cn from "classnames";
import type { ReactNode } from "react";
import { DateTimeFormat, T } from "src/locale";
import { formatDateTime, T } from "src/locale";
interface Props {
domains: string[];
@@ -53,7 +53,7 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color
<div className="font-weight-medium">{...elms}</div>
{createdOn ? (
<div className="text-secondary mt-1">
<T id="created-on" data={{ date: DateTimeFormat(createdOn) }} />
<T id="created-on" data={{ date: formatDateTime(createdOn) }} />
</div>
) : null}
</div>

View File

@@ -1,7 +1,7 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react";
import cn from "classnames";
import type { AuditLog } from "src/api/backend";
import { DateTimeFormat, T } from "src/locale";
import { formatDateTime, T } from "src/locale";
const getEventValue = (event: AuditLog) => {
switch (event.objectType) {
@@ -73,7 +73,7 @@ export function EventFormatter({ row }: Props) {
<T id={`object.event.${row.action}`} tData={{ object: row.objectType }} />
&nbsp; &mdash; <span className="badge">{getEventValue(row)}</span>
</div>
<div className="text-secondary mt-1">{DateTimeFormat(row.createdOn)}</div>
<div className="text-secondary mt-1">{formatDateTime(row.createdOn)}</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { DateTimeFormat, T } from "src/locale";
import { formatDateTime, T } from "src/locale";
interface Props {
value: string;
@@ -13,7 +13,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
</div>
{createdOn ? (
<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
<T id={disabled ? "disabled" : "created-on"} data={{ date: DateTimeFormat(createdOn) }} />
<T id={disabled ? "disabled" : "created-on"} data={{ date: formatDateTime(createdOn) }} />
</div>
) : null}
</div>

View File

@@ -1,15 +0,0 @@
import { intlFormat, parseISO } from "date-fns";
const DateTimeFormat = (isoDate: string) =>
intlFormat(parseISO(isoDate), {
weekday: "long",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: true,
});
export { DateTimeFormat };

View File

@@ -30,13 +30,20 @@ const getLocale = (short = false) => {
if (short) {
return loc.slice(0, 2);
}
// finally, fallback
if (!loc) {
loc = "en";
}
return loc;
};
const cache = createIntlCache();
const initialMessages = loadMessages(getLocale());
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
let intl = createIntl(
{ locale: getLocale(), messages: initialMessages },
cache,
);
const changeLocale = (locale: string): void => {
const messages = loadMessages(locale);
@@ -76,4 +83,12 @@ const T = ({
);
};
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
export {
localeOptions,
getFlagCodeForLocale,
getLocale,
createIntl,
changeLocale,
intl,
T,
};

View File

@@ -0,0 +1,74 @@
import { formatDateTime } from "src/locale";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
describe("DateFormatter", () => {
// Keep a reference to the real Intl to restore later
const RealIntl = global.Intl;
const desiredTimeZone = "Europe/London";
const desiredLocale = "en-GB";
beforeAll(() => {
// Ensure Node-based libs using TZ behave deterministically
try {
process.env.TZ = desiredTimeZone;
} catch {
// ignore if not available
}
// Mock Intl.DateTimeFormat so formatting is stable regardless of host
const MockedDateTimeFormat = class extends RealIntl.DateTimeFormat {
constructor(_locales?: string | string[], options?: Intl.DateTimeFormatOptions) {
super(desiredLocale, {
...options,
timeZone: desiredTimeZone,
});
}
} as unknown as typeof Intl.DateTimeFormat;
global.Intl = {
...RealIntl,
DateTimeFormat: MockedDateTimeFormat,
};
});
afterAll(() => {
// Restore original Intl after tests
global.Intl = RealIntl;
});
it("format date from iso date", () => {
const value = "2024-01-01T00:00:00.000Z";
const text = formatDateTime(value);
expect(text).toBe("Monday, 01/01/2024, 12:00:00 am");
});
it("format date from unix timestamp number", () => {
const value = 1762476112;
const text = formatDateTime(value);
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
});
it("format date from unix timestamp string", () => {
const value = "1762476112";
const text = formatDateTime(value);
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
});
it("catch bad format from string", () => {
const value = "this is not a good date";
const text = formatDateTime(value);
expect(text).toBe("this is not a good date");
});
it("catch bad format from number", () => {
const value = -100;
const text = formatDateTime(value);
expect(text).toBe("-100");
});
it("catch bad format from number as string", () => {
const value = "-100";
const text = formatDateTime(value);
expect(text).toBe("-100");
});
});

View File

@@ -0,0 +1,42 @@
import { fromUnixTime, intlFormat, parseISO } from "date-fns";
const isUnixTimestamp = (value: unknown): boolean => {
if (typeof value !== "number" && typeof value !== "string") return false;
const num = Number(value);
if (!Number.isFinite(num)) return false;
// Check plausible Unix timestamp range: from 1970 to ~year 3000
// Support both seconds and milliseconds
if (num > 0 && num < 10000000000) return true; // seconds (<= 10 digits)
if (num >= 10000000000 && num < 32503680000000) return true; // milliseconds (<= 13 digits)
return false;
};
const parseDate = (value: string | number): Date | null => {
if (typeof value !== "number" && typeof value !== "string") return null;
try {
return isUnixTimestamp(value) ? fromUnixTime(+value) : parseISO(`${value}`);
} catch {
return null;
}
};
const formatDateTime = (value: string | number): string => {
const d = parseDate(value);
if (!d) return `${value}`;
try {
return intlFormat(d, {
weekday: "long",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: true,
});
} catch {
return `${value}`;
}
};
export { formatDateTime, parseDate, isUnixTimestamp };

View File

@@ -1,2 +1,2 @@
export * from "./DateTimeFormat";
export * from "./IntlProvider";
export * from "./Utils";

View File

@@ -169,6 +169,7 @@
"public": "Public",
"redirection-host": "Redirection Host",
"redirection-host.forward-domain": "Forward Domain",
"redirection-host.forward-http-code": "HTTP Code",
"redirection-hosts": "Redirection Hosts",
"redirection-hosts.count": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}",
"role.admin": "Administrator",

View File

@@ -509,6 +509,9 @@
"redirection-host.forward-domain": {
"defaultMessage": "Forward Domain"
},
"redirection-host.forward-http-code": {
"defaultMessage": "HTTP Code"
},
"redirection-hosts": {
"defaultMessage": "Redirection Hosts"
},

View File

@@ -162,7 +162,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
<option value="$scheme">Auto</option>
<option value="auto">Auto</option>
<option value="http">http</option>
<option value="https">https</option>
</select>
@@ -212,6 +212,36 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
</Field>
</div>
</div>
<Field name="forwardHttpCode">
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="forwardHttpCode">
<T id="redirection-host.forward-http-code" />
</label>
<select
id="forwardHttpCode"
className={`form-control ${form.errors.forwardHttpCode && form.touched.forwardHttpCode ? "is-invalid" : ""}`}
required
{...field}
>
<option value="300">300 Multiple choices</option>
<option value="301">301 Moved permanently</option>
<option value="302">302 Moved temporarily</option>
<option value="303">303 See other</option>
<option value="307">307 Temporary redirect</option>
<option value="308">308 Permanent redirect</option>
</select>
{form.errors.forwardHttpCode ? (
<div className="invalid-feedback">
{form.errors.forwardHttpCode &&
form.touched.forwardHttpCode
? form.errors.forwardHttpCode
: null}
</div>
) : null}
</div>
)}
</Field>
<div className="my-3">
<h4 className="py-2">
<T id="options" />

View File

@@ -44,7 +44,7 @@ const validateEmail = () => {
if (!value.length) {
return intl.formatMessage({ id: "error.required" });
}
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+$/i.test(value)) {
return intl.formatMessage({ id: "error.invalid-email" });
}
};

View File

@@ -16,7 +16,7 @@ if hash docker 2>/dev/null; then
-e NODE_OPTIONS=--openssl-legacy-provider \
-v "$(pwd)/frontend:/app/frontend" \
-w /app/frontend "${DOCKER_IMAGE}" \
sh -c "yarn install && yarn lint && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
sh -c "yarn install && yarn lint && yarn vitest run && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
echo -e "${BLUE} ${GREEN}Building Frontend Complete${RESET}"
else