Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
3095cff7d9 | |||
6d8f5aa3a7 | |||
27a06850ff | |||
dce6423c85 | |||
d79fcbf447 | |||
631d9ae4eb | |||
0ac349ba67 | |||
1b0563a4a6 | |||
1db2a29d49 | |||
14e62a0830 | |||
2280a61c2b | |||
f3e6f64c0c | |||
d04b7a0d88 | |||
71dfd5d8f8 | |||
133d66c2fe | |||
6f1d38a0e2 | |||
aad9ecde6b | |||
ae9324295c | |||
0acec1105b | |||
5a9a716ca6 |
355
Jenkinsfile
vendored
355
Jenkinsfile
vendored
@ -6,13 +6,16 @@ pipeline {
|
||||
agent any
|
||||
environment {
|
||||
IMAGE = "nginx-proxy-manager"
|
||||
BASE_IMAGE = "jc21/nginx-proxy-manager-base"
|
||||
TEMP_IMAGE = "nginx-proxy-manager-build_${BUILD_NUMBER}"
|
||||
TEMP_IMAGE_ARM = "nginx-proxy-manager-arm-build_${BUILD_NUMBER}"
|
||||
TEMP_IMAGE_ARM64 = "nginx-proxy-manager-arm64-build_${BUILD_NUMBER}"
|
||||
BASE_IMAGE = "jc21/${IMAGE}-base"
|
||||
TEMP_IMAGE = "${IMAGE}-build_${BUILD_NUMBER}"
|
||||
TAG_VERSION = getPackageVersion()
|
||||
MAJOR_VERSION = "2"
|
||||
BRANCH_LOWER = "${BRANCH_NAME.toLowerCase()}"
|
||||
// Architectures:
|
||||
AMD64_TAG = "amd64"
|
||||
ARMV6_TAG = "armv6l"
|
||||
ARMV7_TAG = "armv7l"
|
||||
ARM64_TAG = "arm64"
|
||||
}
|
||||
stages {
|
||||
stage('Build PR') {
|
||||
@ -29,19 +32,19 @@ pipeline {
|
||||
sh 'docker run --rm -v $(pwd):/data ${DOCKER_CI_TOOLS} node-prune'
|
||||
|
||||
// Docker Build
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE} .'
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE}-${AMD64_TAG} .'
|
||||
|
||||
// Dockerhub
|
||||
sh 'docker tag ${TEMP_IMAGE} docker.io/jc21/${IMAGE}:github-${BRANCH_LOWER}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${AMD64_TAG} docker.io/jc21/${IMAGE}:github-${BRANCH_LOWER}-${AMD64_TAG}'
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}'"
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:github-${BRANCH_LOWER}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:github-${BRANCH_LOWER}-${AMD64_TAG}'
|
||||
}
|
||||
|
||||
sh 'docker rmi ${TEMP_IMAGE}'
|
||||
sh 'docker rmi ${TEMP_IMAGE}-${AMD64_TAG}'
|
||||
|
||||
script {
|
||||
def comment = pullRequest.comment("Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/jc21/${IMAGE}) as `jc21/${IMAGE}:github-${BRANCH_LOWER}`")
|
||||
def comment = pullRequest.comment("Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/jc21/${IMAGE}) as `jc21/${IMAGE}:github-${BRANCH_LOWER}-${AMD64_TAG}`")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -60,31 +63,30 @@ pipeline {
|
||||
sh 'docker run --rm -v $(pwd):/data ${DOCKER_CI_TOOLS} node-prune'
|
||||
|
||||
// Docker Build
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE} .'
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE}-${AMD64_TAG} .'
|
||||
|
||||
// Dockerhub
|
||||
sh 'docker tag ${TEMP_IMAGE} docker.io/jc21/${IMAGE}:develop'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${AMD64_TAG} docker.io/jc21/${IMAGE}:develop-${AMD64_TAG}'
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}'"
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:develop'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:develop-${AMD64_TAG}'
|
||||
}
|
||||
|
||||
// Private Registry
|
||||
sh 'docker tag ${TEMP_IMAGE} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:develop'
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-private-registry', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}' ${DOCKER_PRIVATE_REGISTRY}"
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:develop'
|
||||
}
|
||||
|
||||
sh 'docker rmi ${TEMP_IMAGE}'
|
||||
sh 'docker rmi ${TEMP_IMAGE}-${AMD64_TAG}'
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Build Master') {
|
||||
when {
|
||||
branch 'master'
|
||||
}
|
||||
parallel {
|
||||
stage('x86_64') {
|
||||
when {
|
||||
branch 'master'
|
||||
// ========================
|
||||
// amd64
|
||||
// ========================
|
||||
stage('amd64') {
|
||||
agent {
|
||||
label 'amd64'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
@ -96,131 +98,247 @@ pipeline {
|
||||
sh 'docker run --rm -v $(pwd):/data ${DOCKER_CI_TOOLS} node-prune'
|
||||
|
||||
// Docker Build
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE} .'
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE}-${AMD64_TAG} .'
|
||||
|
||||
// Dockerhub
|
||||
sh 'docker tag ${TEMP_IMAGE} docker.io/jc21/${IMAGE}:${TAG_VERSION}'
|
||||
sh 'docker tag ${TEMP_IMAGE} docker.io/jc21/${IMAGE}:${MAJOR_VERSION}'
|
||||
sh 'docker tag ${TEMP_IMAGE} docker.io/jc21/${IMAGE}:latest'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${AMD64_TAG} docker.io/jc21/${IMAGE}:${TAG_VERSION}-${AMD64_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${AMD64_TAG} docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${AMD64_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${AMD64_TAG} docker.io/jc21/${IMAGE}:latest-${AMD64_TAG}'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}'"
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${TAG_VERSION}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${MAJOR_VERSION}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:latest'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${TAG_VERSION}-${AMD64_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${AMD64_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:latest-${AMD64_TAG}'
|
||||
}
|
||||
|
||||
// Private Registry
|
||||
sh 'docker tag ${TEMP_IMAGE} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${TAG_VERSION}'
|
||||
sh 'docker tag ${TEMP_IMAGE} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${MAJOR_VERSION}'
|
||||
sh 'docker tag ${TEMP_IMAGE} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:latest'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-private-registry', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}' ${DOCKER_PRIVATE_REGISTRY}"
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${TAG_VERSION}'
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${MAJOR_VERSION}'
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:latest'
|
||||
}
|
||||
|
||||
sh 'docker rmi ${TEMP_IMAGE}'
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('armhf') {
|
||||
when {
|
||||
branch 'master'
|
||||
}
|
||||
agent {
|
||||
label 'armhf'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
// Codebase
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE}:armhf yarn install'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE}:armhf npm run-script build'
|
||||
sh 'rm -rf node_modules'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE}:armhf yarn install --prod'
|
||||
|
||||
// Docker Build
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE_ARM} -f Dockerfile.armhf .'
|
||||
|
||||
// Dockerhub
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM} docker.io/jc21/${IMAGE}:${TAG_VERSION}-armhf'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM} docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-armhf'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM} docker.io/jc21/${IMAGE}:latest-armhf'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}'"
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${TAG_VERSION}-armhf'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-armhf'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:latest-armhf'
|
||||
}
|
||||
|
||||
// Private Registry
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${TAG_VERSION}-armhf'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${MAJOR_VERSION}-armhf'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:latest-armhf'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-private-registry', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}' ${DOCKER_PRIVATE_REGISTRY}"
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${TAG_VERSION}-armhf'
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${MAJOR_VERSION}-armhf'
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:latest-armhf'
|
||||
}
|
||||
|
||||
sh 'docker rmi ${TEMP_IMAGE_ARM}'
|
||||
sh 'docker rmi ${TEMP_IMAGE}-${AMD64_TAG}'
|
||||
}
|
||||
}
|
||||
}
|
||||
// ========================
|
||||
// arm64
|
||||
// ========================
|
||||
stage('arm64') {
|
||||
when {
|
||||
branch 'master'
|
||||
}
|
||||
agent {
|
||||
label 'arm64'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
// Codebase
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE}:arm64 yarn install'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE}:arm64 npm run-script build'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} yarn install'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} npm run-script build'
|
||||
sh 'sudo rm -rf node_modules'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE}:arm64 yarn install --prod'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} yarn install --prod'
|
||||
|
||||
// Docker Build
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE_ARM64} -f Dockerfile.arm64 .'
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE}-${ARM64_TAG} -f Dockerfile.${ARM64_TAG} .'
|
||||
|
||||
// Dockerhub
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM64} docker.io/jc21/${IMAGE}:${TAG_VERSION}-arm64'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM64} docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-arm64'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM64} docker.io/jc21/${IMAGE}:latest-arm64'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARM64_TAG} docker.io/jc21/${IMAGE}:${TAG_VERSION}-${ARM64_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARM64_TAG} docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${ARM64_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARM64_TAG} docker.io/jc21/${IMAGE}:latest-${ARM64_TAG}'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}'"
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${TAG_VERSION}-arm64'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-arm64'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:latest-arm64'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${TAG_VERSION}-${ARM64_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${ARM64_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:latest-${ARM64_TAG}'
|
||||
}
|
||||
|
||||
// Private Registry
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM64} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${TAG_VERSION}-arm64'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM64} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${MAJOR_VERSION}-arm64'
|
||||
sh 'docker tag ${TEMP_IMAGE_ARM64} ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:latest-arm64'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-private-registry', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}' ${DOCKER_PRIVATE_REGISTRY}"
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${TAG_VERSION}-arm64'
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:${MAJOR_VERSION}-arm64'
|
||||
sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/${IMAGE}:latest-arm64'
|
||||
}
|
||||
|
||||
sh 'docker rmi ${TEMP_IMAGE_ARM64}'
|
||||
|
||||
// Hack to clean up ec2 instance for next build
|
||||
sh 'sudo chown -R ec2-user:ec2-user *'
|
||||
sh 'docker rmi ${TEMP_IMAGE}-${ARM64_TAG}'
|
||||
}
|
||||
}
|
||||
}
|
||||
// ========================
|
||||
// armv7l
|
||||
// ========================
|
||||
stage('armv7l') {
|
||||
agent {
|
||||
label 'armv7l'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
// Codebase
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} yarn install'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} npm run-script build'
|
||||
sh 'rm -rf node_modules'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} yarn install --prod'
|
||||
|
||||
// Docker Build
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE}-${ARMV7_TAG} -f Dockerfile.${ARMV7_TAG} .'
|
||||
|
||||
// Dockerhub
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARMV7_TAG} docker.io/jc21/${IMAGE}:${TAG_VERSION}-${ARMV7_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARMV7_TAG} docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV7_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARMV7_TAG} docker.io/jc21/${IMAGE}:latest-${ARMV7_TAG}'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}'"
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${TAG_VERSION}-${ARMV7_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV7_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:latest-${ARMV7_TAG}'
|
||||
}
|
||||
|
||||
sh 'docker rmi ${TEMP_IMAGE}-${ARMV7_TAG}'
|
||||
}
|
||||
}
|
||||
}
|
||||
// ========================
|
||||
// armv6l - Disabled for the time being
|
||||
// ========================
|
||||
/*
|
||||
stage('armv6l') {
|
||||
agent {
|
||||
label 'armv6l'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
// Codebase
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} yarn install'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} npm run-script build'
|
||||
sh 'rm -rf node_modules'
|
||||
sh 'docker run --rm -v $(pwd):/app -w /app ${BASE_IMAGE} yarn install --prod'
|
||||
|
||||
// Docker Build
|
||||
sh 'docker build --pull --no-cache --squash --compress -t ${TEMP_IMAGE}-${ARMV6_TAG} -f Dockerfile.${ARMV6_TAG} .'
|
||||
|
||||
// Dockerhub
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARMV6_TAG} docker.io/jc21/${IMAGE}:${TAG_VERSION}-${ARMV6_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARMV6_TAG} docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV6_TAG}'
|
||||
sh 'docker tag ${TEMP_IMAGE}-${ARMV6_TAG} docker.io/jc21/${IMAGE}:latest-${ARMV6_TAG}'
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
|
||||
sh "docker login -u '${duser}' -p '${dpass}'"
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${TAG_VERSION}-${ARMV6_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV6_TAG}'
|
||||
sh 'docker push docker.io/jc21/${IMAGE}:latest-${ARMV6_TAG}'
|
||||
}
|
||||
|
||||
sh 'docker rmi ${TEMP_IMAGE}-${ARMV6_TAG}'
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
// ========================
|
||||
// latest manifest
|
||||
// ========================
|
||||
stage('Latest Manifest') {
|
||||
when {
|
||||
branch 'master'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
// =======================
|
||||
// latest
|
||||
// =======================
|
||||
sh 'docker pull jc21/${IMAGE}:latest-${AMD64_TAG}'
|
||||
sh 'docker pull jc21/${IMAGE}:latest-${ARM64_TAG}'
|
||||
sh 'docker pull jc21/${IMAGE}:latest-${ARMV7_TAG}'
|
||||
//sh 'docker pull jc21/${IMAGE}:latest-${ARMV6_TAG}'
|
||||
|
||||
sh 'docker manifest push --purge jc21/${IMAGE}:latest || echo ""'
|
||||
sh 'docker manifest create jc21/${IMAGE}:latest jc21/${IMAGE}:latest-${AMD64_TAG} jc21/${IMAGE}:latest-${ARM64_TAG} jc21/${IMAGE}:latest-${ARMV7_TAG}'
|
||||
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:latest jc21/${IMAGE}:latest-${AMD64_TAG} --arch ${AMD64_TAG}'
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:latest jc21/${IMAGE}:latest-${ARM64_TAG} --os linux --arch ${ARM64_TAG}'
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:latest jc21/${IMAGE}:latest-${ARMV7_TAG} --os linux --arch arm --variant ${ARMV7_TAG}'
|
||||
//sh 'docker manifest annotate jc21/${IMAGE}:latest jc21/${IMAGE}:latest-${ARMV6_TAG} --os linux --arch arm --variant ${ARMV6_TAG}'
|
||||
sh 'docker manifest push --purge jc21/${IMAGE}:latest'
|
||||
|
||||
// =======================
|
||||
// major version
|
||||
// =======================
|
||||
sh 'docker pull jc21/${IMAGE}:${MAJOR_VERSION}-${AMD64_TAG}'
|
||||
sh 'docker pull jc21/${IMAGE}:${MAJOR_VERSION}-${ARM64_TAG}'
|
||||
sh 'docker pull jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV7_TAG}'
|
||||
//sh 'docker pull jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV6_TAG}'
|
||||
|
||||
sh 'docker manifest push --purge jc21/${IMAGE}:${MAJOR_VERSION} || echo ""'
|
||||
sh 'docker manifest create jc21/${IMAGE}:${MAJOR_VERSION} jc21/${IMAGE}:${MAJOR_VERSION}-${AMD64_TAG} jc21/${IMAGE}:${MAJOR_VERSION}-${ARM64_TAG} jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV7_TAG}'
|
||||
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:${MAJOR_VERSION} jc21/${IMAGE}:${MAJOR_VERSION}-${AMD64_TAG} --arch ${AMD64_TAG}'
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:${MAJOR_VERSION} jc21/${IMAGE}:${MAJOR_VERSION}-${ARM64_TAG} --os linux --arch ${ARM64_TAG}'
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:${MAJOR_VERSION} jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV7_TAG} --os linux --arch arm --variant ${ARMV7_TAG}'
|
||||
//sh 'docker manifest annotate jc21/${IMAGE}:${MAJOR_VERSION} jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV6_TAG} --os linux --arch arm --variant ${ARMV6_TAG}'
|
||||
|
||||
// =======================
|
||||
// version
|
||||
// =======================
|
||||
sh 'docker pull jc21/${IMAGE}:${TAG_VERSION}-${AMD64_TAG}'
|
||||
sh 'docker pull jc21/${IMAGE}:${TAG_VERSION}-${ARM64_TAG}'
|
||||
sh 'docker pull jc21/${IMAGE}:${TAG_VERSION}-${ARMV7_TAG}'
|
||||
//sh 'docker pull jc21/${IMAGE}:${TAG_VERSION}-${ARMV6_TAG}'
|
||||
|
||||
sh 'docker manifest push --purge jc21/${IMAGE}:${TAG_VERSION} || echo ""'
|
||||
sh 'docker manifest create jc21/${IMAGE}:${TAG_VERSION} jc21/${IMAGE}:${TAG_VERSION}-${AMD64_TAG} jc21/${IMAGE}:${TAG_VERSION}-${ARM64_TAG} jc21/${IMAGE}:${TAG_VERSION}-${ARMV7_TAG}'
|
||||
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:${TAG_VERSION} jc21/${IMAGE}:${TAG_VERSION}-${AMD64_TAG} --arch ${AMD64_TAG}'
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:${TAG_VERSION} jc21/${IMAGE}:${TAG_VERSION}-${ARM64_TAG} --os linux --arch ${ARM64_TAG}'
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:${TAG_VERSION} jc21/${IMAGE}:${TAG_VERSION}-${ARMV7_TAG} --os linux --arch arm --variant ${ARMV7_TAG}'
|
||||
//sh 'docker manifest annotate jc21/${IMAGE}:${TAG_VERSION} jc21/${IMAGE}:${TAG_VERSION}-${ARMV6_TAG} --os linux --arch arm --variant ${ARMV6_TAG}'
|
||||
}
|
||||
}
|
||||
}
|
||||
// ========================
|
||||
// develop
|
||||
// ========================
|
||||
stage('Develop Manifest') {
|
||||
when {
|
||||
branch 'develop'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
sh 'docker pull jc21/${IMAGE}:develop-${AMD64_TAG}'
|
||||
//sh 'docker pull jc21/${IMAGE}:develop-${ARM64_TAG}'
|
||||
//sh 'docker pull jc21/${IMAGE}:develop-${ARMV7_TAG}'
|
||||
//sh 'docker pull jc21/${IMAGE}:${TAG_VERSION}-${ARMV6_TAG}'
|
||||
|
||||
sh 'docker manifest push --purge jc21/${IMAGE}:develop || :'
|
||||
sh 'docker manifest create jc21/${IMAGE}:develop jc21/${IMAGE}:develop-${AMD64_TAG}'
|
||||
|
||||
sh 'docker manifest annotate jc21/${IMAGE}:develop jc21/${IMAGE}:develop-${AMD64_TAG} --arch ${AMD64_TAG}'
|
||||
//sh 'docker manifest annotate jc21/${IMAGE}:develop jc21/${IMAGE}:develop-${ARM64_TAG} --os linux --arch ${ARM64_TAG}'
|
||||
//sh 'docker manifest annotate jc21/${IMAGE}:develop jc21/${IMAGE}:develop-${ARMV7_TAG} --os linux --arch arm --variant ${ARMV7_TAG}'
|
||||
//sh 'docker manifest annotate jc21/${IMAGE}:develop jc21/${IMAGE}:develop-${ARMV6_TAG} --os linux --arch arm --variant ${ARMV6_TAG}'
|
||||
}
|
||||
}
|
||||
}
|
||||
// ========================
|
||||
// cleanup
|
||||
// ========================
|
||||
stage('Latest Cleanup') {
|
||||
when {
|
||||
branch 'master'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
sh 'docker rmi jc21/${IMAGE}:latest jc21/${IMAGE}:latest-${AMD64_TAG} jc21/${IMAGE}:latest-${ARM64_TAG} jc21/${IMAGE}:latest-${ARMV7_TAG} || echo ""'
|
||||
sh 'docker rmi jc21/${IMAGE}:${MAJOR_VERSION}-${AMD64_TAG} jc21/${IMAGE}:${MAJOR_VERSION}-${ARM64_TAG} jc21/${IMAGE}:${MAJOR_VERSION}-${ARMV7_TAG} || echo ""'
|
||||
sh 'docker rmi jc21/${IMAGE}:${TAG_VERSION}-${AMD64_TAG} jc21/${IMAGE}:${TAG_VERSION}-${ARM64_TAG} jc21/${IMAGE}:${TAG_VERSION}-${ARMV7_TAG} || echo ""'
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Develop Cleanup') {
|
||||
when {
|
||||
branch 'develop'
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
sh 'docker rmi jc21/${IMAGE}:develop jc21/${IMAGE}:develop-${AMD64_TAG} || echo ""'
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('PR Cleanup') {
|
||||
when {
|
||||
changeRequest()
|
||||
}
|
||||
steps {
|
||||
ansiColor('xterm') {
|
||||
sh 'docker rmi jc21/${IMAGE}:github-${BRANCH_LOWER}-${AMD64_TAG} || echo ""'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -240,4 +358,3 @@ def getPackageVersion() {
|
||||
ver = sh(script: 'docker run --rm -v $(pwd):/data ${DOCKER_CI_TOOLS} bash -c "cat /data/package.json|jq -r \'.version\'"', returnStdout: true)
|
||||
return ver.trim()
|
||||
}
|
||||
|
||||
|
20
README.md
20
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
# Nginx Proxy Manager
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@ -57,24 +57,6 @@ Please consult the [installation instructions](doc/INSTALL.md) for a complete gu
|
||||
if you just want to get up and running in the quickest time possible, grab all the files in the `doc/example/` folder and run `docker-compose up -d`
|
||||
|
||||
|
||||
## Importing from Version 1?
|
||||
|
||||
Here's a [guide for you to migrate your configuration](doc/IMPORTING.md). You should definitely read the [installation instructions](doc/INSTALL.md) first though.
|
||||
|
||||
**Why should I?**
|
||||
|
||||
Version 2 has the following improvements:
|
||||
|
||||
- Management security and multiple user access
|
||||
- User permissions and visibility
|
||||
- Custom SSL certificate support
|
||||
- Audit log of changes
|
||||
- Broken nginx config detection
|
||||
- Multiple domains in Let's Encrypt certificates
|
||||
- Wildcard domain name support (not available with a Let's Encrypt certificate though)
|
||||
- It's super sexy
|
||||
|
||||
|
||||
## Administration
|
||||
|
||||
When your docker container is running, connect to it on port `81` for the admin interface.
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
# Nginx Proxy Manager
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
@ -143,3 +143,23 @@ Password: changeme
|
||||
```
|
||||
|
||||
Immediately after logging in with this default user you will be asked to modify your details and change your password.
|
||||
|
||||
|
||||
### Advanced Options
|
||||
|
||||
#### X-FRAME-OPTIONS Header
|
||||
|
||||
You can configure the [`X-FRAME-OPTIONS`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) header
|
||||
value by specifying it as a Docker environment variable. The default if not specified is `deny`.
|
||||
|
||||
```yml
|
||||
...
|
||||
environment:
|
||||
X_FRAME_OPTIONS: "sameorigin"
|
||||
...
|
||||
```
|
||||
|
||||
```
|
||||
... -e "X_FRAME_OPTIONS=sameorigin" ...
|
||||
```
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nginx-proxy-manager",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"description": "A beautiful interface for creating Nginx endpoints",
|
||||
"main": "src/backend/index.js",
|
||||
"devDependencies": {
|
||||
|
@ -22,10 +22,10 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Default 80 Host, which shows a "You are not configured" page
|
||||
# "You are not configured" page, which is the default if another default doesn't exist
|
||||
server {
|
||||
listen 80 default;
|
||||
server_name localhost;
|
||||
listen 80;
|
||||
server_name localhost-nginx-proxy-manager;
|
||||
|
||||
access_log /data/logs/default.log proxy;
|
||||
|
||||
@ -38,9 +38,9 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Default 443 Host
|
||||
# First 443 Host, which is the default if another default doesn't exist
|
||||
server {
|
||||
listen 443 ssl default;
|
||||
listen 443 ssl;
|
||||
server_name localhost;
|
||||
|
||||
access_log /data/logs/default.log proxy;
|
||||
|
@ -19,25 +19,26 @@ events {
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
server_tokens off;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
client_body_temp_path /tmp/nginx/body 1 2;
|
||||
keepalive_timeout 65;
|
||||
ssl_prefer_server_ciphers on;
|
||||
gzip on;
|
||||
proxy_ignore_client_abort off;
|
||||
client_max_body_size 2000m;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_cache off;
|
||||
proxy_cache_path /var/lib/nginx/cache/public levels=1:2 keys_zone=public-cache:30m max_size=192m;
|
||||
proxy_cache_path /var/lib/nginx/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
server_tokens off;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
client_body_temp_path /tmp/nginx/body 1 2;
|
||||
keepalive_timeout 65;
|
||||
ssl_prefer_server_ciphers on;
|
||||
gzip on;
|
||||
proxy_ignore_client_abort off;
|
||||
client_max_body_size 2000m;
|
||||
server_names_hash_bucket_size 64;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_cache off;
|
||||
proxy_cache_path /var/lib/nginx/cache/public levels=1:2 keys_zone=public-cache:30m max_size=192m;
|
||||
proxy_cache_path /var/lib/nginx/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m;
|
||||
|
||||
# MISS
|
||||
# BYPASS
|
||||
@ -70,6 +71,7 @@ http {
|
||||
|
||||
# Files generated by NPM
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /data/nginx/default_host/*.conf;
|
||||
include /data/nginx/proxy_host/*.conf;
|
||||
include /data/nginx/redirection_host/*.conf;
|
||||
include /data/nginx/dead_host/*.conf;
|
||||
|
@ -7,6 +7,8 @@ mkdir -p /tmp/nginx/body \
|
||||
/data/custom_ssl \
|
||||
/data/logs \
|
||||
/data/access \
|
||||
/data/nginx/default_host \
|
||||
/data/nginx/default_www \
|
||||
/data/nginx/proxy_host \
|
||||
/data/nginx/redirection_host \
|
||||
/data/nginx/stream \
|
||||
|
@ -40,11 +40,17 @@ app.use(require('./lib/express/cors'));
|
||||
|
||||
// General security/cache related headers + server header
|
||||
app.use(function (req, res, next) {
|
||||
let x_frame_options = 'DENY';
|
||||
|
||||
if (typeof process.env.X_FRAME_OPTIONS !== 'undefined' && process.env.X_FRAME_OPTIONS) {
|
||||
x_frame_options = process.env.X_FRAME_OPTIONS;
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload',
|
||||
'X-XSS-Protection': '0',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-Frame-Options': x_frame_options,
|
||||
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
Expires: 0
|
||||
|
@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const logger = require('./logger').global;
|
||||
|
||||
function appStart () {
|
||||
|
@ -17,9 +17,9 @@ const internalNginx = {
|
||||
* - IF BAD: update the meta with offline status and remove the config entirely
|
||||
* - then reload nginx
|
||||
*
|
||||
* @param {Object} model
|
||||
* @param {String} host_type
|
||||
* @param {Object} host
|
||||
* @param {Object|String} model
|
||||
* @param {String} host_type
|
||||
* @param {Object} host
|
||||
* @returns {Promise}
|
||||
*/
|
||||
configure: (model, host_type, host) => {
|
||||
@ -122,9 +122,52 @@ const internalNginx = {
|
||||
*/
|
||||
getConfigName: (host_type, host_id) => {
|
||||
host_type = host_type.replace(new RegExp('-', 'g'), '_');
|
||||
|
||||
if (host_type === 'default') {
|
||||
return '/data/nginx/default_host/site.conf';
|
||||
}
|
||||
|
||||
return '/data/nginx/' + host_type + '/' + host_id + '.conf';
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates custom locations
|
||||
* @param {Object} host
|
||||
* @returns {Promise}
|
||||
*/
|
||||
renderLocations: (host) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let template;
|
||||
|
||||
try {
|
||||
template = fs.readFileSync(__dirname + '/../templates/_location.conf', {encoding: 'utf8'});
|
||||
} catch (err) {
|
||||
reject(new error.ConfigurationError(err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
let renderer = new Liquid();
|
||||
let renderedLocations = '';
|
||||
|
||||
const locationRendering = async () => {
|
||||
for (let i = 0; i < host.locations.length; i++) {
|
||||
let locationCopy = Object.assign({}, host.locations[i]);
|
||||
|
||||
if (locationCopy.forward_host.indexOf('/') > -1) {
|
||||
const splitted = locationCopy.forward_host.split('/');
|
||||
|
||||
locationCopy.forward_host = splitted.shift();
|
||||
locationCopy.forward_path = `/${splitted.join('/')}`;
|
||||
}
|
||||
|
||||
renderedLocations += await renderer.parseAndRender(template, locationCopy);
|
||||
}
|
||||
}
|
||||
|
||||
locationRendering().then(() => resolve(renderedLocations));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {String} host_type
|
||||
* @param {Object} host
|
||||
@ -152,30 +195,49 @@ const internalNginx = {
|
||||
return;
|
||||
}
|
||||
|
||||
let locationsPromise;
|
||||
let origLocations;
|
||||
|
||||
// Manipulate the data a bit before sending it to the template
|
||||
host.use_default_location = true;
|
||||
if (typeof host.advanced_config !== 'undefined' && host.advanced_config) {
|
||||
host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config);
|
||||
if (host_type !== 'default') {
|
||||
host.use_default_location = true;
|
||||
if (typeof host.advanced_config !== 'undefined' && host.advanced_config) {
|
||||
host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config);
|
||||
}
|
||||
}
|
||||
|
||||
renderEngine
|
||||
.parseAndRender(template, host)
|
||||
.then(config_text => {
|
||||
fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
|
||||
|
||||
if (debug_mode) {
|
||||
logger.success('Wrote config:', filename, config_text);
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
})
|
||||
.catch(err => {
|
||||
if (debug_mode) {
|
||||
logger.warn('Could not write ' + filename + ':', err.message);
|
||||
}
|
||||
|
||||
reject(new error.ConfigurationError(err.message));
|
||||
if (host.locations) {
|
||||
origLocations = [].concat(host.locations);
|
||||
locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => {
|
||||
host.locations = renderedLocations;
|
||||
});
|
||||
} else {
|
||||
locationsPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
locationsPromise.then(() => {
|
||||
renderEngine
|
||||
.parseAndRender(template, host)
|
||||
.then(config_text => {
|
||||
fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
|
||||
|
||||
if (debug_mode) {
|
||||
logger.success('Wrote config:', filename, config_text);
|
||||
}
|
||||
|
||||
// Restore locations array
|
||||
host.locations = origLocations;
|
||||
|
||||
resolve(true);
|
||||
})
|
||||
.catch(err => {
|
||||
if (debug_mode) {
|
||||
logger.warn('Could not write ' + filename + ':', err.message);
|
||||
}
|
||||
|
||||
reject(new error.ConfigurationError(err.message));
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@ -260,7 +322,7 @@ const internalNginx = {
|
||||
|
||||
/**
|
||||
* @param {String} host_type
|
||||
* @param {Object} host
|
||||
* @param {Object} [host]
|
||||
* @param {Boolean} [throw_errors]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
@ -269,7 +331,7 @@ const internalNginx = {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
let config_file = internalNginx.getConfigName(host_type, host.id);
|
||||
let config_file = internalNginx.getConfigName(host_type, typeof host === 'undefined' ? 0 : host.id);
|
||||
|
||||
if (debug_mode) {
|
||||
logger.warn('Deleting nginx config: ' + config_file);
|
||||
|
@ -108,7 +108,7 @@ const internalProxyHost = {
|
||||
*/
|
||||
update: (access, data) => {
|
||||
let create_certificate = data.certificate_id === 'new';
|
||||
console.log('PH UPDATE:', data);
|
||||
|
||||
if (create_certificate) {
|
||||
delete data.certificate_id;
|
||||
}
|
||||
|
133
src/backend/internal/setting.js
Normal file
133
src/backend/internal/setting.js
Normal file
@ -0,0 +1,133 @@
|
||||
const fs = require('fs');
|
||||
const error = require('../lib/error');
|
||||
const settingModel = require('../models/setting');
|
||||
const internalNginx = require('./nginx');
|
||||
|
||||
const internalSetting = {
|
||||
|
||||
/**
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {String} data.id
|
||||
* @return {Promise}
|
||||
*/
|
||||
update: (access, data) => {
|
||||
return access.can('settings:update', data.id)
|
||||
.then(access_data => {
|
||||
return internalSetting.get(access, {id: data.id});
|
||||
})
|
||||
.then(row => {
|
||||
if (row.id !== data.id) {
|
||||
// Sanity check that something crazy hasn't happened
|
||||
throw new error.InternalValidationError('Setting could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
|
||||
}
|
||||
|
||||
return settingModel
|
||||
.query()
|
||||
.where({id: data.id})
|
||||
.patch(data);
|
||||
})
|
||||
.then(() => {
|
||||
return internalSetting.get(access, {
|
||||
id: data.id
|
||||
});
|
||||
})
|
||||
.then(row => {
|
||||
if (row.id === 'default-site') {
|
||||
// write the html if we need to
|
||||
if (row.value === 'html') {
|
||||
fs.writeFileSync('/data/nginx/default_www/index.html', row.meta.html, {encoding: 'utf8'});
|
||||
}
|
||||
|
||||
// Configure nginx
|
||||
return internalNginx.deleteConfig('default')
|
||||
.then(() => {
|
||||
return internalNginx.generateConfig('default', row);
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.test();
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.reload();
|
||||
})
|
||||
.then(() => {
|
||||
return row;
|
||||
})
|
||||
.catch((err) => {
|
||||
internalNginx.deleteConfig('default')
|
||||
.then(() => {
|
||||
return internalNginx.test();
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.reload();
|
||||
})
|
||||
.then(() => {
|
||||
// I'm being slack here I know..
|
||||
throw new error.ValidationError('Could not reconfigure Nginx. Please check logs.');
|
||||
})
|
||||
});
|
||||
} else {
|
||||
return row;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {String} data.id
|
||||
* @return {Promise}
|
||||
*/
|
||||
get: (access, data) => {
|
||||
return access.can('settings:get', data.id)
|
||||
.then(() => {
|
||||
return settingModel
|
||||
.query()
|
||||
.where('id', data.id)
|
||||
.first();
|
||||
})
|
||||
.then(row => {
|
||||
if (row) {
|
||||
return row;
|
||||
} else {
|
||||
throw new error.ItemNotFoundError(data.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* This will only count the settings
|
||||
*
|
||||
* @param {Access} access
|
||||
* @returns {*}
|
||||
*/
|
||||
getCount: (access) => {
|
||||
return access.can('settings:list')
|
||||
.then(() => {
|
||||
return settingModel
|
||||
.query()
|
||||
.count('id as count')
|
||||
.first();
|
||||
})
|
||||
.then(row => {
|
||||
return parseInt(row.count, 10);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* All settings
|
||||
*
|
||||
* @param {Access} access
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getAll: (access) => {
|
||||
return access.can('settings:list')
|
||||
.then(() => {
|
||||
return settingModel
|
||||
.query()
|
||||
.orderBy('description', 'ASC');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = internalSetting;
|
7
src/backend/lib/access/settings-get.json
Normal file
7
src/backend/lib/access/settings-get.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
7
src/backend/lib/access/settings-list.json
Normal file
7
src/backend/lib/access/settings-list.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
7
src/backend/lib/access/settings-update.json
Normal file
7
src/backend/lib/access/settings-update.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
37
src/backend/migrations/20190215115310_customlocations.js
Normal file
37
src/backend/migrations/20190215115310_customlocations.js
Normal file
@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
|
||||
const migrate_name = 'custom_locations';
|
||||
const logger = require('../logger').migrate;
|
||||
|
||||
/**
|
||||
* Migrate
|
||||
* Extends proxy_host table with locations field
|
||||
*
|
||||
* @see http://knexjs.org/#Schema
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @param {Promise} Promise
|
||||
* @returns {Promise}
|
||||
*/
|
||||
exports.up = function (knex/*, Promise*/) {
|
||||
logger.info('[' + migrate_name + '] Migrating Up...');
|
||||
|
||||
return knex.schema.table('proxy_host', function (proxy_host) {
|
||||
proxy_host.json('locations');
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('[' + migrate_name + '] proxy_host Table altered');
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* Undo Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @param {Promise} Promise
|
||||
* @returns {Promise}
|
||||
*/
|
||||
exports.down = function (knex, Promise) {
|
||||
logger.warn('[' + migrate_name + '] You can\'t migrate down this one.');
|
||||
return Promise.resolve(true);
|
||||
};
|
54
src/backend/migrations/20190227065017_settings.js
Normal file
54
src/backend/migrations/20190227065017_settings.js
Normal file
@ -0,0 +1,54 @@
|
||||
const migrate_name = 'settings';
|
||||
const logger = require('../logger').migrate;
|
||||
|
||||
/**
|
||||
* Migrate
|
||||
*
|
||||
* @see http://knexjs.org/#Schema
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @param {Promise} Promise
|
||||
* @returns {Promise}
|
||||
*/
|
||||
exports.up = function (knex/*, Promise*/) {
|
||||
logger.info('[' + migrate_name + '] Migrating Up...');
|
||||
|
||||
return knex.schema.createTable('setting', table => {
|
||||
table.string('id').notNull().primary();
|
||||
table.string('name', 100).notNull();
|
||||
table.string('description', 255).notNull();
|
||||
table.string('value', 255).notNull();
|
||||
table.json('meta').notNull();
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('[' + migrate_name + '] setting Table created');
|
||||
|
||||
// TODO: add settings
|
||||
let settingModel = require('../models/setting');
|
||||
|
||||
return settingModel
|
||||
.query()
|
||||
.insert({
|
||||
id: 'default-site',
|
||||
name: 'Default Site',
|
||||
description: 'What to show when Nginx is hit with an unknown Host',
|
||||
value: 'congratulations',
|
||||
meta: {}
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('[' + migrate_name + '] Default settings added');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Undo Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @param {Promise} Promise
|
||||
* @returns {Promise}
|
||||
*/
|
||||
exports.down = function (knex, Promise) {
|
||||
logger.warn('[' + migrate_name + '] You can\'t migrate down the initial data.');
|
||||
return Promise.resolve(true);
|
||||
};
|
@ -47,7 +47,7 @@ class ProxyHost extends Model {
|
||||
}
|
||||
|
||||
static get jsonAttributes () {
|
||||
return ['domain_names', 'meta'];
|
||||
return ['domain_names', 'meta', 'locations'];
|
||||
}
|
||||
|
||||
static get relationMappings () {
|
||||
|
30
src/backend/models/setting.js
Normal file
30
src/backend/models/setting.js
Normal file
@ -0,0 +1,30 @@
|
||||
// Objection Docs:
|
||||
// http://vincit.github.io/objection.js/
|
||||
|
||||
const db = require('../db');
|
||||
const Model = require('objection').Model;
|
||||
|
||||
Model.knex(db);
|
||||
|
||||
class Setting extends Model {
|
||||
$beforeInsert () {
|
||||
// Default for meta
|
||||
if (typeof this.meta === 'undefined') {
|
||||
this.meta = {};
|
||||
}
|
||||
}
|
||||
|
||||
static get name () {
|
||||
return 'Setting';
|
||||
}
|
||||
|
||||
static get tableName () {
|
||||
return 'setting';
|
||||
}
|
||||
|
||||
static get jsonAttributes () {
|
||||
return ['meta'];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Setting;
|
@ -31,6 +31,7 @@ router.use('/tokens', require('./tokens'));
|
||||
router.use('/users', require('./users'));
|
||||
router.use('/audit-log', require('./audit-log'));
|
||||
router.use('/reports', require('./reports'));
|
||||
router.use('/settings', require('./settings'));
|
||||
router.use('/nginx/proxy-hosts', require('./nginx/proxy_hosts'));
|
||||
router.use('/nginx/redirection-hosts', require('./nginx/redirection_hosts'));
|
||||
router.use('/nginx/dead-hosts', require('./nginx/dead_hosts'));
|
||||
|
96
src/backend/routes/api/settings.js
Normal file
96
src/backend/routes/api/settings.js
Normal file
@ -0,0 +1,96 @@
|
||||
const express = require('express');
|
||||
const validator = require('../../lib/validator');
|
||||
const jwtdecode = require('../../lib/express/jwt-decode');
|
||||
const internalSetting = require('../../internal/setting');
|
||||
const apiValidator = require('../../lib/validator/api');
|
||||
|
||||
let router = express.Router({
|
||||
caseSensitive: true,
|
||||
strict: true,
|
||||
mergeParams: true
|
||||
});
|
||||
|
||||
/**
|
||||
* /api/settings
|
||||
*/
|
||||
router
|
||||
.route('/')
|
||||
.options((req, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
*
|
||||
* Retrieve all settings
|
||||
*/
|
||||
.get((req, res, next) => {
|
||||
internalSetting.getAll(res.locals.access)
|
||||
.then(rows => {
|
||||
res.status(200)
|
||||
.send(rows);
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
/**
|
||||
* Specific setting
|
||||
*
|
||||
* /api/settings/something
|
||||
*/
|
||||
router
|
||||
.route('/:setting_id')
|
||||
.options((req, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
|
||||
/**
|
||||
* GET /settings/something
|
||||
*
|
||||
* Retrieve a specific setting
|
||||
*/
|
||||
.get((req, res, next) => {
|
||||
validator({
|
||||
required: ['setting_id'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
setting_id: {
|
||||
$ref: 'definitions#/definitions/setting_id'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
setting_id: req.params.setting_id
|
||||
})
|
||||
.then(data => {
|
||||
return internalSetting.get(res.locals.access, {
|
||||
id: data.setting_id
|
||||
});
|
||||
})
|
||||
.then(row => {
|
||||
res.status(200)
|
||||
.send(row);
|
||||
})
|
||||
.catch(next);
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /api/settings/something
|
||||
*
|
||||
* Update and existing setting
|
||||
*/
|
||||
.put((req, res, next) => {
|
||||
apiValidator({$ref: 'endpoints/settings#/links/1/schema'}, req.body)
|
||||
.then(payload => {
|
||||
payload.id = req.params.setting_id;
|
||||
return internalSetting.update(res.locals.access, payload);
|
||||
})
|
||||
.then(result => {
|
||||
res.status(200)
|
||||
.send(result);
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
module.exports = router;
|
@ -9,6 +9,13 @@
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"setting_id": {
|
||||
"description": "Unique identifier for a Setting",
|
||||
"example": "default-site",
|
||||
"readOnly": true,
|
||||
"type": "string",
|
||||
"minLength": 2
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"minLength": 10
|
||||
|
@ -69,6 +69,44 @@
|
||||
},
|
||||
"meta": {
|
||||
"type": "object"
|
||||
},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"forward_scheme",
|
||||
"forward_host",
|
||||
"forward_port",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": ["integer", "null"]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"forward_scheme": {
|
||||
"$ref": "#/definitions/forward_scheme"
|
||||
},
|
||||
"forward_host": {
|
||||
"$ref": "#/definitions/forward_host"
|
||||
},
|
||||
"forward_port": {
|
||||
"$ref": "#/definitions/forward_port"
|
||||
},
|
||||
"forward_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"advanced_config": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
@ -128,6 +166,9 @@
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/definitions/meta"
|
||||
},
|
||||
"locations": {
|
||||
"$ref": "#/definitions/locations"
|
||||
}
|
||||
},
|
||||
"links": [
|
||||
@ -215,6 +256,9 @@
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/definitions/meta"
|
||||
},
|
||||
"locations": {
|
||||
"$ref": "#/definitions/locations"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -285,6 +329,9 @@
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/definitions/meta"
|
||||
},
|
||||
"locations": {
|
||||
"$ref": "#/definitions/locations"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
99
src/backend/schema/endpoints/settings.json
Normal file
99
src/backend/schema/endpoints/settings.json
Normal file
@ -0,0 +1,99 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "endpoints/settings",
|
||||
"title": "Settings",
|
||||
"description": "Endpoints relating to Settings",
|
||||
"stability": "stable",
|
||||
"type": "object",
|
||||
"definitions": {
|
||||
"id": {
|
||||
"$ref": "../definitions.json#/definitions/setting_id"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name",
|
||||
"example": "Default Site",
|
||||
"type": "string",
|
||||
"minLength": 2,
|
||||
"maxLength": 100
|
||||
},
|
||||
"description": {
|
||||
"description": "Description",
|
||||
"example": "Default Site",
|
||||
"type": "string",
|
||||
"minLength": 2,
|
||||
"maxLength": 255
|
||||
},
|
||||
"value": {
|
||||
"description": "Value",
|
||||
"example": "404",
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"meta": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"title": "List",
|
||||
"description": "Returns a list of Settings",
|
||||
"href": "/settings",
|
||||
"access": "private",
|
||||
"method": "GET",
|
||||
"rel": "self",
|
||||
"http_header": {
|
||||
"$ref": "../examples.json#/definitions/auth_header"
|
||||
},
|
||||
"targetSchema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/properties"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Update",
|
||||
"description": "Updates a existing Setting",
|
||||
"href": "/settings/{definitions.identity.example}",
|
||||
"access": "private",
|
||||
"method": "PUT",
|
||||
"rel": "update",
|
||||
"http_header": {
|
||||
"$ref": "../examples.json#/definitions/auth_header"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"$ref": "#/definitions/value"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/definitions/meta"
|
||||
}
|
||||
}
|
||||
},
|
||||
"targetSchema": {
|
||||
"properties": {
|
||||
"$ref": "#/properties"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/id"
|
||||
},
|
||||
"name": {
|
||||
"$ref": "#/definitions/description"
|
||||
},
|
||||
"description": {
|
||||
"$ref": "#/definitions/description"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/definitions/value"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/definitions/meta"
|
||||
}
|
||||
}
|
||||
}
|
@ -34,6 +34,9 @@
|
||||
},
|
||||
"access-lists": {
|
||||
"$ref": "endpoints/access-lists.json"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "endpoints/settings.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
9
src/backend/templates/_location.conf
Normal file
9
src/backend/templates/_location.conf
Normal file
@ -0,0 +1,9 @@
|
||||
location {{ path }} {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_pass {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }};
|
||||
{{ advanced_config }}
|
||||
}
|
||||
|
32
src/backend/templates/default.conf
Normal file
32
src/backend/templates/default.conf
Normal file
@ -0,0 +1,32 @@
|
||||
# ------------------------------------------------------------
|
||||
# Default Site
|
||||
# ------------------------------------------------------------
|
||||
{% if value == "congratulations" %}
|
||||
# Skipping output, congratulations page configration is baked in.
|
||||
{%- else %}
|
||||
server {
|
||||
listen 80 default;
|
||||
server_name default-host.localhost;
|
||||
access_log /data/logs/default_host.log combined;
|
||||
{% include "_exploits.conf" %}
|
||||
|
||||
{%- if value == "404" %}
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{%- if value == "redirect" %}
|
||||
location / {
|
||||
return 301 {{ meta.redirect }};
|
||||
}
|
||||
{%- endif %}
|
||||
|
||||
{%- if value == "html" %}
|
||||
root /data/nginx/default_www;
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
{%- endif %}
|
||||
}
|
||||
{% endif %}
|
@ -16,7 +16,10 @@ server {
|
||||
|
||||
{{ advanced_config }}
|
||||
|
||||
{{ locations }}
|
||||
|
||||
{% if use_default_location %}
|
||||
|
||||
location / {
|
||||
{%- if access_list_id > 0 -%}
|
||||
# Access List
|
||||
|
@ -662,5 +662,34 @@ module.exports = {
|
||||
getHostStats: function () {
|
||||
return fetch('get', 'reports/hosts');
|
||||
}
|
||||
},
|
||||
|
||||
Settings: {
|
||||
|
||||
/**
|
||||
* @param {String} setting_id
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getById: function (setting_id) {
|
||||
return fetch('get', 'settings/' + setting_id);
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getAll: function () {
|
||||
return getAllObjects('settings');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} data
|
||||
* @param {Number} data.id
|
||||
* @returns {Promise}
|
||||
*/
|
||||
update: function (data) {
|
||||
let id = data.id;
|
||||
delete data.id;
|
||||
return fetch('put', 'settings/' + id, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -383,6 +383,36 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings
|
||||
*/
|
||||
showSettings: function () {
|
||||
let controller = this;
|
||||
if (Cache.User.isAdmin()) {
|
||||
require(['./main', './settings/main'], (App, View) => {
|
||||
controller.navigate('/settings');
|
||||
App.UI.showAppContent(new View());
|
||||
});
|
||||
} else {
|
||||
this.showDashboard();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings Item Form
|
||||
*
|
||||
* @param model
|
||||
*/
|
||||
showSettingForm: function (model) {
|
||||
if (Cache.User.isAdmin()) {
|
||||
if (model.get('id') === 'default-site') {
|
||||
require(['./main', './settings/default-site/main'], function (App, View) {
|
||||
App.UI.showModalDialog(new View({model: model}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
|
@ -7,10 +7,22 @@
|
||||
<form>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
|
||||
<li role="presentation" class="nav-item"><a href="#locations" aria-controls="tab4" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-layers"></i> <%- i18n('all-hosts', 'locations') %></a></li>
|
||||
<li role="presentation" class="nav-item"><a href="#ssl-options" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-shield"></i> <%- i18n('str', 'ssl') %></a></li>
|
||||
<li role="presentation" class="nav-item"><a href="#advanced" aria-controls="tab3" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-settings"></i> <%- i18n('all-hosts', 'advanced') %></a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- Locations -->
|
||||
<div class="tab-pane" id="locations">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-secondary add_location"><%- i18n('locations', 'new_location') %></button>
|
||||
<div class="locations_container mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div role="tabpanel" class="tab-pane active" id="details">
|
||||
<div class="row">
|
||||
|
@ -3,11 +3,14 @@
|
||||
const Mn = require('backbone.marionette');
|
||||
const App = require('../../main');
|
||||
const ProxyHostModel = require('../../../models/proxy-host');
|
||||
const ProxyLocationModel = require('../../../models/proxy-host-location');
|
||||
const template = require('./form.ejs');
|
||||
const certListItemTemplate = require('../certificates-list-item.ejs');
|
||||
const accessListItemTemplate = require('./access-list-item.ejs');
|
||||
const CustomLocation = require('./location');
|
||||
const Helpers = require('../../../lib/helpers');
|
||||
|
||||
|
||||
require('jquery-serializejson');
|
||||
require('selectize');
|
||||
|
||||
@ -15,6 +18,8 @@ module.exports = Mn.View.extend({
|
||||
template: template,
|
||||
className: 'modal-dialog',
|
||||
|
||||
locationsCollection: new ProxyLocationModel.Collection(),
|
||||
|
||||
ui: {
|
||||
form: 'form',
|
||||
domain_names: 'input[name="domain_names"]',
|
||||
@ -22,6 +27,8 @@ module.exports = Mn.View.extend({
|
||||
buttons: '.modal-footer button',
|
||||
cancel: 'button.cancel',
|
||||
save: 'button.save',
|
||||
add_location_btn: 'button.add_location',
|
||||
locations_container:'.locations_container',
|
||||
certificate_select: 'select[name="certificate_id"]',
|
||||
access_list_select: 'select[name="access_list_id"]',
|
||||
ssl_forced: 'input[name="ssl_forced"]',
|
||||
@ -32,6 +39,10 @@ module.exports = Mn.View.extend({
|
||||
letsencrypt: '.letsencrypt'
|
||||
},
|
||||
|
||||
regions: {
|
||||
locations_regions: '@ui.locations_container'
|
||||
},
|
||||
|
||||
events: {
|
||||
'change @ui.certificate_select': function () {
|
||||
let id = this.ui.certificate_select.val();
|
||||
@ -82,6 +93,13 @@ module.exports = Mn.View.extend({
|
||||
}
|
||||
},
|
||||
|
||||
'click @ui.add_location_btn': function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const model = new ProxyLocationModel.Model();
|
||||
this.locationsCollection.add(model);
|
||||
},
|
||||
|
||||
'click @ui.save': function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@ -93,6 +111,16 @@ module.exports = Mn.View.extend({
|
||||
let view = this;
|
||||
let data = this.ui.form.serializeJSON();
|
||||
|
||||
// Add locations
|
||||
data.locations = [];
|
||||
this.locationsCollection.models.forEach((location) => {
|
||||
data.locations.push(location.toJSON());
|
||||
});
|
||||
|
||||
// Serialize collects path from custom locations
|
||||
// This field must be removed from root object
|
||||
delete data.path;
|
||||
|
||||
// Manipulate
|
||||
data.forward_port = parseInt(data.forward_port, 10);
|
||||
data.block_exploits = !!data.block_exploits;
|
||||
@ -246,5 +274,20 @@ module.exports = Mn.View.extend({
|
||||
if (typeof options.model === 'undefined' || !options.model) {
|
||||
this.model = new ProxyHostModel.Model();
|
||||
}
|
||||
|
||||
this.locationsCollection = new ProxyLocationModel.Collection();
|
||||
|
||||
// Custom locations
|
||||
this.showChildView('locations_regions', new CustomLocation.LocationCollectionView({
|
||||
collection: this.locationsCollection
|
||||
}));
|
||||
|
||||
// Check wether there are any location defined
|
||||
if (options.model && Array.isArray(options.model.attributes.locations)) {
|
||||
options.model.attributes.locations.forEach((location) => {
|
||||
let m = new ProxyLocationModel.Model(location);
|
||||
this.locationsCollection.add(m);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
64
src/frontend/js/app/nginx/proxy/location-item.ejs
Normal file
64
src/frontend/js/app/nginx/proxy/location-item.ejs
Normal file
@ -0,0 +1,64 @@
|
||||
<div class="location-block card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><%- i18n('locations', 'location_label') %> <span class="form-required">*</span></label>
|
||||
<div class="row gutter-xs">
|
||||
<div class="col">
|
||||
<div class="input-group">
|
||||
<span class="input-group-prepend">
|
||||
<span class="input-group-text">location</span>
|
||||
</span>
|
||||
<input type="text" name="path" class="form-control model" value="<%- path %>" placeholder="<%- i18n('locations', 'path') %>" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="selectgroup">
|
||||
<label class="selectgroup-item">
|
||||
<input type="checkbox" class="selectgroup-input">
|
||||
<span class="selectgroup-button">
|
||||
<i class="fe fe-settings"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 col-md-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><%- i18n('proxy-hosts', 'forward-scheme') %><span class="form-required">*</span></label>
|
||||
<select name="forward_scheme" class="form-control custom-select model" placeholder="http">
|
||||
<option value="http" <%- forward_scheme === 'http' ? 'selected' : '' %>>http</option>
|
||||
<option value="https" <%- forward_scheme === 'https' ? 'selected' : '' %>>https</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-5 col-md-5">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><%- i18n('proxy-hosts', 'forward-host') %><span class="form-required">*</span></label>
|
||||
<input type="text" name="forward_host" class="form-control text-monospace model" placeholder="" value="<%- forward_host %>" autocomplete="off" maxlength="50" required>
|
||||
<span style="font-size: 9px;"><%- i18n('proxy-hosts', 'cutom-forward-host-help') %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-4">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><%- i18n('proxy-hosts', 'forward-port') %> <span class="form-required">*</span></label>
|
||||
<input name="forward_port" type="number" class="form-control text-monospace model" placeholder="80" value="<%- forward_port %>" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row config">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group">
|
||||
<textarea name="advanced_config" rows="8" class="form-control text-monospace model" placeholder="# <%- i18n('all-hosts', 'advanced-warning') %>"><%- advanced_config %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="#" class="card-link location-delete">
|
||||
<i class="fa fa-trash"></i> <%- i18n('locations', 'delete') %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
54
src/frontend/js/app/nginx/proxy/location.js
Normal file
54
src/frontend/js/app/nginx/proxy/location.js
Normal file
@ -0,0 +1,54 @@
|
||||
const locationItemTemplate = require('./location-item.ejs');
|
||||
const Mn = require('backbone.marionette');
|
||||
const App = require('../../main');
|
||||
|
||||
const LocationView = Mn.View.extend({
|
||||
template: locationItemTemplate,
|
||||
className: 'location_block',
|
||||
|
||||
ui: {
|
||||
toggle: 'input[type="checkbox"]',
|
||||
config: '.config',
|
||||
delete: '.location-delete'
|
||||
},
|
||||
|
||||
events: {
|
||||
'change @ui.toggle': function(el) {
|
||||
if (el.target.checked) {
|
||||
this.ui.config.show();
|
||||
} else {
|
||||
this.ui.config.hide();
|
||||
}
|
||||
},
|
||||
|
||||
'change .model': function (e) {
|
||||
const map = {};
|
||||
map[e.target.name] = e.target.value;
|
||||
this.model.set(map);
|
||||
},
|
||||
|
||||
'click @ui.delete': function () {
|
||||
this.model.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
onRender: function() {
|
||||
$(this.ui.config).hide();
|
||||
},
|
||||
|
||||
templateContext: function() {
|
||||
return {
|
||||
i18n: App.i18n
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const LocationCollectionView = Mn.CollectionView.extend({
|
||||
className: 'locations_container',
|
||||
childView: LocationView
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
LocationCollectionView,
|
||||
LocationView
|
||||
}
|
@ -15,6 +15,7 @@ module.exports = AppRouter.default.extend({
|
||||
'nginx/access': 'showNginxAccess',
|
||||
'nginx/certificates': 'showNginxCertificates',
|
||||
'audit-log': 'showAuditLog',
|
||||
'settings': 'showSettings',
|
||||
'*default': 'showDashboard'
|
||||
}
|
||||
});
|
||||
|
53
src/frontend/js/app/settings/default-site/main.ejs
Normal file
53
src/frontend/js/app/settings/default-site/main.ejs
Normal file
@ -0,0 +1,53 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><%- i18n('settings', id) %></h5>
|
||||
<button type="button" class="close cancel" aria-label="Close" data-dismiss="modal"> </button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="row">
|
||||
<div class="col-sm-12 col-md-12">
|
||||
<div class="form-group">
|
||||
<div class="form-label"><%- description %></div>
|
||||
<div class="custom-controls-stacked">
|
||||
<label class="custom-control custom-radio">
|
||||
<input class="custom-control-input" name="value" value="congratulations" type="radio" required <%- value === 'congratulations' ? 'checked' : '' %>>
|
||||
<div class="custom-control-label"><%- i18n('settings', 'default-site-congratulations') %></div>
|
||||
</label>
|
||||
<label class="custom-control custom-radio">
|
||||
<input class="custom-control-input" name="value" value="404" type="radio" required <%- value === '404' ? 'checked' : '' %>>
|
||||
<div class="custom-control-label"><%- i18n('settings', 'default-site-404') %></div>
|
||||
</label>
|
||||
<label class="custom-control custom-radio">
|
||||
<input class="custom-control-input" name="value" value="redirect" type="radio" required <%- value === 'redirect' ? 'checked' : '' %>>
|
||||
<div class="custom-control-label"><%- i18n('settings', 'default-site-redirect') %></div>
|
||||
</label>
|
||||
<label class="custom-control custom-radio">
|
||||
<input class="custom-control-input" name="value" value="html" type="radio" required <%- value === 'html' ? 'checked' : '' %>>
|
||||
<div class="custom-control-label"><%- i18n('settings', 'default-site-html') %></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-12 option-item option-redirect">
|
||||
<div class="form-group">
|
||||
<div class="form-label">Redirect to</div>
|
||||
<input class="form-control redirect-input" name="meta[redirect]" placeholder="https://" type="url" value="<%- meta && typeof meta.redirect !== 'undefined' ? meta.redirect : '' %>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-12 option-item option-html">
|
||||
<div class="form-group">
|
||||
<div class="form-label">HTML Content</div>
|
||||
<textarea class="form-control text-monospace html-content" name="meta[html]" rows="6" placeholder="<!-- Enter your HTML here -->"><%- meta && typeof meta.html !== 'undefined' ? meta.html : '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
|
||||
<button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
|
||||
</div>
|
||||
</div>
|
71
src/frontend/js/app/settings/default-site/main.js
Normal file
71
src/frontend/js/app/settings/default-site/main.js
Normal file
@ -0,0 +1,71 @@
|
||||
'use strict';
|
||||
|
||||
const Mn = require('backbone.marionette');
|
||||
const App = require('../../main');
|
||||
const template = require('./main.ejs');
|
||||
|
||||
require('jquery-serializejson');
|
||||
require('selectize');
|
||||
|
||||
module.exports = Mn.View.extend({
|
||||
template: template,
|
||||
className: 'modal-dialog',
|
||||
|
||||
ui: {
|
||||
form: 'form',
|
||||
buttons: '.modal-footer button',
|
||||
cancel: 'button.cancel',
|
||||
save: 'button.save',
|
||||
options: '.option-item',
|
||||
value: 'input[name="value"]',
|
||||
redirect: '.redirect-input',
|
||||
html: '.html-content'
|
||||
},
|
||||
|
||||
events: {
|
||||
'change @ui.value': function (e) {
|
||||
let val = this.ui.value.filter(':checked').val();
|
||||
this.ui.options.hide();
|
||||
this.ui.options.filter('.option-' + val).show();
|
||||
},
|
||||
|
||||
'click @ui.save': function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
let val = this.ui.value.filter(':checked').val();
|
||||
|
||||
// Clear redirect field before validation
|
||||
if (val !== 'redirect') {
|
||||
this.ui.redirect.val('').attr('required', false);
|
||||
} else {
|
||||
this.ui.redirect.attr('required', true);
|
||||
}
|
||||
|
||||
this.ui.html.attr('required', val === 'html');
|
||||
|
||||
if (!this.ui.form[0].checkValidity()) {
|
||||
$('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
|
||||
return;
|
||||
}
|
||||
|
||||
let view = this;
|
||||
let data = this.ui.form.serializeJSON();
|
||||
data.id = this.model.get('id');
|
||||
|
||||
this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
|
||||
App.Api.Settings.update(data)
|
||||
.then(result => {
|
||||
view.model.set(result);
|
||||
App.UI.closeModal();
|
||||
})
|
||||
.catch(err => {
|
||||
alert(err.message);
|
||||
this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onRender: function () {
|
||||
this.ui.value.trigger('change');
|
||||
}
|
||||
});
|
21
src/frontend/js/app/settings/list/item.ejs
Normal file
21
src/frontend/js/app/settings/list/item.ejs
Normal file
@ -0,0 +1,21 @@
|
||||
<td>
|
||||
<div><%- name %></div>
|
||||
<div class="small text-muted">
|
||||
<%- description %>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<% if (id === 'default-site') { %>
|
||||
<%- i18n('settings', 'default-site-' + value) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="item-action dropdown">
|
||||
<a href="#" data-toggle="dropdown" class="icon"><i class="fe fe-more-vertical"></i></a>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a href="#" class="edit dropdown-item"><i class="dropdown-icon fe fe-edit"></i> <%- i18n('str', 'edit') %></a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
25
src/frontend/js/app/settings/list/item.js
Normal file
25
src/frontend/js/app/settings/list/item.js
Normal file
@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
const Mn = require('backbone.marionette');
|
||||
const App = require('../../main');
|
||||
const template = require('./item.ejs');
|
||||
|
||||
module.exports = Mn.View.extend({
|
||||
template: template,
|
||||
tagName: 'tr',
|
||||
|
||||
ui: {
|
||||
edit: 'a.edit'
|
||||
},
|
||||
|
||||
events: {
|
||||
'click @ui.edit': function (e) {
|
||||
e.preventDefault();
|
||||
App.Controller.showSettingForm(this.model);
|
||||
}
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
this.listenTo(this.model, 'change', this.render);
|
||||
}
|
||||
});
|
8
src/frontend/js/app/settings/list/main.ejs
Normal file
8
src/frontend/js/app/settings/list/main.ejs
Normal file
@ -0,0 +1,8 @@
|
||||
<thead>
|
||||
<th><%- i18n('str', 'name') %></th>
|
||||
<th><%- i18n('str', 'value') %></th>
|
||||
<th> </th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- items -->
|
||||
</tbody>
|
29
src/frontend/js/app/settings/list/main.js
Normal file
29
src/frontend/js/app/settings/list/main.js
Normal file
@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
const Mn = require('backbone.marionette');
|
||||
const ItemView = require('./item');
|
||||
const template = require('./main.ejs');
|
||||
|
||||
const TableBody = Mn.CollectionView.extend({
|
||||
tagName: 'tbody',
|
||||
childView: ItemView
|
||||
});
|
||||
|
||||
module.exports = Mn.View.extend({
|
||||
tagName: 'table',
|
||||
className: 'table table-hover table-outline table-vcenter text-nowrap card-table',
|
||||
template: template,
|
||||
|
||||
regions: {
|
||||
body: {
|
||||
el: 'tbody',
|
||||
replaceElement: true
|
||||
}
|
||||
},
|
||||
|
||||
onRender: function () {
|
||||
this.showChildView('body', new TableBody({
|
||||
collection: this.collection
|
||||
}));
|
||||
}
|
||||
});
|
14
src/frontend/js/app/settings/main.ejs
Normal file
14
src/frontend/js/app/settings/main.ejs
Normal file
@ -0,0 +1,14 @@
|
||||
<div class="card">
|
||||
<div class="card-status bg-teal"></div>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><%- i18n('settings', 'title') %></h3>
|
||||
</div>
|
||||
<div class="card-body no-padding min-100">
|
||||
<div class="dimmer active">
|
||||
<div class="loader"></div>
|
||||
<div class="dimmer-content list-region">
|
||||
<!-- List Region -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
50
src/frontend/js/app/settings/main.js
Normal file
50
src/frontend/js/app/settings/main.js
Normal file
@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const Mn = require('backbone.marionette');
|
||||
const App = require('../main');
|
||||
const SettingModel = require('../../models/setting');
|
||||
const ListView = require('./list/main');
|
||||
const ErrorView = require('../error/main');
|
||||
const template = require('./main.ejs');
|
||||
|
||||
module.exports = Mn.View.extend({
|
||||
id: 'settings',
|
||||
template: template,
|
||||
|
||||
ui: {
|
||||
list_region: '.list-region',
|
||||
add: '.add-item',
|
||||
dimmer: '.dimmer'
|
||||
},
|
||||
|
||||
regions: {
|
||||
list_region: '@ui.list_region'
|
||||
},
|
||||
|
||||
onRender: function () {
|
||||
let view = this;
|
||||
|
||||
App.Api.Settings.getAll()
|
||||
.then(response => {
|
||||
if (!view.isDestroyed() && response && response.length) {
|
||||
view.showChildView('list_region', new ListView({
|
||||
collection: new SettingModel.Collection(response)
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
view.showChildView('list_region', new ErrorView({
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
retry: function () {
|
||||
App.Controller.showSettings();
|
||||
}
|
||||
}));
|
||||
|
||||
console.error(err);
|
||||
})
|
||||
.then(() => {
|
||||
view.ui.dimmer.removeClass('active');
|
||||
});
|
||||
}
|
||||
});
|
@ -42,6 +42,9 @@
|
||||
<li class="nav-item">
|
||||
<a href="/audit-log" class="nav-link"><i class="fe fe-book-open"></i> <%- i18n('audit-log', 'title') %></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/settings" class="nav-link"><i class="fe fe-settings"></i> <%- i18n('settings', 'title') %></a>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -31,7 +31,8 @@
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"unknown": "Unknown",
|
||||
"expires": "Expires"
|
||||
"expires": "Expires",
|
||||
"value": "Value"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login to your account"
|
||||
@ -81,7 +82,14 @@
|
||||
"advanced-warning": "Enter your custom Nginx configuration here at your own risk!",
|
||||
"advanced-config": "Custom Nginx Configuration",
|
||||
"hsts-enabled": "HSTS Enabled",
|
||||
"hsts-subdomains": "HSTS Subdomains"
|
||||
"hsts-subdomains": "HSTS Subdomains",
|
||||
"locations": "Custom locations"
|
||||
},
|
||||
"locations": {
|
||||
"new_location": "Add location",
|
||||
"path": "/path",
|
||||
"location_label": "Define location",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"ssl": {
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
@ -106,7 +114,8 @@
|
||||
"help-content": "A Proxy Host is the incoming endpoint for a web service that you want to forward.\nIt provides optional SSL termination for your service that might not have SSL support built in.\nProxy Hosts are the most common use for the Nginx Proxy Manager.",
|
||||
"access-list": "Access List",
|
||||
"allow-websocket-upgrade": "Websockets Support",
|
||||
"ignore-invalid-upstream-ssl": "Ignore Invalid SSL"
|
||||
"ignore-invalid-upstream-ssl": "Ignore Invalid SSL",
|
||||
"cutom-forward-host-help": "Use 1.1.1.1/path for sub-folder forwarding"
|
||||
},
|
||||
"redirection-hosts": {
|
||||
"title": "Redirection Hosts",
|
||||
@ -222,6 +231,14 @@
|
||||
"meta-title": "Details for Event",
|
||||
"view-meta": "View Details",
|
||||
"date": "Date"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"default-site": "Default Site",
|
||||
"default-site-congratulations": "Congratulations Page",
|
||||
"default-site-404": "404 Page",
|
||||
"default-site-html": "Custom Page",
|
||||
"default-site-redirect": "Redirect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
37
src/frontend/js/models/proxy-host-location.js
Normal file
37
src/frontend/js/models/proxy-host-location.js
Normal file
@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
|
||||
const Backbone = require('backbone');
|
||||
|
||||
const model = Backbone.Model.extend({
|
||||
idAttribute: 'id',
|
||||
|
||||
defaults: function() {
|
||||
return {
|
||||
opened: false,
|
||||
path: '',
|
||||
advanced_config: '',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '',
|
||||
forward_port: '80'
|
||||
}
|
||||
},
|
||||
|
||||
toJSON() {
|
||||
const r = Object.assign({}, this.attributes);
|
||||
delete r.opened;
|
||||
return r;
|
||||
},
|
||||
|
||||
toggleVisibility: function () {
|
||||
this.save({
|
||||
opened: !this.get('opened')
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
Model: model,
|
||||
Collection: Backbone.Collection.extend({
|
||||
model
|
||||
})
|
||||
}
|
25
src/frontend/js/models/setting.js
Normal file
25
src/frontend/js/models/setting.js
Normal file
@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('underscore');
|
||||
const Backbone = require('backbone');
|
||||
|
||||
const model = Backbone.Model.extend({
|
||||
idAttribute: 'id',
|
||||
|
||||
defaults: function () {
|
||||
return {
|
||||
id: undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
value: null,
|
||||
meta: []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
Model: model,
|
||||
Collection: Backbone.Collection.extend({
|
||||
model: model
|
||||
})
|
||||
};
|
Reference in New Issue
Block a user