diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..313497cf --- /dev/null +++ b/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + ["env", { + "targets": { + "browsers": ["Chrome >= 65"] + }, + "debug": false, + "modules": false, + "useBuiltIns": "usage" + }] + ] +} diff --git a/.gitignore b/.gitignore index ba4d9556..184fbc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ .DS_Store .idea ._* -manager/node_modules -manager/core* -manager/dist -manager/webpack_stats.html -config/* -letsencrypt/* +node_modules +core* +config/development.json +dist +webpack_stats.html +data/* +yarn-error.log +yarn.lock +tmp +certbot.log + diff --git a/Dockerfile b/Dockerfile index e9ecb9ba..4a5b2e80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM jc21/nginx-proxy-manager-base +FROM jc21/nginx-proxy-manager-base:latest MAINTAINER Jamie Curnow LABEL maintainer="Jamie Curnow " @@ -7,6 +7,8 @@ ENV SUPPRESS_NO_CONFIG_WARNING=1 ENV S6_FIX_ATTRS_HIDDEN=1 RUN echo "fs.file-max = 65535" > /etc/sysctl.conf +# Nginx, Node and required packages should already be installed from the base image + # root filesystem COPY rootfs / @@ -17,13 +19,14 @@ RUN curl -L -o /tmp/s6-overlay-amd64.tar.gz "https://github.com/just-containers/ # App ENV NODE_ENV=production -ADD manager/dist /srv/manager/dist -ADD manager/node_modules /srv/manager/node_modules -ADD manager/src/backend /srv/manager/src/backend -ADD manager/package.json /srv/manager/package.json +ADD dist /app/dist +ADD node_modules /app/node_modules +ADD src/backend /app/src/backend +ADD package.json /app/package.json +ADD knexfile.js /app/knexfile.js # Volumes -VOLUME [ "/config", "/etc/letsencrypt" ] +VOLUME [ "/data", "/etc/letsencrypt" ] CMD [ "/init" ] HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://localhost:9876/health || exit 1 diff --git a/Dockerfile.armhf b/Dockerfile.armhf index ca32bb24..537b6d40 100644 --- a/Dockerfile.armhf +++ b/Dockerfile.armhf @@ -7,6 +7,8 @@ ENV SUPPRESS_NO_CONFIG_WARNING=1 ENV S6_FIX_ATTRS_HIDDEN=1 RUN echo "fs.file-max = 65535" > /etc/sysctl.conf +# Nginx, Node and required packages should already be installed from the base image + # root filesystem COPY rootfs / @@ -17,13 +19,14 @@ RUN curl -L -o /tmp/s6-overlay-armhf.tar.gz "https://github.com/just-containers/ # App ENV NODE_ENV=production -ADD manager/dist /srv/manager/dist -ADD manager/node_modules /srv/manager/node_modules -ADD manager/src/backend /srv/manager/src/backend -ADD manager/package.json /srv/manager/package.json +ADD dist /app/dist +ADD node_modules /app/node_modules +ADD src/backend /app/src/backend +ADD package.json /app/package.json +ADD knexfile.js /app/knexfile.js # Volumes -VOLUME [ "/config", "/etc/letsencrypt" ] +VOLUME [ "/data", "/etc/letsencrypt" ] CMD [ "/init" ] HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://localhost:9876/health || exit 1 diff --git a/Jenkinsfile b/Jenkinsfile index b6ec5bca..06662173 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -6,12 +6,18 @@ pipeline { agent any environment { IMAGE_NAME = "nginx-proxy-manager" + BASE_IMAGE_NAME = "jc21/nginx-proxy-manager-base:v2" TEMP_IMAGE_NAME = "nginx-proxy-manager-build_${BUILD_NUMBER}" - TEMP_IMAGE_NAME_ARM = "nginx-proxy-manager-armhf-build_${BUILD_NUMBER}" + TEMP_IMAGE_NAME_ARM = "nginx-proxy-manager-arm-build_${BUILD_NUMBER}" TAG_VERSION = getPackageVersion() - MAJOR_VERSION = "1" + MAJOR_VERSION = "2" } stages { + stage('Prepare') { + steps { + sh 'docker pull $DOCKER_CI_TOOLS' + } + } stage('Build') { parallel { stage('x86_64') { @@ -21,34 +27,33 @@ pipeline { steps { ansiColor('xterm') { // Codebase - sh 'docker pull jc21/$IMAGE_NAME-base' - sh 'docker run --rm -v $(pwd)/manager:/srv/manager -w /srv/manager jc21/$IMAGE_NAME-base yarn --registry=$NPM_REGISTRY install' - sh 'docker run --rm -v $(pwd)/manager:/srv/manager -w /srv/manager jc21/$IMAGE_NAME-base gulp build' + sh 'docker run --rm -v $(pwd):/app -w /app $BASE_IMAGE_NAME yarn --registry=$NPM_REGISTRY install' + sh 'docker run --rm -v $(pwd):/app -w /app $BASE_IMAGE_NAME npm run-script build' sh 'rm -rf node_modules' - sh 'docker run --rm -v $(pwd)/manager:/srv/manager -w /srv/manager jc21/$IMAGE_NAME-base yarn --registry=$NPM_REGISTRY install --prod' - sh 'docker run --rm -v $(pwd)/manager:/data $DOCKER_CI_TOOLS node-prune' + sh 'docker run --rm -v $(pwd):/app -w /app $BASE_IMAGE_NAME yarn --registry=$NPM_REGISTRY install --prod' + 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_NAME .' // Private Registry + sh 'docker tag $TEMP_IMAGE_NAME $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$TAG_VERSION' + sh 'docker push $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$TAG_VERSION' + sh 'docker tag $TEMP_IMAGE_NAME $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$MAJOR_VERSION' + sh 'docker push $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$MAJOR_VERSION' sh 'docker tag $TEMP_IMAGE_NAME $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:latest' sh 'docker push $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:latest' - sh 'docker tag $TEMP_IMAGE_NAME ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$TAG_VERSION' - sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$TAG_VERSION' - sh 'docker tag $TEMP_IMAGE_NAME ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$MAJOR_VERSION' - sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$MAJOR_VERSION' // Dockerhub - sh 'docker tag $TEMP_IMAGE_NAME docker.io/jc21/$IMAGE_NAME:latest' sh 'docker tag $TEMP_IMAGE_NAME docker.io/jc21/$IMAGE_NAME:$TAG_VERSION' sh 'docker tag $TEMP_IMAGE_NAME docker.io/jc21/$IMAGE_NAME:$MAJOR_VERSION' + sh 'docker tag $TEMP_IMAGE_NAME docker.io/jc21/$IMAGE_NAME:latest' withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { sh "docker login -u '${duser}' -p '$dpass'" - sh 'docker push docker.io/jc21/$IMAGE_NAME:latest' sh 'docker push docker.io/jc21/$IMAGE_NAME:$TAG_VERSION' sh 'docker push docker.io/jc21/$IMAGE_NAME:$MAJOR_VERSION' + sh 'docker push docker.io/jc21/$IMAGE_NAME:latest' } sh 'docker rmi $TEMP_IMAGE_NAME' @@ -65,32 +70,32 @@ pipeline { steps { ansiColor('xterm') { // Codebase - sh 'docker pull jc21/$IMAGE_NAME-base:armhf' - sh 'docker run --rm -v $(pwd)/manager:/srv/manager -w /srv/manager jc21/$IMAGE_NAME-base:armhf yarn --registry=$NPM_REGISTRY install' - sh 'docker run --rm -v $(pwd)/manager:/srv/manager -w /srv/manager jc21/$IMAGE_NAME-base:armhf gulp build' - sh 'docker run --rm -v $(pwd)/manager:/srv/manager -w /srv/manager jc21/$IMAGE_NAME-base:armhf yarn --registry=$NPM_REGISTRY install --prod' + sh 'docker run --rm -v $(pwd):/app -w /app $BASE_IMAGE_NAME-armhf yarn --registry=$NPM_REGISTRY install' + sh 'docker run --rm -v $(pwd):/app -w /app $BASE_IMAGE_NAME-armhf npm run-script build' + sh 'rm -rf node_modules' + sh 'docker run --rm -v $(pwd):/app -w /app $BASE_IMAGE_NAME-armhf yarn --registry=$NPM_REGISTRY install --prod' // Docker Build - sh 'docker build --pull --no-cache --squash --compress -f Dockerfile.armhf -t $TEMP_IMAGE_NAME_ARM .' + sh 'docker build --pull --no-cache --squash --compress -t $TEMP_IMAGE_NAME_ARM -f Dockerfile.armhf .' // Private Registry + sh 'docker tag $TEMP_IMAGE_NAME_ARM $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$TAG_VERSION-armhf' + sh 'docker push $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$TAG_VERSION-armhf' + sh 'docker tag $TEMP_IMAGE_NAME_ARM $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$MAJOR_VERSION-armhf' + sh 'docker push $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:$MAJOR_VERSION-armhf' sh 'docker tag $TEMP_IMAGE_NAME_ARM $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:latest-armhf' sh 'docker push $DOCKER_PRIVATE_REGISTRY/$IMAGE_NAME:latest-armhf' - sh 'docker tag $TEMP_IMAGE_NAME_ARM ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$TAG_VERSION-armhf' - sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$TAG_VERSION-armhf' - sh 'docker tag $TEMP_IMAGE_NAME_ARM ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$MAJOR_VERSION-armhf' - sh 'docker push ${DOCKER_PRIVATE_REGISTRY}/$IMAGE_NAME:$MAJOR_VERSION-armhf' // Dockerhub - sh 'docker tag $TEMP_IMAGE_NAME_ARM docker.io/jc21/$IMAGE_NAME:latest-armhf' sh 'docker tag $TEMP_IMAGE_NAME_ARM docker.io/jc21/$IMAGE_NAME:$TAG_VERSION-armhf' sh 'docker tag $TEMP_IMAGE_NAME_ARM docker.io/jc21/$IMAGE_NAME:$MAJOR_VERSION-armhf' + sh 'docker tag $TEMP_IMAGE_NAME_ARM docker.io/jc21/$IMAGE_NAME: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_NAME:latest-armhf' sh 'docker push docker.io/jc21/$IMAGE_NAME:$TAG_VERSION-armhf' sh 'docker push docker.io/jc21/$IMAGE_NAME:$MAJOR_VERSION-armhf' + sh 'docker push docker.io/jc21/$IMAGE_NAME:latest-armhf' } sh 'docker rmi $TEMP_IMAGE_NAME_ARM' @@ -113,7 +118,7 @@ pipeline { } def getPackageVersion() { - ver = sh(script: 'docker run --rm -v $(pwd)/manager:/data $DOCKER_CI_TOOLS bash -c "cat /data/package.json|jq -r \'.version\'"', returnStdout: true) + 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() } diff --git a/README.md b/README.md index 87434286..b534d105 100644 --- a/README.md +++ b/README.md @@ -2,72 +2,75 @@ # Nginx Proxy Manager -![Version](https://img.shields.io/badge/version-1.1.2-green.svg?style=for-the-badge) +![Version](https://img.shields.io/badge/version-2.0.0-green.svg?style=for-the-badge) ![Stars](https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge) ![Pulls](https://img.shields.io/docker/pulls/jc21/nginx-proxy-manager.svg?style=for-the-badge) This project comes as a pre-built docker image that enables you to easily forward to your websites running at home or otherwise, including free SSL, without having to know too much about Nginx or Letsencrypt. +---------- + +**WARNING: Version 2 a complete rewrite!** If you are using the `latest` docker tag and update to version 2 +without preparation, horrible things might happen. Refer to the [Importing Documentation](doc/IMPORTING.md). + +---------- ## Features -- Clean and simple interface -- Create an unlimited number of hosts and forward them to any IPv4/Port combination running HTTP -- Secure your sites with SSL and optionally force SSL -- Secure your sites with Basic HTTP Authentication Access Lists -- Advanced Nginx config option for super users -- 3 domain uses: - - Proxy requests to upstream server - - Redirect requests to another domain - - Return immediate 404's +- Beautiful and Secure Admin Interface based on [Tabler](https://tabler.github.io/) +- Easily create forwarding domains, redirections, streams and 404 hosts without knowing anything about Nginx +- Free SSL using Let's Encrypt or provide your own custom SSL certificates +- Access Lists and basic HTTP Authentication for your hosts +- Advanced Nginx configuration available for super users +- User management, permissions and audit log -## Using [Rancher](https://rancher.com)? +## Screenshots -Easily start an Nginx Proxy Manager Stack by adding [my template catalog](https://github.com/jc21/rancher-templates). +[![Login](https://public.jc21.com/nginx-proxy-manager/v2/small/login.jpg "Login")](https://public.jc21.com/nginx-proxy-manager/v2/large/login.jpg) +[![Dashboard](https://public.jc21.com/nginx-proxy-manager/v2/small/dashboard.jpg "Dashboard")](https://public.jc21.com/nginx-proxy-manager/v2/large/dashboard.jpg) +[![Proxy Hosts](https://public.jc21.com/nginx-proxy-manager/v2/small/proxy-hosts.jpg "Proxy Hosts")](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts.jpg) +[![Proxy Host Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/proxy-hosts-new1.jpg "Proxy Host Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts-new1.jpg) +[![Proxy Host SSL](https://public.jc21.com/nginx-proxy-manager/v2/small/proxy-hosts-new2.jpg "Proxy Host SSL")](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts-new2.jpg) +[![Redirection Hosts](https://public.jc21.com/nginx-proxy-manager/v2/small/redirection-hosts.jpg "Redirection Hosts")](https://public.jc21.com/nginx-proxy-manager/v2/large/redirection-hosts.jpg) +[![Redirection Host Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/redirection-hosts-new1.jpg "Redirection Host Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/redirection-hosts-new1.jpg) +[![Streams](https://public.jc21.com/nginx-proxy-manager/v2/small/streams.jpg "Streams")](https://public.jc21.com/nginx-proxy-manager/v2/large/streams.jpg) +[![Stream Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/streams-new1.jpg "Stream Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/streams-new1.jpg) +[![404 Hosts](https://public.jc21.com/nginx-proxy-manager/v2/small/dead-hosts.jpg "404 Hosts")](https://public.jc21.com/nginx-proxy-manager/v2/large/dead-hosts.jpg) +[![404 Host Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/dead-hosts-new1.jpg "404 Host Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/dead-hosts-new1.jpg) +[![Certificates](https://public.jc21.com/nginx-proxy-manager/v2/small/certificates.jpg "Certificates")](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates.jpg) +[![Lets Encrypt Certificates](https://public.jc21.com/nginx-proxy-manager/v2/small/certificates-new1.jpg "Lets Encrypt Certificates")](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates-new1.jpg) +[![Custom Certificates](https://public.jc21.com/nginx-proxy-manager/v2/small/certificates-new2.jpg "Custom Certificates")](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates-new2.jpg) +[![Access Lists](https://public.jc21.com/nginx-proxy-manager/v2/small/access-lists.jpg "Access Lists")](https://public.jc21.com/nginx-proxy-manager/v2/large/access-lists.jpg) +[![Access List Users](https://public.jc21.com/nginx-proxy-manager/v2/small/access-lists-new1.jpg "Access List Users")](https://public.jc21.com/nginx-proxy-manager/v2/large/access-lists-new1.jpg) +[![Users](https://public.jc21.com/nginx-proxy-manager/v2/small/users.jpg "Users")](https://public.jc21.com/nginx-proxy-manager/v2/large/users.jpg) +[![User Permissions](https://public.jc21.com/nginx-proxy-manager/v2/small/users-permissions.jpg "User Permissions")](https://public.jc21.com/nginx-proxy-manager/v2/large/users-permissions.jpg) +[![Audit Log](https://public.jc21.com/nginx-proxy-manager/v2/small/audit-log.jpg "Audit Log")](https://public.jc21.com/nginx-proxy-manager/v2/large/audit-log.jpg) ## Getting started -### Method 1: Using docker-compose - -By far the easiest way to get up and running. Create this `docker-compose.yml` - -```yml -version: "2" -services: - app: - image: jc21/nginx-proxy-manager - restart: always - ports: - - 80:80 - - 81:81 - - 443:443 - volumes: - - ./config:/config - - ./letsencrypt:/etc/letsencrypt -``` - -Then: - -```bash -docker-compose up -d -``` +Please consult the [installation instructions](doc/INSTALL.md) for a complete guide or +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` -### Method 2: Using vanilla docker +## Importing from Version 1? -```bash -docker run -d \ - -p 80:80 \ - -p 81:81 \ - -p 443:443 \ - -v /path/to/config:/config \ - -v /path/to/letsencrypt:/etc/letsencrypt \ - --restart always \ - jc21/nginx-proxy-manager -``` +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 ### Raspberry Pi / ARMHF @@ -87,18 +90,23 @@ docker run -d \ ## Administration -Now that your docker container is running, connect to it on port `81` for the admin interface. +When your docker container is running, connect to it on port `81` for the admin interface. [http://localhost:81](http://localhost:81) -There is no authentication on this interface to keep things simple. It is expected that you would not -expose port 81 to the outside world. - -From here, the rest should be self explanatory. - Note: Requesting SSL Certificates won't work until this project is accessible from the outside world, as explained below. +### Default Administrator User + +``` +Email: admin@example.com +Password: changeme +``` + +Immediately after logging in with this default user you will be asked to modify your details and change your password. + + ## Hosting your home network I won't go in to too much detail here but here are the basics for someone new to this self-hosted world. @@ -108,18 +116,3 @@ I won't go in to too much detail here but here are the basics for someone new to 3. Configure your domain name details to point to your home, either with a static ip or a service like DuckDNS 4. Use the Nginx Proxy Manager here as your gateway to forward to your other web based services - -## Screenshots - -[![Screenshot](https://public.jc21.com/nginx-proxy-manager/npm-1-sm.jpg "Screenshot")](https://public.jc21.com/nginx-proxy-manager/npm-1.jpg) -[![Screenshot](https://public.jc21.com/nginx-proxy-manager/npm-2-sm.jpg "Screenshot")](https://public.jc21.com/nginx-proxy-manager/npm-2.jpg) -[![Screenshot](https://public.jc21.com/nginx-proxy-manager/npm-3-sm.jpg "Screenshot")](https://public.jc21.com/nginx-proxy-manager/npm-3.jpg) -[![Screenshot](https://public.jc21.com/nginx-proxy-manager/npm-4-sm.jpg "Screenshot")](https://public.jc21.com/nginx-proxy-manager/npm-4.jpg) - -## TODO - -- Pass on human readable ssl cert errors to the ui -- UI: Allow column sorting on tables -- UI: Allow filtering hosts by types -- Advanced option to overwrite the default location block (or regex to do it automatically) -- Add nice upstream error pages diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..aa3cc31b --- /dev/null +++ b/TODO.md @@ -0,0 +1,17 @@ +# TODO + +- Dashboard stats are caching instead of querying + +Next version: + +- UI Log tail +- Enable/Disable a config + +Testing: + +- Access Levels + - Adding a proxy host without access to read certs or access lists +- Visibility +- Forwarding +- Cert renewals +- Custom certs diff --git a/bin/build b/bin/build new file mode 100755 index 00000000..43d749a6 --- /dev/null +++ b/bin/build @@ -0,0 +1,4 @@ +#!/bin/bash + +sudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script build +exit $? diff --git a/bin/build-dev b/bin/build-dev new file mode 100755 index 00000000..25203eb9 --- /dev/null +++ b/bin/build-dev @@ -0,0 +1,4 @@ +#!/bin/bash + +sudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script dev +exit $? diff --git a/bin/gulp b/bin/gulp deleted file mode 100755 index ca51616e..00000000 --- a/bin/gulp +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -if hash realpath 2>/dev/null; then - export CODEBASE=$(realpath $SCRIPT_DIR/..) -elif hash grealpath 2>/dev/null; then - export CODEBASE=$(grealpath $SCRIPT_DIR/..) -else - export CODEBASE=$(readlink -e $SCRIPT_DIR/..) -fi - -if [ -z "$CODEBASE" ]; then - echo "Unable to determine absolute codebase directory" - exit 1 -fi - -cd "$CODEBASE" - -/usr/local/bin/docker-compose run --no-deps --rm -w /srv/manager app gulp $@ -exit $? diff --git a/bin/npm b/bin/npm index 7a477952..a0863df0 100755 --- a/bin/npm +++ b/bin/npm @@ -1,20 +1,4 @@ #!/bin/bash -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -if hash realpath 2>/dev/null; then - export CODEBASE=$(realpath $SCRIPT_DIR/..) -elif hash grealpath 2>/dev/null; then - export CODEBASE=$(grealpath $SCRIPT_DIR/..) -else - export CODEBASE=$(readlink -e $SCRIPT_DIR/..) -fi - -if [ -z "$CODEBASE" ]; then - echo "Unable to determine absolute codebase directory" - exit 1 -fi - -cd "$CODEBASE" - -/usr/local/bin/docker-compose run --no-deps --rm -w /srv/manager app npm $@ +sudo /usr/local/bin/docker-compose run --no-deps --rm app npm $@ exit $? diff --git a/bin/yarn b/bin/yarn index fe3b1feb..e4fd807d 100755 --- a/bin/yarn +++ b/bin/yarn @@ -1,20 +1,4 @@ #!/bin/bash -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -if hash realpath 2>/dev/null; then - export CODEBASE=$(realpath $SCRIPT_DIR/..) -elif hash grealpath 2>/dev/null; then - export CODEBASE=$(grealpath $SCRIPT_DIR/..) -else - export CODEBASE=$(readlink -e $SCRIPT_DIR/..) -fi - -if [ -z "$CODEBASE" ]; then - echo "Unable to determine absolute codebase directory" - exit 1 -fi - -cd "$CODEBASE" - -/usr/local/bin/docker-compose run --no-deps --rm -w /srv/manager app yarn $@ +sudo /usr/local/bin/docker-compose run --no-deps --rm app yarn $@ exit $? diff --git a/config/README.md b/config/README.md new file mode 100644 index 00000000..26268a11 --- /dev/null +++ b/config/README.md @@ -0,0 +1,2 @@ +These files are use in development and are not deployed as part of the final product. + \ No newline at end of file diff --git a/config/default.json b/config/default.json new file mode 100644 index 00000000..64ab577c --- /dev/null +++ b/config/default.json @@ -0,0 +1,10 @@ +{ + "database": { + "engine": "mysql", + "host": "db", + "name": "npm", + "user": "npm", + "password": "npm", + "port": 3306 + } +} diff --git a/config/my.cnf b/config/my.cnf new file mode 100644 index 00000000..e3860d70 --- /dev/null +++ b/config/my.cnf @@ -0,0 +1,7 @@ +[mysqld] +skip-innodb +default-storage-engine=Aria +default-tmp-storage-engine=Aria +innodb=OFF +symbolic-links=0 +log-output=file diff --git a/doc/DOCKERHUB.md b/doc/DOCKERHUB.md new file mode 100644 index 00000000..b3892989 --- /dev/null +++ b/doc/DOCKERHUB.md @@ -0,0 +1,49 @@ +![Nginx Proxy Manager](https://public.jc21.com/nginx-proxy-manager/github.png "Nginx Proxy Manager") + +# Nginx Proxy Manager + +![Version](https://img.shields.io/badge/version-2.0.0-green.svg?style=for-the-badge) +![Stars](https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge) +![Pulls](https://img.shields.io/docker/pulls/jc21/nginx-proxy-manager.svg?style=for-the-badge) + +[View on Github](https://github.com/jc21/nginx-proxy-manager) + +This project comes as a pre-built docker image that enables you to easily forward to your websites +running at home or otherwise, including free SSL, without having to know too much about Nginx or Letsencrypt. + + +## Tags + +* latest 1, 1.x.x ([Dockerfile](https://github.com/jc21/nginx-proxy-manager/blob/master/Dockerfile)) +* latest-armhf, 1-armhf, 1.x.x-armhf ([Dockerfile](https://github.com/jc21/nginx-proxy-manager/blob/master/Dockerfile.armhf)) +* 2, 2.x.x ([Dockerfile](https://github.com/jc21/nginx-proxy-manager/blob/v2/Dockerfile)) +* 2-armhf, 2.x.x-armhf ([Dockerfile](https://github.com/jc21/nginx-proxy-manager/blob/v2/Dockerfile.armhf)) + + +## Getting started + +Please consult the [installation instructions](https://github.com/jc21/nginx-proxy-manager/blob/v2/doc/INSTALL.md) for a complete guide or +if you just want to get up and running in the quickest time possible, grab all the files in the [doc/example/](https://github.com/jc21/nginx-proxy-manager/tree/v2/doc/example) folder and run `docker-compose up -d` + + +## Screenshots + +[![Login](https://public.jc21.com/nginx-proxy-manager/v2/small/login.jpg "Login")](https://public.jc21.com/nginx-proxy-manager/v2/large/login.jpg) +[![Dashboard](https://public.jc21.com/nginx-proxy-manager/v2/small/dashboard.jpg "Dashboard")](https://public.jc21.com/nginx-proxy-manager/v2/large/dashboard.jpg) +[![Proxy Hosts](https://public.jc21.com/nginx-proxy-manager/v2/small/proxy-hosts.jpg "Proxy Hosts")](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts.jpg) +[![Proxy Host Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/proxy-hosts-new1.jpg "Proxy Host Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts-new1.jpg) +[![Proxy Host SSL](https://public.jc21.com/nginx-proxy-manager/v2/small/proxy-hosts-new2.jpg "Proxy Host SSL")](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts-new2.jpg) +[![Redirection Hosts](https://public.jc21.com/nginx-proxy-manager/v2/small/redirection-hosts.jpg "Redirection Hosts")](https://public.jc21.com/nginx-proxy-manager/v2/large/redirection-hosts.jpg) +[![Redirection Host Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/redirection-hosts-new1.jpg "Redirection Host Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/redirection-hosts-new1.jpg) +[![Streams](https://public.jc21.com/nginx-proxy-manager/v2/small/streams.jpg "Streams")](https://public.jc21.com/nginx-proxy-manager/v2/large/streams.jpg) +[![Stream Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/streams-new1.jpg "Stream Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/streams-new1.jpg) +[![404 Hosts](https://public.jc21.com/nginx-proxy-manager/v2/small/dead-hosts.jpg "404 Hosts")](https://public.jc21.com/nginx-proxy-manager/v2/large/dead-hosts.jpg) +[![404 Host Settings](https://public.jc21.com/nginx-proxy-manager/v2/small/dead-hosts-new1.jpg "404 Host Settings")](https://public.jc21.com/nginx-proxy-manager/v2/large/dead-hosts-new1.jpg) +[![Certificates](https://public.jc21.com/nginx-proxy-manager/v2/small/certificates.jpg "Certificates")](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates.jpg) +[![Lets Encrypt Certificates](https://public.jc21.com/nginx-proxy-manager/v2/small/certificates-new1.jpg "Lets Encrypt Certificates")](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates-new1.jpg) +[![Custom Certificates](https://public.jc21.com/nginx-proxy-manager/v2/small/certificates-new2.jpg "Custom Certificates")](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates-new2.jpg) +[![Access Lists](https://public.jc21.com/nginx-proxy-manager/v2/small/access-lists.jpg "Access Lists")](https://public.jc21.com/nginx-proxy-manager/v2/large/access-lists.jpg) +[![Access List Users](https://public.jc21.com/nginx-proxy-manager/v2/small/access-lists-new1.jpg "Access List Users")](https://public.jc21.com/nginx-proxy-manager/v2/large/access-lists-new1.jpg) +[![Users](https://public.jc21.com/nginx-proxy-manager/v2/small/users.jpg "Users")](https://public.jc21.com/nginx-proxy-manager/v2/large/users.jpg) +[![User Permissions](https://public.jc21.com/nginx-proxy-manager/v2/small/users-permissions.jpg "User Permissions")](https://public.jc21.com/nginx-proxy-manager/v2/large/users-permissions.jpg) +[![Audit Log](https://public.jc21.com/nginx-proxy-manager/v2/small/audit-log.jpg "Audit Log")](https://public.jc21.com/nginx-proxy-manager/v2/large/audit-log.jpg) diff --git a/doc/IMPORTING.md b/doc/IMPORTING.md new file mode 100644 index 00000000..bfda5164 --- /dev/null +++ b/doc/IMPORTING.md @@ -0,0 +1,57 @@ +## Importing from Version 1 + +Thanks for using Nginx Proxy Manager version 1. It sucked. + +But it worked. + +This guide will let your import your configuration from version 1 to version 2. + +**IMPORTANT: This will make changes to your `letsencrypt` folder and certificate files!** Make sure you back them up first. + + +### Link your previous folders in your new docker stack + +In version 1, the docker configuration asked for a `config` folder to be linked and a `letsencrypt` folder. However in version 2, the +configuration exists in the database, so the `config` folder is no longer required. However if you have this folder linked in a +version 2 stack, the application will automatically import that configuration the first time it finds it. + +Following the [example configuration](../example): + +```yaml +version: "3" +services: + app: + image: jc21/nginx-proxy-manager:2 + restart: always + ports: + - 80:80 + - 81:81 + - 443:443 + volumes: + - ./config.json:/app/config/production.json + - ./data:/data + - ./letsencrypt:/etc/letsencrypt # this is your previous letsencrypt folder + - ./config:/config # this is your previous config folder + depends_on: + - db + db: + image: mariadb + restart: always + environment: + MYSQL_ROOT_PASSWORD: "password123" + MYSQL_DATABASE: "nginxproxymanager" + MYSQL_USER: "nginxproxymanager" + MYSQL_PASSWORD: "password123" + volumes: + - ./data/mysql:/var/lib/mysql +``` + +After you start the stack, the import will begin just after database initialize. + +Some notes: +- After importing, a file is created in the `config` folder to signify that it has been imported and should not be imported again. +- Because no users previously existed in the version 1 config, the `admin@example.com` user will own all of the imported data. +- If you were crazy like me and used Nginx Proxy Manager version 1 to proxy the Admin interface behind a Access List, you should +really disable the access list for that proxy host in version 1 before importing in to version 2. The app doesn't like being behind basic +authentication and it's own internal authentication. If you forgot to do this before importing, just hit the admin interface directly +on port 81 to get around your basic authentication access list. diff --git a/doc/INSTALL.md b/doc/INSTALL.md new file mode 100644 index 00000000..615424ed --- /dev/null +++ b/doc/INSTALL.md @@ -0,0 +1,143 @@ +## Installation and Configuration + +There's a few ways to configure this app depending on: + +- Whether you use `docker-compose` or vanilla docker +- Which architecture you're running it on (raspberry pi also supported - Testers wanted!) + +### Configuration File + +**The configuration file needs to be provided by you!** + +Don't worry, this is easy to do. + +The app requires a configuration file to let it know what database you're using. + +Here's an example configuration for `mysql` (or mariadb): + +```json +{ + "database": { + "engine": "mysql", + "host": "127.0.0.1", + "name": "nginxproxymanager", + "user": "nginxproxymanager", + "password": "password123", + "port": 3306 + } +} +``` + +Once you've created your configuration file it's easy to mount it in the docker container, examples below. + +**Note:** After the first run of the application, the config file will be altered to include generated encryption keys unique to your installation. These keys +affect the login and session management of the application. If these keys change for any reason, all users will be logged out. + + +### Database + +This app doesn't come with a database, you have to provide one yourself. Currently only `mysql/mariadb` is supported. + +It's easy to use another docker container for your database also and link it as part of the docker stack. Here's an example: + +```yml +version: "3" +services: + app: + image: jc21/nginx-proxy-manager:2 + restart: always + ports: + - 80:80 + - 81:81 + - 443:443 + volumes: + - ./config.json:/app/config/production.json + - ./data:/data + - ./letsencrypt:/etc/letsencrypt + depends_on: + - db + db: + image: mariadb + restart: always + environment: + MYSQL_ROOT_PASSWORD: "password123" + MYSQL_DATABASE: "nginxproxymanager" + MYSQL_USER: "nginxproxymanager" + MYSQL_PASSWORD: "password123" + volumes: + - ./data/mysql:/var/lib/mysql +``` + + +### Running the App + +Via `docker-compose`: + +```yml +version: "3" +services: + app: + image: jc21/nginx-proxy-manager:2 + restart: always + ports: + - 80:80 + - 81:81 + - 443:443 + volumes: + - ./config.json:/app/config/production.json + - ./data:/data + - ./letsencrypt:/etc/letsencrypt +``` + +Vanilla Docker: + +```bash +docker run -d \ + --name nginx-proxy-manager \ + -p 80:80 \ + -p 81:81 \ + -p 443:443 \ + -v /path/to/config.json:/app/config/production.json \ + -v /path/to/data:/data \ + -v /path/to/letsencrypt:/etc/letsencrypt \ + jc21/nginx-proxy-manager:2 +``` + + +### Running on Raspberry PI / `armhf` + +I have created a `armhf` docker container just for you. There may be issues with it, +if you have issues please report them here. + +```bash +docker run -d \ + --name nginx-proxy-manager-app \ + -p 80:80 \ + -p 81:81 \ + -p 443:443 \ + -v /path/to/config.json:/app/config/production.json \ + -v /path/to/data:/data \ + -v /path/to/letsencrypt:/etc/letsencrypt \ + jc21/nginx-proxy-manager:2-armhf +``` + + +### Initial Run + +After the app is running for the first time, the following will happen: + +- The database will initialize with table structures +- GPG keys will be generated and saved in the configuration file +- A default admin user will be created + +This process can take a couple of minutes depending on your machine. + + +### Default Administrator User + +``` +Email: admin@example.com +Password: changeme +``` + +Immediately after logging in with this default user you will be asked to modify your details and change your password. diff --git a/doc/example/config.json b/doc/example/config.json new file mode 100644 index 00000000..dfc9f049 --- /dev/null +++ b/doc/example/config.json @@ -0,0 +1,10 @@ +{ + "database": { + "engine": "mysql", + "host": "db", + "name": "nginxproxymanager", + "user": "nginxproxymanager", + "password": "password123", + "port": 3306 + } +} diff --git a/doc/example/docker-compose.yml b/doc/example/docker-compose.yml new file mode 100644 index 00000000..0df29609 --- /dev/null +++ b/doc/example/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3" +services: + app: + image: jc21/nginx-proxy-manager:2 + restart: always + ports: + - 80:80 + - 81:81 + - 443:443 + volumes: + - ./config.json:/app/config/production.json + - ./data:/data + - ./letsencrypt:/etc/letsencrypt + depends_on: + - db + environment: + # if you want pretty colors in your docker logs: + - FORCE_COLOR=1 + db: + image: mariadb + restart: always + environment: + MYSQL_ROOT_PASSWORD: "password123" + MYSQL_DATABASE: "nginxproxymanager" + MYSQL_USER: "nginxproxymanager" + MYSQL_PASSWORD: "password123" + volumes: + - ./data/mysql:/var/lib/mysql diff --git a/docker-compose.yml b/docker-compose.yml index 96c125f7..ac0f64f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,32 @@ +# WARNING: This is a DEVELOPMENT docker-compose file, it should not be used for production. version: "2" services: app: - image: jc21/nginx-proxy-manager + image: jc21/nginx-proxy-manager-base:latest ports: - - 80:80 - - 81:81 - - 443:443 + - 8080:80 + - 8081:81 + - 8443:443 environment: - NODE_ENV=development + - FORCE_COLOR=1 volumes: - - ./config:/config - - ./letsencrypt:/etc/letsencrypt - - ./manager:/srv/manager + - ./data/letsencrypt:/etc/letsencrypt + - .:/app + - ./rootfs/etc/nginx:/etc/nginx + working_dir: /app + depends_on: + - db + links: + - db + command: node --max_old_space_size=250 --abort_on_uncaught_exception node_modules/nodemon/bin/nodemon.js + db: + image: mariadb:10.3.7 + environment: + MYSQL_ROOT_PASSWORD: "npm" + MYSQL_DATABASE: "npm" + MYSQL_USER: "npm" + MYSQL_PASSWORD: "npm" + volumes: + - ./config/my.cnf:/etc/mysql/conf.d/npm.cnf + - ./data/mysql:/var/lib/mysql diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 00000000..3d735ea7 --- /dev/null +++ b/knexfile.js @@ -0,0 +1,19 @@ +module.exports = { + development: { + client: 'mysql', + migrations: { + tableName: 'migrations', + stub: 'src/backend/lib/migrate_template.js', + directory: 'src/backend/migrations' + } + }, + + production: { + client: 'mysql', + migrations: { + tableName: 'migrations', + stub: 'src/backend/lib/migrate_template.js', + directory: 'src/backend/migrations' + } + } +}; diff --git a/manager/src/backend/db.js b/manager/src/backend/db.js deleted file mode 100644 index 45479f7b..00000000 --- a/manager/src/backend/db.js +++ /dev/null @@ -1,5 +0,0 @@ -'use strict'; - -const db = require('diskdb'); - -module.exports = db.connect('/config', ['hosts', 'access']); diff --git a/manager/src/backend/index.js b/manager/src/backend/index.js deleted file mode 100644 index 2e8b30cd..00000000 --- a/manager/src/backend/index.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const app = require('./app'); -const logger = require('./logger'); -const apiValidator = require('./lib/validator/api'); -const internalSsl = require('./internal/ssl'); - -let port = process.env.PORT || 81; - -apiValidator.loadSchemas - .then(() => { - - internalSsl.initTimer(); - - const server = app.listen(port, () => { - logger.info('PID ' + process.pid + ' listening on port ' + port + ' ...'); - - process.on('SIGTERM', () => { - logger.info('PID ' + process.pid + ' received SIGTERM'); - server.close(() => { - logger.info('Stopping.'); - process.exit(0); - }); - }); - }); - }) - .catch((err) => { - logger.error(err); - process.exit(1); - }); diff --git a/manager/src/backend/internal/access.js b/manager/src/backend/internal/access.js deleted file mode 100644 index 4fe8ae5b..00000000 --- a/manager/src/backend/internal/access.js +++ /dev/null @@ -1,261 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const fs = require('fs'); -const batchflow = require('batchflow'); -const db = require('../db'); -const logger = require('../logger'); -const internalNginx = require('./nginx'); -const utils = require('../lib/utils'); - -const internalAccess = { - - /** - * All Access Lists - * - * @returns {Promise} - */ - getAll: () => { - return new Promise((resolve/*, reject*/) => { - resolve(db.access.find()); - }) - .then(list => { - _.map(list, (list_item, idx) => { - list[idx] = internalAccess.maskItems(list_item); - list[idx].hosts = db.hosts.find({access_list_id: list_item._id}); - }); - - return list; - }); - }, - - /** - * Specific Access List - * - * @param {String} id - * @returns {Promise} - */ - get: id => { - return new Promise((resolve/*, reject*/) => { - resolve(db.access.findOne({_id: id})); - }) - .then(list => { - if (list) { - return internalAccess.maskItems(list); - } - - list.hosts = db.hosts.find({access_list_id: list._id}); - - return list; - }); - }, - - /** - * Create a Access List - * - * @param {Object} payload - * @returns {Promise} - */ - create: payload => { - return new Promise((resolve/*, reject*/) => { - // Add list to db - resolve(db.access.save(payload)); - }) - .then(list => { - return internalAccess.build(list) - .then(() => { - return internalAccess.maskItems(list); - }); - }); - }, - - /** - * Update a Access List - * - * @param {String} id - * @param {Object} payload - * @returns {Promise} - */ - update: (id, payload) => { - return new Promise((resolve, reject) => { - // get existing list - let list = db.access.findOne({_id: id}); - - if (!list) { - reject(new error.ValidationError('Access List not found')); - } else { - - if (typeof payload.name !== 'undefined') { - list.name = payload.name; - } - - if (typeof payload.items !== 'undefined') { - // go through each of the items in the payload and assess how they apply to the original items - let new_items = []; - _.map(payload.items, (payload_item) => { - if (!payload_item.password) { - // try to find original item and use the password from there, this is essentially keeping existing users - let old = _.find(list.items, {username: payload_item.username}); - if (old) { - new_items.push(old); - } - } else { - new_items.push(payload_item); - } - }); - - list.items = new_items; - } - - db.access.update({_id: id}, list, {multi: false, upsert: false}); - resolve(list); - } - }) - .then(list => { - return internalAccess.build(list) - .then(() => { - return internalAccess.maskItems(list); - }); - }); - }, - - /** - * Deletes a Access List - * - * @param {String} id - * @returns {Promise} - */ - delete: id => { - const internalHost = require('./host'); - let associated_hosts = db.hosts.find({access_list_id: id}); - - return new Promise((resolve/*, reject*/) => { - db.hosts.update({access_list_id: id}, {access_list_id: ''}, {multi: true, upsert: false}); - - if (associated_hosts.length) { - // regenerate config for these hosts - let promises = []; - - _.map(associated_hosts, associated_host => { - promises.push(internalHost.configure(db.hosts.findOne({_id: associated_host._id}))); - }); - - resolve(Promise.all(promises)); - } else { - resolve(); - } - }) - .then(() => { - // restart nginx - if (associated_hosts.length) { - return internalNginx.reload(); - } - }) - .then(() => { - db.access.remove({_id: id}, false); - - // delete access file - try { - fs.unlinkSync(internalAccess.getFilename(id)); - } catch (err) { - // do nothing - } - - return true; - }); - }, - - /** - * @param {Object} list - * @returns {Object} - */ - maskItems: list => { - if (list && typeof list.items !== 'undefined') { - _.map(list.items, (val, idx) => { - list.items[idx].hint = val.password.charAt(0) + ('*').repeat(val.password.length - 1); - list.items[idx].password = ''; - }); - } - - return list; - }, - - /** - * @param {String|Object} list - * @returns {String} - */ - getFilename: (list) => { - return '/config/access/' + (typeof list === 'string' ? list : list._id); - }, - - /** - * @param {Object} list - * @returns {Promise} - */ - build: list => { - logger.info('Building Access file for: ' + list.name); - - return new Promise((resolve, reject) => { - if (typeof list._id !== 'undefined') { - - let htpasswd_file = internalAccess.getFilename(list); - - // 1. remove any existing access file - try { - fs.unlinkSync(htpasswd_file); - } catch (err) { - // do nothing - } - - // 2. create empty access file - try { - fs.writeFileSync(htpasswd_file, '', {encoding: 'utf8'}); - resolve(htpasswd_file); - } catch (err) { - reject(err); - } - } else { - reject(new Error('List does not have an _id')); - } - }) - .then(htpasswd_file => { - // 3. generate password for each user - if (list.items.length) { - return new Promise((resolve, reject) => { - batchflow(list.items).sequential() - .each((i, item, next) => { - if (typeof item.password !== 'undefined' && item.password.length) { - logger.info('Adding: ' + item.username); - - utils.exec('/usr/bin/htpasswd -b "' + htpasswd_file + '" "' + item.username + '" "' + item.password + '"') - .then((/*result*/) => { - next(); - }) - .catch(err => { - logger.error(err); - next(err); - }); - } - }) - .error(err => { - logger.error(err); - reject(err); - }) - .end(results => { - logger.info('Built Access file for: ' + list.name); - resolve(results); - }); - }); - } - }) - .then(() => { - // only reload nginx if any hosts are using this access - let hosts = db.hosts.find({access_list_id: list._id}); - if (hosts && hosts.length) { - return internalNginx.reload(); - } - }); - } -}; - -module.exports = internalAccess; diff --git a/manager/src/backend/internal/host.js b/manager/src/backend/internal/host.js deleted file mode 100644 index 72f55d28..00000000 --- a/manager/src/backend/internal/host.js +++ /dev/null @@ -1,280 +0,0 @@ -'use strict'; - -const _ = require('lodash'); - -const db = require('../db'); -const error = require('../lib/error'); -const internalAccess = require('./access'); -const internalSsl = require('./ssl'); -const internalNginx = require('./nginx'); -const timestamp = require('unix-timestamp'); - -timestamp.round = true; - -const internalHost = { - - /** - * All Hosts - * - * @returns {Promise} - */ - getAll: () => { - return new Promise((resolve/*, reject*/) => { - resolve(db.hosts.find()); - }) - .then(hosts => { - _.map(hosts, (host, idx) => { - if (typeof host.access_list_id !== 'undefined' && host.access_list_id) { - hosts[idx].access_list = internalAccess.maskItems(db.access.findOne({_id: host.access_list_id})); - } else { - hosts[idx].access_list_id = ''; - hosts[idx].access_list = null; - } - }); - - return hosts; - }); - }, - - /** - * Create a Host - * - * @param {Object} payload - * @returns {Promise} - */ - create: payload => { - return new Promise((resolve, reject) => { - let existing_host = false; - - if (payload.type === 'stream') { - // Check that the incoming port doesn't already exist - existing_host = db.hosts.findOne({incoming_port: payload.incoming_port}); - - if (payload.incoming_port === 80 || payload.incoming_port === 81 || payload.incoming_port === 443) { - reject(new error.ConfigurationError('Port ' + payload.incoming_port + ' is reserved')); - return; - } - - } else { - payload.hostname = payload.hostname.toLowerCase(); - - // Check that the hostname doesn't already exist - existing_host = db.hosts.findOne({hostname: payload.hostname}); - } - - if (existing_host) { - reject(new error.ValidationError('Hostname already exists')); - } else { - // Add host to db - let host = db.hosts.save(payload); - - // Fire the config generation for this host - internalHost.configure(host, true) - .then((/*result*/) => { - resolve(host); - }) - .catch(err => { - reject(err); - }); - } - }) - .catch(err => { - // Remove host if the configuration failed - if (err instanceof error.ConfigurationError) { - db.hosts.remove({hostname: payload.hostname}); - internalNginx.deleteConfig(payload); - internalSsl.deleteCerts(payload); - } - - throw err; - }); - }, - - /** - * Update a Host - * - * @param {String} id - * @param {Object} payload - * @returns {Promise} - */ - update: (id, payload) => { - return new Promise((resolve, reject) => { - let original_host = db.hosts.findOne({_id: id}); - - if (!original_host) { - reject(new error.ValidationError('Host not found')); - } else { - // Enforce lowercase hostnames - if (typeof payload.hostname !== 'undefined') { - payload.hostname = payload.hostname.toLowerCase(); - } - - // Check that the hostname doesn't already exist - let other_host = false; - - if (typeof payload.incoming_port !== 'undefined') { - other_host = db.hosts.findOne({incoming_port: payload.incoming_port}); - } else { - other_host = db.hosts.findOne({hostname: payload.hostname}); - } - - if (other_host && other_host._id !== id) { - reject(new error.ValidationError((other_host.type === 'stream' ? 'Source Stream Port' : 'Hostname') + ' already exists')); - } else { - // 2. Update host - db.hosts.update({_id: id}, payload, {multi: false, upsert: false}); - let updated_host = db.hosts.findOne({_id: id}); - - resolve({ - original: original_host, - updated: updated_host - }); - } - } - }) - .then(data => { - if (data.original.hostname !== data.updated.hostname) { - // Hostname has changed, delete the old file - return internalNginx.deleteConfig(data.original) - .then(() => { - return data; - }); - } - - return data; - }) - .then(data => { - if (data.updated.type !== 'stream') { - if ( - (data.original.ssl && !data.updated.ssl) || // ssl was enabled and is now disabled - (data.original.ssl && data.original.hostname !== data.updated.hostname) // hostname was changed for a previously ssl-enabled host - ) { - // SSL was turned off or hostname for ssl has changed so we should remove certs for the original - return internalSsl.deleteCerts(data.original) - .then(() => { - return data; - }); - } - } - - return data; - }) - .then(data => { - // 3. Fire the config generation for this host - return internalHost.configure(data.updated, true) - .then((/*result*/) => { - return data.updated; - }); - }); - }, - - /** - * This will create the nginx config for the host and fire off letsencrypt duties if required - * - * @param {Object} host - * @param {Boolean} [reload_nginx] - * @returns {Promise} - */ - configure: (host, reload_nginx) => { - return new Promise((resolve/*, reject*/) => { - resolve(internalNginx.deleteConfig(host)); - }) - .then(() => { - if (host.ssl && !internalSsl.hasValidSslCerts(host)) { - return internalSsl.configureSsl(host); - } - }) - .then(() => { - return internalNginx.generateConfig(host); - }) - .then(() => { - if (reload_nginx) { - return internalNginx.reload(); - } - }); - }, - - /** - * Deletes a Host - * - * @param {String} id - * @returns {Promise} - */ - delete: id => { - let existing_host = db.hosts.findOne({_id: id}); - return new Promise((resolve, reject) => { - if (existing_host) { - db.hosts.update({_id: id}, {is_deleted: true}, {multi: true, upsert: false}); - resolve(internalNginx.deleteConfig(existing_host)); - } else { - reject(new error.ValidationError('Hostname does not exist')); - } - }) - .then(() => { - if (existing_host.ssl) { - return internalSsl.deleteCerts(existing_host); - } - }) - .then(() => { - db.hosts.remove({_id: id}, false); - return internalNginx.reload(); - }) - .then(() => { - return true; - }); - }, - - /** - * Reconfigure a Host - * - * @param {String} id - * @returns {Promise} - */ - reconfigure: id => { - return new Promise((resolve, reject) => { - let host = db.hosts.findOne({_id: id}); - - if (!host) { - reject(new error.ValidationError('Host does not exist: ' + id)); - } else { - // 3. Fire the config generation for this host - internalHost.configure(host, true) - .then((/*result*/) => { - resolve(host); - }) - .catch(err => { - reject(err); - }); - } - }); - }, - - /** - * Renew SSL for a Host - * - * @param {String} id - * @returns {Promise} - */ - renew: id => { - return new Promise((resolve, reject) => { - let host = db.hosts.findOne({_id: id}); - - if (!host) { - reject(new error.ValidationError('Host does not exist')); - } else if (!host.ssl) { - reject(new error.ValidationError('Host does not have SSL enabled')); - } else { - // 3. Fire the ssl and config generation for this host, forcing ssl - internalSsl.renewSsl(host) - .then((/*result*/) => { - resolve(host); - }) - .catch(err => { - reject(err); - }); - } - }); - } -}; - -module.exports = internalHost; diff --git a/manager/src/backend/internal/nginx.js b/manager/src/backend/internal/nginx.js deleted file mode 100644 index c05d50d8..00000000 --- a/manager/src/backend/internal/nginx.js +++ /dev/null @@ -1,86 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const ejs = require('ejs'); -const logger = require('../logger'); -const utils = require('../lib/utils'); -const error = require('../lib/error'); - -const internalNginx = { - - /** - * @returns {Promise} - */ - test: () => { - logger.info('Testing Nginx configuration'); - return utils.exec('/usr/sbin/nginx -t'); - }, - - /** - * @returns {Promise} - */ - reload: () => { - return internalNginx.test() - .then(() => { - logger.info('Reloading Nginx'); - return utils.exec('/usr/sbin/nginx -s reload'); - }); - }, - - /** - * @param {Object} host - * @returns {String} - */ - getConfigName: host => { - if (host.type === 'stream') { - return '/config/nginx/stream/' + host.incoming_port + '.conf'; - } - - return '/config/nginx/' + host.hostname + '.conf'; - }, - - /** - * @param {Object} host - * @returns {Promise} - */ - generateConfig: host => { - return new Promise((resolve, reject) => { - let template = null; - let filename = internalNginx.getConfigName(host); - - try { - if (typeof host.type === 'undefined' || !host.type) { - host.type = 'proxy'; - } - - template = fs.readFileSync(__dirname + '/../templates/' + host.type + '.conf.ejs', {encoding: 'utf8'}); - let config_text = ejs.render(template, host); - fs.writeFileSync(filename, config_text, {encoding: 'utf8'}); - resolve(true); - } catch (err) { - reject(new error.ConfigurationError(err.message)); - } - }); - }, - - /** - * @param {Object} host - * @param {Boolean} [throw_errors] - * @returns {Promise} - */ - deleteConfig: (host, throw_errors) => { - return new Promise((resolve, reject) => { - try { - fs.unlinkSync(internalNginx.getConfigName(host)); - } catch (err) { - if (throw_errors) { - reject(err); - } - } - - resolve(); - }); - } -}; - -module.exports = internalNginx; diff --git a/manager/src/backend/internal/ssl.js b/manager/src/backend/internal/ssl.js deleted file mode 100644 index f631c278..00000000 --- a/manager/src/backend/internal/ssl.js +++ /dev/null @@ -1,137 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const ejs = require('ejs'); -const timestamp = require('unix-timestamp'); -const internalNginx = require('./nginx'); -const logger = require('../logger'); -const utils = require('../lib/utils'); -const error = require('../lib/error'); - -timestamp.round = true; - -const internalSsl = { - - interval_timeout: 1000 * 60 * 60 * 6, // 6 hours - interval: null, - interval_processing: false, - - initTimer: () => { - internalSsl.interval = setInterval(internalSsl.processExpiringHosts, internalSsl.interval_timeout); - }, - - /** - * Triggered by a timer, this will check for expiring hosts and renew their ssl certs if required - */ - processExpiringHosts: () => { - if (!internalSsl.interval_processing) { - logger.info('Renewing SSL certs close to expiry...'); - return utils.exec('/usr/bin/certbot renew -q') - .then(result => { - logger.info(result); - internalSsl.interval_processing = false; - - return internalNginx.reload() - .then(() => { - logger.info('Renew Complete'); - return result; - }); - }) - .catch(err => { - logger.error(err); - internalSsl.interval_processing = false; - }); - } - }, - - /** - * @param {Object} host - * @returns {Boolean} - */ - hasValidSslCerts: host => { - return fs.existsSync('/etc/letsencrypt/live/' + host.hostname + '/fullchain.pem') && - fs.existsSync('/etc/letsencrypt/live/' + host.hostname + '/privkey.pem'); - }, - - /** - * @param {Object} host - * @returns {Promise} - */ - requestSsl: host => { - logger.info('Requesting SSL certificates for ' + host.hostname); - - return utils.exec('/usr/bin/letsencrypt certonly --agree-tos --email "' + host.letsencrypt_email + '" -n -a webroot -d "' + host.hostname + '"') - .then(result => { - logger.info(result); - return result; - }); - }, - - /** - * @param {Object} host - * @returns {Promise} - */ - renewSsl: host => { - logger.info('Renewing SSL certificates for ' + host.hostname); - - return utils.exec('/usr/bin/certbot renew --force-renewal --disable-hook-validation --cert-name "' + host.hostname + '"') - .then(result => { - logger.info(result); - return result; - }); - }, - - /** - * @param {Object} host - * @returns {Promise} - */ - deleteCerts: host => { - logger.info('Deleting SSL certificates for ' + host.hostname); - - return utils.exec('/usr/bin/certbot delete -n --cert-name "' + host.hostname + '"') - .then(result => { - logger.info(result); - }) - .catch(err => { - logger.error(err); - }); - }, - - /** - * @param {Object} host - * @returns {Promise} - */ - generateSslSetupConfig: host => { - let template = null; - let filename = internalNginx.getConfigName(host); - let template_data = host; - - return new Promise((resolve, reject) => { - try { - template = fs.readFileSync(__dirname + '/../templates/letsencrypt.conf.ejs', {encoding: 'utf8'}); - let config_text = ejs.render(template, template_data); - fs.writeFileSync(filename, config_text, {encoding: 'utf8'}); - - resolve(template_data); - } catch (err) { - reject(new error.ConfigurationError(err.message)); - } - }); - }, - - /** - * @param {Object} host - * @returns {Promise} - */ - configureSsl: host => { - return internalSsl.generateSslSetupConfig(host) - .then(data => { - return internalNginx.reload() - .then(() => { - return internalSsl.requestSsl(data); - }); - }); - } -}; - -module.exports = internalSsl; diff --git a/manager/src/backend/lib/error.js b/manager/src/backend/lib/error.js deleted file mode 100644 index 92ae3e61..00000000 --- a/manager/src/backend/lib/error.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const util = require('util'); - -module.exports = { - - ItemNotFoundError: function (id, previous) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.previous = previous; - this.message = 'Item Not Found - ' + id; - this.public = true; - this.status = 404; - }, - - InternalError: function (message, previous) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.previous = previous; - this.message = message; - this.status = 500; - this.public = false; - }, - - InternalValidationError: function (message, previous) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.previous = previous; - this.message = message; - this.status = 400; - this.public = false; - }, - - ConfigurationError: function (message, previous) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.previous = previous; - this.message = message; - this.status = 400; - this.public = true; - }, - - ValidationError: function (message, previous) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.previous = previous; - this.message = message; - this.public = true; - this.status = 400; - } -}; - -_.forEach(module.exports, function (error) { - util.inherits(error, Error); -}); diff --git a/manager/src/backend/logger.js b/manager/src/backend/logger.js deleted file mode 100644 index 7b37e065..00000000 --- a/manager/src/backend/logger.js +++ /dev/null @@ -1,30 +0,0 @@ -const winston = require('winston'); - -winston.remove(winston.transports.Console); - -winston.add(winston.transports.Console, { - colorize: true, - timestamp: true, - prettyPrint: true, - depth: 3 -}); - -winston.setLevels({ - error: 0, - warn: 1, - info: 2, - success: 2, - verbose: 3, - debug: 4 -}); - -winston.addColors({ - error: 'red', - warn: 'yellow', - info: 'cyan', - success: 'green', - verbose: 'blue', - debug: 'magenta' -}); - -module.exports = winston; diff --git a/manager/src/backend/routes/api/access.js b/manager/src/backend/routes/api/access.js deleted file mode 100644 index 544b5f2f..00000000 --- a/manager/src/backend/routes/api/access.js +++ /dev/null @@ -1,121 +0,0 @@ -'use strict'; - -const express = require('express'); -const validator = require('../../lib/validator'); -const apiValidator = require('../../lib/validator/api'); -const internalAccess = require('../../internal/access'); - -let router = express.Router({ - caseSensitive: true, - strict: true, - mergeParams: true -}); - -/** - * /api/access - */ -router - .route('/') - .options((req, res) => { - res.sendStatus(204); - }) - - /** - * GET /api/access - * - * Retrieve all access lists - */ - .get((req, res, next) => { - internalAccess.getAll() - .then(lists => { - res.status(200) - .send(lists); - }) - .catch(next); - }) - - /** - * POST /api/access - * - * Create a new Access List - */ - .post((req, res, next) => { - apiValidator({$ref: 'endpoints/access#/links/1/schema'}, req.body) - .then(payload => { - return internalAccess.create(payload); - }) - .then(result => { - res.status(201) - .send(result); - }) - .catch(next); - }); - -/** - * Specific Access List - * - * /api/access/123 - */ -router - .route('/:list_id') - .options((req, res) => { - res.sendStatus(204); - }) - - /** - * GET /access/123 - * - * Retrieve a specific Access List - */ - .get((req, res, next) => { - validator({ - required: ['list_id'], - additionalProperties: false, - properties: { - list_id: { - $ref: 'definitions#/definitions/id' - } - } - }, req.params) - .then(data => { - return internalAccess.get(data.list_id); - }) - .then(list => { - res.status(200) - .send(list); - }) - .catch(next); - }) - - /** - * PUT /api/access/123 - * - * Update an existing Access List - */ - .put((req, res, next) => { - apiValidator({$ref: 'endpoints/access#/links/2/schema'}, req.body) - .then(payload => { - return internalAccess.update(req.params.list_id, payload); - }) - .then(result => { - res.status(200) - .send(result); - }) - .catch(next); - }) - - /** - * DELETE /api/access/123 - * - * Delete an existing Access List - */ - .delete((req, res, next) => { - internalAccess.delete(req.params.list_id) - .then(result => { - res.status(200) - .send(result); - }) - .catch(next); - }); - -module.exports = router; diff --git a/manager/src/backend/routes/api/hosts.js b/manager/src/backend/routes/api/hosts.js deleted file mode 100644 index 0d300ed4..00000000 --- a/manager/src/backend/routes/api/hosts.js +++ /dev/null @@ -1,155 +0,0 @@ -'use strict'; - -const express = require('express'); -const validator = require('../../lib/validator'); -const apiValidator = require('../../lib/validator/api'); -const internalHost = require('../../internal/host'); - -let router = express.Router({ - caseSensitive: true, - strict: true, - mergeParams: true -}); - -/** - * /api/hosts - */ -router - .route('/') - .options((req, res) => { - res.sendStatus(204); - }) - - /** - * GET /api/hosts - * - * Retrieve all hosts - */ - .get((req, res, next) => { - internalHost.getAll() - .then(hosts => { - res.status(200) - .send(hosts); - }) - .catch(next); - }) - - /** - * POST /api/hosts - * - * Create a new Host - */ - .post((req, res, next) => { - apiValidator({$ref: 'endpoints/hosts#/links/1/schema'}, req.body) - .then(payload => { - return internalHost.create(payload); - }) - .then(result => { - res.status(201) - .send(result); - }) - .catch(next); - }); - -/** - * Specific Host - * - * /api/hosts/123 - */ -router - .route('/:host_id') - .options((req, res) => { - res.sendStatus(204); - }) - - /** - * GET /hosts/123 - * - * Retrieve a specific Host - */ - .get((req, res, next) => { - validator({ - required: ['host_id'], - additionalProperties: false, - properties: { - host_id: { - $ref: 'definitions#/definitions/_id' - } - } - }, req.params) - .then(data => { - return internalHost.get(data.host_id); - }) - .then(host => { - res.status(200) - .send(host); - }) - .catch(next); - }) - - /** - * PUT /api/hosts/123 - * - * Update an existing Host - */ - .put((req, res, next) => { - apiValidator({$ref: 'endpoints/hosts#/links/2/schema'}, req.body) - .then(payload => { - return internalHost.update(req.params.host_id, payload); - }) - .then(result => { - res.status(200) - .send(result); - }) - .catch(next); - }) - - /** - * DELETE /api/hosts/123 - * - * Delete an existing Host - */ - .delete((req, res, next) => { - internalHost.delete(req.params.host_id) - .then(result => { - res.status(200) - .send(result); - }) - .catch(next); - }); - -/** - * Reconfigure Host Action - * - * /api/hosts/123/reconfigure - */ -router - .route('/:host_id/reconfigure') - .options((req, res) => { - res.sendStatus(204); - }) - - /** - * POST /api/hosts/123/reconfigure - */ - .post((req, res, next) => { - validator({ - required: ['host_id'], - additionalProperties: false, - properties: { - host_id: { - $ref: 'definitions#/definitions/_id' - } - } - }, req.params) - .then(data => { - return internalHost.reconfigure(data.host_id); - }) - .then(result => { - res.status(200) - .send(result); - }) - .catch(next); - }); - -module.exports = router; diff --git a/manager/src/backend/routes/api/main.js b/manager/src/backend/routes/api/main.js deleted file mode 100644 index 378be3ab..00000000 --- a/manager/src/backend/routes/api/main.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -const express = require('express'); -const pjson = require('../../../../package.json'); - -let router = express.Router({ - caseSensitive: true, - strict: true, - mergeParams: true -}); - -/** - * GET /api - */ -router.get('/', (req, res/*, next*/) => { - let version = pjson.version.split('-').shift().split('.'); - - res.status(200).send({ - status: 'Healthy', - version: { - major: parseInt(version.shift(), 10), - minor: parseInt(version.shift(), 10), - revision: parseInt(version.shift(), 10) - } - }); -}); - -router.use('/hosts', require('./hosts')); -router.use('/access', require('./access')); - -module.exports = router; diff --git a/manager/src/backend/routes/main.js b/manager/src/backend/routes/main.js deleted file mode 100644 index 0b0ab4c7..00000000 --- a/manager/src/backend/routes/main.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -const express = require('express'); -const fs = require('fs'); - -const router = express.Router({ - caseSensitive: true, - strict: true, - mergeParams: true -}); - -/** - * Health Check - * GET /health - */ -router.get('/health', (req, res/*, next*/) => { - res.status(200).send({ - status: 'Healthy' - }); -}); - -/** - * GET .* - */ -router.get(/(.*)/, function (req, res, next) { - req.params.page = req.params['0']; - if (req.params.page === '/') { - req.params.page = '/index.html'; - } - - fs.readFile('dist' + req.params.page, 'utf8', function (err, data) { - if (err) { - if (req.params.page !== '/index.html') { - fs.readFile('dist/index.html', 'utf8', function (err2, data) { - if (err2) { - next(err); - } else { - res.contentType('text/html').end(data); - } - }); - } else { - next(err); - } - } else { - res.contentType('text/html').end(data); - } - }); -}); - -module.exports = router; diff --git a/manager/src/backend/schema/definitions.json b/manager/src/backend/schema/definitions.json deleted file mode 100644 index 58822cd6..00000000 --- a/manager/src/backend/schema/definitions.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-06/schema#", - "id": "definitions", - "definitions": { - "id": { - "description": "Unique identifier", - "example": 123456, - "readOnly": true, - "type": "integer", - "minimum": 1 - }, - "_id": { - "description": "Unique identifier", - "example": "dfgbkjwj23asdad23gbweg", - "readOnly": true, - "type": "string", - "minLength": 1 - }, - "hostname": { - "definition": "Fully Qualified Host Name", - "type": "string", - "minLength": 2, - "example": "myhost.example.com" - }, - "expand": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - } - ] - }, - "sort": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "field", - "dir" - ], - "additionalProperties": false, - "properties": { - "field": { - "type": "string" - }, - "dir": { - "type": "string", - "pattern": "^(asc|desc)$" - } - } - } - } - } -} diff --git a/manager/src/backend/schema/endpoints/access.json b/manager/src/backend/schema/endpoints/access.json deleted file mode 100644 index b66f888d..00000000 --- a/manager/src/backend/schema/endpoints/access.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-06/schema#", - "id": "endpoints/access", - "title": "Access", - "description": "Endpoints relating to Access Lists", - "stability": "stable", - "type": "object", - "definitions": { - "_id": { - "type": "string", - "readonly": true - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - "items": { - "type": "array", - "items": { - "type": "object", - "required": [ - "username", - "password" - ], - "properties": { - "username": { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - "password": { - "type": "string", - "minLength": 0, - "maxLength": 50 - }, - "hint": { - "type": "string", - "minLength": 0, - "maxLength": 50 - } - } - } - }, - "hosts": { - "type": "array", - "items": { - "type": "object" - } - } - }, - "links": [ - { - "title": "List", - "description": "Returns a list of Access Lists", - "href": "/access", - "access": "public", - "method": "GET", - "rel": "self", - "targetSchema": { - "type": "array", - "items": { - "$ref": "#/properties" - } - } - }, - { - "title": "Create", - "description": "Creates a new Access List", - "href": "/access", - "access": "public", - "method": "POST", - "rel": "create", - "schema": { - "type": "object", - "required": [ - "name", - "items" - ], - "properties": { - "name": { - "$ref": "#/definitions/name" - }, - "items": { - "$ref": "#/definitions/items" - } - } - }, - "targetSchema": { - "properties": { - "$ref": "#/properties" - } - } - }, - { - "title": "Update", - "description": "Updates a Access List", - "href": "/hosts/{definitions.identity.example}", - "access": "public", - "method": "PUT", - "rel": "update", - "schema": { - "type": "object", - "required": [ - "name", - "items" - ], - "properties": { - "name": { - "$ref": "#/definitions/name" - }, - "items": { - "$ref": "#/definitions/items" - } - } - }, - "targetSchema": { - "properties": { - "$ref": "#/properties" - } - } - }, - { - "title": "Delete", - "description": "Deletes a existing Access List", - "href": "/hosts/{definitions.identity.example}", - "access": "public", - "method": "DELETE", - "rel": "delete", - "targetSchema": { - "type": "boolean" - } - } - ], - "properties": { - "_id": { - "$ref": "#/definitions/_id" - }, - "name": { - "$ref": "#/definitions/name" - }, - "items": { - "$ref": "#/definitions/items" - }, - "hosts": { - "$ref": "#/definitions/hosts" - } - } -} diff --git a/manager/src/backend/schema/endpoints/hosts.json b/manager/src/backend/schema/endpoints/hosts.json deleted file mode 100644 index c56ed69a..00000000 --- a/manager/src/backend/schema/endpoints/hosts.json +++ /dev/null @@ -1,272 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-06/schema#", - "id": "endpoints/hosts", - "title": "Hosts", - "description": "Endpoints relating to Hosts", - "stability": "stable", - "type": "object", - "definitions": { - "_id": { - "type": "string", - "readonly": true - }, - "type": { - "type": "string", - "pattern": "^(proxy|redirection|404|stream)$" - }, - "hostname": { - "$ref": "../definitions.json#/definitions/hostname" - }, - "forward_server": { - "type": "string", - "format": "ipv4" - }, - "forward_host": { - "type": "string" - }, - "forward_port": { - "type": "integer", - "minumum": 1, - "maxiumum": 65535 - }, - "asset_caching": { - "type": "boolean" - }, - "block_exploits": { - "type": "boolean" - }, - "ssl": { - "type": "boolean" - }, - "letsencrypt_email": { - "type": "string", - "format": "email" - }, - "force_ssl": { - "type": "boolean" - }, - "access_list_id": { - "type": "string" - }, - "advanced": { - "type": "string" - }, - "access_list": { - "type": "object", - "readonly": true - }, - "incoming_port": { - "type": "integer", - "minumum": 1, - "maxiumum": 65535 - }, - "protocols": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "links": [ - { - "title": "List", - "description": "Returns a list of Hosts", - "href": "/hosts", - "access": "public", - "method": "GET", - "rel": "self", - "targetSchema": { - "type": "array", - "items": { - "$ref": "#/properties" - } - } - }, - { - "title": "Create", - "description": "Creates a new Host", - "href": "/hosts", - "access": "public", - "method": "POST", - "rel": "create", - "schema": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "$ref": "#/definitions/type" - }, - "hostname": { - "$ref": "#/definitions/hostname" - }, - "forward_host": { - "$ref": "#/definitions/forward_host" - }, - "forward_server": { - "$ref": "#/definitions/forward_server" - }, - "forward_port": { - "$ref": "#/definitions/forward_port" - }, - "asset_caching": { - "$ref": "#/definitions/asset_caching" - }, - "block_exploits": { - "$ref": "#/definitions/block_exploits" - }, - "ssl": { - "$ref": "#/definitions/ssl" - }, - "letsencrypt_email": { - "$ref": "#/definitions/letsencrypt_email" - }, - "force_ssl": { - "$ref": "#/definitions/force_ssl" - }, - "advanced": { - "$ref": "#/definitions/advanced" - }, - "access_list_id": { - "$ref": "#/definitions/access_list_id" - }, - "incoming_port": { - "$ref": "#/definitions/incoming_port" - }, - "protocols": { - "$ref": "#/definitions/protocols" - } - } - }, - "targetSchema": { - "properties": { - "$ref": "#/properties" - } - } - }, - { - "title": "Update", - "description": "Updates a Host", - "href": "/hosts/{definitions.identity.example}", - "access": "public", - "method": "PUT", - "rel": "update", - "schema": { - "type": "object", - "required": [], - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/type" - }, - "hostname": { - "$ref": "#/definitions/hostname" - }, - "forward_host": { - "$ref": "#/definitions/forward_host" - }, - "forward_server": { - "$ref": "#/definitions/forward_server" - }, - "forward_port": { - "$ref": "#/definitions/forward_port" - }, - "asset_caching": { - "$ref": "#/definitions/asset_caching" - }, - "block_exploits": { - "$ref": "#/definitions/block_exploits" - }, - "ssl": { - "$ref": "#/definitions/ssl" - }, - "letsencrypt_email": { - "$ref": "#/definitions/letsencrypt_email" - }, - "force_ssl": { - "$ref": "#/definitions/force_ssl" - }, - "advanced": { - "$ref": "#/definitions/advanced" - }, - "access_list_id": { - "$ref": "#/definitions/access_list_id" - }, - "incoming_port": { - "$ref": "#/definitions/incoming_port" - }, - "protocols": { - "$ref": "#/definitions/protocols" - } - } - }, - "targetSchema": { - "properties": { - "$ref": "#/properties" - } - } - }, - { - "title": "Delete", - "description": "Deletes a existing Host", - "href": "/hosts/{definitions.identity.example}", - "access": "public", - "method": "DELETE", - "rel": "delete", - "targetSchema": { - "type": "boolean" - } - } - ], - "properties": { - "_id": { - "$ref": "#/definitions/_id" - }, - "type": { - "$ref": "#/definitions/type" - }, - "hostname": { - "$ref": "#/definitions/hostname" - }, - "forward_host": { - "$ref": "#/definitions/forward_host" - }, - "forward_server": { - "$ref": "#/definitions/forward_server" - }, - "forward_port": { - "$ref": "#/definitions/forward_port" - }, - "asset_caching": { - "$ref": "#/definitions/asset_caching" - }, - "block_exploits": { - "$ref": "#/definitions/block_exploits" - }, - "ssl": { - "$ref": "#/definitions/ssl" - }, - "letsencrypt_email": { - "$ref": "#/definitions/letsencrypt_email" - }, - "force_ssl": { - "$ref": "#/definitions/force_ssl" - }, - "access_list_id": { - "$ref": "#/definitions/access_list_id" - }, - "access_list": { - "$ref": "#/definitions/access_list" - }, - "advanced": { - "$ref": "#/definitions/advanced" - }, - "incoming_port": { - "$ref": "#/definitions/incoming_port" - }, - "protocols": { - "$ref": "#/definitions/protocols" - } - } -} diff --git a/manager/src/backend/schema/examples.json b/manager/src/backend/schema/examples.json deleted file mode 100644 index 1a95cfa6..00000000 --- a/manager/src/backend/schema/examples.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-06/schema#", - "id": "examples", - "type": "object", - "definitions": {} -} diff --git a/manager/src/backend/schema/index.json b/manager/src/backend/schema/index.json deleted file mode 100644 index ce056d31..00000000 --- a/manager/src/backend/schema/index.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-06/schema#", - "title": "Nginx Proxy Manager REST API", - "description": "This is the Nginx Proxy Manager REST API", - "id": "root", - "version": "1.0.0", - "links": [ - { - "href": "http://localhost:81/api", - "rel": "self" - } - ], - "properties": { - "hosts": { - "$ref": "endpoints/hosts.json" - }, - "access": { - "$ref": "endpoints/access.json" - } - } -} diff --git a/manager/src/backend/templates/404.conf.ejs b/manager/src/backend/templates/404.conf.ejs deleted file mode 100644 index d136541a..00000000 --- a/manager/src/backend/templates/404.conf.ejs +++ /dev/null @@ -1,19 +0,0 @@ -# <%- hostname %> -server { - listen 80; - <%- typeof ssl !== 'undefined' && ssl ? 'listen 443 ssl;' : '' %> - - server_name <%- hostname %>; - - access_log /config/logs/<%- hostname %>.log proxy; - -<% if (typeof ssl !== 'undefined' && ssl) { -%> - include conf.d/include/ssl-ciphers.conf; - ssl_certificate /etc/letsencrypt/live/<%- hostname %>/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/<%- hostname %>/privkey.pem; -<% } -%> - - <%- typeof advanced !== 'undefined' && advanced ? advanced : '' %> - - return 404; -} diff --git a/manager/src/backend/templates/letsencrypt.conf.ejs b/manager/src/backend/templates/letsencrypt.conf.ejs deleted file mode 100644 index f870f2ea..00000000 --- a/manager/src/backend/templates/letsencrypt.conf.ejs +++ /dev/null @@ -1,11 +0,0 @@ -# Letsencrypt Verification Temporary Host: <%- hostname %> -server { - listen 80; - server_name <%- hostname %>; - - access_log /config/logs/letsencrypt.log proxy; - - location / { - root /config/letsencrypt-acme-challenge; - } -} diff --git a/manager/src/backend/templates/proxy.conf.ejs b/manager/src/backend/templates/proxy.conf.ejs deleted file mode 100644 index 4f320360..00000000 --- a/manager/src/backend/templates/proxy.conf.ejs +++ /dev/null @@ -1,33 +0,0 @@ -# <%- hostname %> -server { - listen 80; - <%- typeof ssl !== 'undefined' && ssl ? 'listen 443 ssl;' : '' %> - - server_name <%- hostname %>; - - access_log /config/logs/<%- hostname %>.log proxy; - - set $server <%- forward_server %>; - set $port <%- forward_port %>; - - <%- typeof asset_caching !== 'undefined' && asset_caching ? 'include conf.d/include/assets.conf;' : '' %> - <%- typeof block_exploits !== 'undefined' && block_exploits ? 'include conf.d/include/block-exploits.conf;' : '' %> - -<% if (typeof ssl !== 'undefined' && ssl) { -%> - include conf.d/include/letsencrypt-acme-challenge.conf; - include conf.d/include/ssl-ciphers.conf; - ssl_certificate /etc/letsencrypt/live/<%- hostname %>/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/<%- hostname %>/privkey.pem; -<% } -%> - -<%- typeof advanced !== 'undefined' && advanced ? advanced : '' %> - - location / { - <% if (typeof access_list_id !== 'undefined' && access_list_id) { -%> - auth_basic "Authorization required"; - auth_basic_user_file /config/access/<%- access_list_id %>; - <% } -%> - <%- typeof force_ssl !== 'undefined' && force_ssl ? 'include conf.d/include/force-ssl.conf;' : '' %> - include conf.d/include/proxy.conf; - } -} diff --git a/manager/src/backend/templates/redirection.conf.ejs b/manager/src/backend/templates/redirection.conf.ejs deleted file mode 100644 index 1c4f91b5..00000000 --- a/manager/src/backend/templates/redirection.conf.ejs +++ /dev/null @@ -1,22 +0,0 @@ -# <%- hostname %> -server { - listen 80; - <%- typeof ssl !== 'undefined' && ssl ? 'listen 443 ssl;' : '' %> - - server_name <%- hostname %>; - - access_log /config/logs/<%- hostname %>.log proxy; - - <%- typeof asset_caching !== 'undefined' && asset_caching ? 'include conf.d/include/assets.conf;' : '' %> - <%- typeof block_exploits !== 'undefined' && block_exploits ? 'include conf.d/include/block-exploits.conf;' : '' %> - -<% if (typeof ssl !== 'undefined' && ssl) { -%> - include conf.d/include/ssl-ciphers.conf; - ssl_certificate /etc/letsencrypt/live/<%- hostname %>/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/<%- hostname %>/privkey.pem; -<% } -%> - - <%- typeof advanced !== 'undefined' && advanced ? advanced : '' %> - - return 301 $scheme://<%- forward_host %>$request_uri; -} diff --git a/manager/src/backend/templates/stream.conf.ejs b/manager/src/backend/templates/stream.conf.ejs deleted file mode 100644 index 49994a26..00000000 --- a/manager/src/backend/templates/stream.conf.ejs +++ /dev/null @@ -1,11 +0,0 @@ -# <%- incoming_port %> - <%- protocols.join(',').toUpperCase() %> -<% -protocols.forEach(function (protocol) { -%> -server { - listen <%- incoming_port %> <%- protocol === 'tcp' ? '' : protocol %>; - proxy_pass <%- forward_server %>:<%- forward_port %>; -} -<% -}); -%> diff --git a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.eot b/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.eot deleted file mode 100755 index b93a4953..00000000 Binary files a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.eot and /dev/null differ diff --git a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.svg b/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.svg deleted file mode 100755 index 94fb5490..00000000 --- a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.svg +++ /dev/null @@ -1,288 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.ttf b/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.ttf deleted file mode 100755 index 1413fc60..00000000 Binary files a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.ttf and /dev/null differ diff --git a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.woff b/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.woff deleted file mode 100755 index 9e612858..00000000 Binary files a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.woff and /dev/null differ diff --git a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.woff2 b/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.woff2 deleted file mode 100755 index 64539b54..00000000 Binary files a/manager/src/frontend/fonts/bootstrap/glyphicons-halflings-regular.woff2 and /dev/null differ diff --git a/manager/src/frontend/fonts/font-awesome/FontAwesome.otf b/manager/src/frontend/fonts/font-awesome/FontAwesome.otf deleted file mode 100755 index 401ec0f3..00000000 Binary files a/manager/src/frontend/fonts/font-awesome/FontAwesome.otf and /dev/null differ diff --git a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.eot b/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.eot deleted file mode 100755 index e9f60ca9..00000000 Binary files a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.eot and /dev/null differ diff --git a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.svg b/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.svg deleted file mode 100755 index 855c845e..00000000 --- a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.svg +++ /dev/null @@ -1,2671 +0,0 @@ - - - - -Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 - By ,,, -Copyright Dave Gandy 2016. All rights reserved. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.ttf b/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.ttf deleted file mode 100755 index 35acda2f..00000000 Binary files a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.ttf and /dev/null differ diff --git a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.woff b/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.woff deleted file mode 100755 index 400014a4..00000000 Binary files a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.woff and /dev/null differ diff --git a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.woff2 b/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.woff2 deleted file mode 100755 index 4d13fc60..00000000 Binary files a/manager/src/frontend/fonts/font-awesome/fontawesome-webfont.woff2 and /dev/null differ diff --git a/manager/src/frontend/js/app/access/empty.ejs b/manager/src/frontend/js/app/access/empty.ejs deleted file mode 100644 index 382b17ea..00000000 --- a/manager/src/frontend/js/app/access/empty.ejs +++ /dev/null @@ -1,5 +0,0 @@ - -

-

It looks like there are no access lists configured.

-

- diff --git a/manager/src/frontend/js/app/access/main.ejs b/manager/src/frontend/js/app/access/main.ejs deleted file mode 100644 index 795e0a9b..00000000 --- a/manager/src/frontend/js/app/access/main.ejs +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - -
Access List NameUser CountHost Count
diff --git a/manager/src/frontend/js/app/access/row.ejs b/manager/src/frontend/js/app/access/row.ejs deleted file mode 100644 index 9aa77bd8..00000000 --- a/manager/src/frontend/js/app/access/row.ejs +++ /dev/null @@ -1,7 +0,0 @@ -<%- name %> -<%- items.length %> -<%- hosts.length %> - - - - diff --git a/manager/src/frontend/js/app/access_list/delete.ejs b/manager/src/frontend/js/app/access_list/delete.ejs deleted file mode 100644 index 1bb2cc56..00000000 --- a/manager/src/frontend/js/app/access_list/delete.ejs +++ /dev/null @@ -1,24 +0,0 @@ - diff --git a/manager/src/frontend/js/app/access_list/form.ejs b/manager/src/frontend/js/app/access_list/form.ejs deleted file mode 100644 index 31759521..00000000 --- a/manager/src/frontend/js/app/access_list/form.ejs +++ /dev/null @@ -1,30 +0,0 @@ - diff --git a/manager/src/frontend/js/app/access_list/item.ejs b/manager/src/frontend/js/app/access_list/item.ejs deleted file mode 100644 index 74c6baed..00000000 --- a/manager/src/frontend/js/app/access_list/item.ejs +++ /dev/null @@ -1,8 +0,0 @@ -
-
- -
-
- -
-
diff --git a/manager/src/frontend/js/app/cache.js b/manager/src/frontend/js/app/cache.js deleted file mode 100644 index c0c9df56..00000000 --- a/manager/src/frontend/js/app/cache.js +++ /dev/null @@ -1,5 +0,0 @@ -'use strict'; - -let cache = {}; - -module.exports = cache; diff --git a/manager/src/frontend/js/app/dashboard/empty.ejs b/manager/src/frontend/js/app/dashboard/empty.ejs deleted file mode 100644 index f45451ab..00000000 --- a/manager/src/frontend/js/app/dashboard/empty.ejs +++ /dev/null @@ -1,9 +0,0 @@ - -

-

It looks like there are no hosts configured.

-

- - - -

- diff --git a/manager/src/frontend/js/app/dashboard/main.ejs b/manager/src/frontend/js/app/dashboard/main.ejs deleted file mode 100644 index 6df4f241..00000000 --- a/manager/src/frontend/js/app/dashboard/main.ejs +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - -
SourceDestinationSSLAccess List -
- - -
-
diff --git a/manager/src/frontend/js/app/dashboard/row.ejs b/manager/src/frontend/js/app/dashboard/row.ejs deleted file mode 100644 index 19a3ba43..00000000 --- a/manager/src/frontend/js/app/dashboard/row.ejs +++ /dev/null @@ -1,49 +0,0 @@ - - <% if (type === 'stream') { %> - <%- incoming_port %> - <%- protocols.join(', ').toUpperCase() %> - <% } else { %> - <%- hostname %> - <% } %> - - - - <% if (type === 'proxy' || type === 'stream') { %> - <%- forward_server %>:<%- forward_port %> - <% } else if (type === 'redirection') { %> - <%- forward_host %> - <% } else if (type === '404') { %> - 404 - <% } %> - - - - <% if (type === 'stream') { %> - - - <% } else { %> - <% if (ssl && force_ssl) { %> - Forced - <% } else if (ssl) { %> - Enabled - <% } else { %> - No - <% } %> - <% } %> - - - <% if (type === 'stream') { %> - - - <% } else { %> - <% if (access_list) { %> - <%- access_list.name %> - <% } else { %> - None - <% } %> - <% } %> - - - - - - - diff --git a/manager/src/frontend/js/app/error/main.ejs b/manager/src/frontend/js/app/error/main.ejs deleted file mode 100644 index 68bafc09..00000000 --- a/manager/src/frontend/js/app/error/main.ejs +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/manager/src/frontend/js/app/host/404_form.ejs b/manager/src/frontend/js/app/host/404_form.ejs deleted file mode 100644 index 373a66dd..00000000 --- a/manager/src/frontend/js/app/host/404_form.ejs +++ /dev/null @@ -1,50 +0,0 @@ - diff --git a/manager/src/frontend/js/app/host/advanced.ejs b/manager/src/frontend/js/app/host/advanced.ejs deleted file mode 100644 index 378db252..00000000 --- a/manager/src/frontend/js/app/host/advanced.ejs +++ /dev/null @@ -1,23 +0,0 @@ - diff --git a/manager/src/frontend/js/app/host/delete.ejs b/manager/src/frontend/js/app/host/delete.ejs deleted file mode 100644 index e891b1a5..00000000 --- a/manager/src/frontend/js/app/host/delete.ejs +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/manager/src/frontend/js/app/host/proxy_form.ejs b/manager/src/frontend/js/app/host/proxy_form.ejs deleted file mode 100644 index f977d41e..00000000 --- a/manager/src/frontend/js/app/host/proxy_form.ejs +++ /dev/null @@ -1,84 +0,0 @@ - diff --git a/manager/src/frontend/js/app/host/reconfigure.ejs b/manager/src/frontend/js/app/host/reconfigure.ejs deleted file mode 100644 index c80be0a3..00000000 --- a/manager/src/frontend/js/app/host/reconfigure.ejs +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/manager/src/frontend/js/app/host/redirection_form.ejs b/manager/src/frontend/js/app/host/redirection_form.ejs deleted file mode 100644 index a04b328e..00000000 --- a/manager/src/frontend/js/app/host/redirection_form.ejs +++ /dev/null @@ -1,62 +0,0 @@ - diff --git a/manager/src/frontend/js/app/host/stream_form.ejs b/manager/src/frontend/js/app/host/stream_form.ejs deleted file mode 100644 index 9a52b3c2..00000000 --- a/manager/src/frontend/js/app/host/stream_form.ejs +++ /dev/null @@ -1,55 +0,0 @@ - diff --git a/manager/src/frontend/js/app/router.js b/manager/src/frontend/js/app/router.js deleted file mode 100644 index 45cbe862..00000000 --- a/manager/src/frontend/js/app/router.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -const Mn = require('../lib/marionette'); -const Controller = require('./controller'); - -module.exports = Mn.AppRouter.extend({ - appRoutes: { - access: 'showAccess', - '*default': 'showDashboard' - }, - - initialize: function () { - this.controller = Controller; - } -}); diff --git a/manager/src/frontend/js/app/ui/header/main.ejs b/manager/src/frontend/js/app/ui/header/main.ejs deleted file mode 100644 index 2f4763f1..00000000 --- a/manager/src/frontend/js/app/ui/header/main.ejs +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/manager/src/frontend/js/app/ui/main.ejs b/manager/src/frontend/js/app/ui/main.ejs deleted file mode 100644 index 3a6d324f..00000000 --- a/manager/src/frontend/js/app/ui/main.ejs +++ /dev/null @@ -1,2 +0,0 @@ - -
diff --git a/manager/src/frontend/scss/fontawesome/_animated.scss b/manager/src/frontend/scss/fontawesome/_animated.scss deleted file mode 100755 index 8a020dbf..00000000 --- a/manager/src/frontend/scss/fontawesome/_animated.scss +++ /dev/null @@ -1,34 +0,0 @@ -// Spinning Icons -// -------------------------- - -.#{$fa-css-prefix}-spin { - -webkit-animation: fa-spin 2s infinite linear; - animation: fa-spin 2s infinite linear; -} - -.#{$fa-css-prefix}-pulse { - -webkit-animation: fa-spin 1s infinite steps(8); - animation: fa-spin 1s infinite steps(8); -} - -@-webkit-keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} - -@keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} diff --git a/manager/src/frontend/scss/fontawesome/_bordered-pulled.scss b/manager/src/frontend/scss/fontawesome/_bordered-pulled.scss deleted file mode 100755 index d4b85a02..00000000 --- a/manager/src/frontend/scss/fontawesome/_bordered-pulled.scss +++ /dev/null @@ -1,25 +0,0 @@ -// Bordered & Pulled -// ------------------------- - -.#{$fa-css-prefix}-border { - padding: .2em .25em .15em; - border: solid .08em $fa-border-color; - border-radius: .1em; -} - -.#{$fa-css-prefix}-pull-left { float: left; } -.#{$fa-css-prefix}-pull-right { float: right; } - -.#{$fa-css-prefix} { - &.#{$fa-css-prefix}-pull-left { margin-right: .3em; } - &.#{$fa-css-prefix}-pull-right { margin-left: .3em; } -} - -/* Deprecated as of 4.4.0 */ -.pull-right { float: right; } -.pull-left { float: left; } - -.#{$fa-css-prefix} { - &.pull-left { margin-right: .3em; } - &.pull-right { margin-left: .3em; } -} diff --git a/manager/src/frontend/scss/fontawesome/_core.scss b/manager/src/frontend/scss/fontawesome/_core.scss deleted file mode 100755 index 7425ef85..00000000 --- a/manager/src/frontend/scss/fontawesome/_core.scss +++ /dev/null @@ -1,12 +0,0 @@ -// Base Class Definition -// ------------------------- - -.#{$fa-css-prefix} { - display: inline-block; - font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration - font-size: inherit; // can't have font-size inherit on line above, so need to override - text-rendering: auto; // optimizelegibility throws things off #1094 - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -} diff --git a/manager/src/frontend/scss/fontawesome/_fixed-width.scss b/manager/src/frontend/scss/fontawesome/_fixed-width.scss deleted file mode 100755 index b221c981..00000000 --- a/manager/src/frontend/scss/fontawesome/_fixed-width.scss +++ /dev/null @@ -1,6 +0,0 @@ -// Fixed Width Icons -// ------------------------- -.#{$fa-css-prefix}-fw { - width: (18em / 14); - text-align: center; -} diff --git a/manager/src/frontend/scss/fontawesome/_icons.scss b/manager/src/frontend/scss/fontawesome/_icons.scss deleted file mode 100755 index e63e702c..00000000 --- a/manager/src/frontend/scss/fontawesome/_icons.scss +++ /dev/null @@ -1,789 +0,0 @@ -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen - readers do not read off random characters that represent icons */ - -.#{$fa-css-prefix}-glass:before { content: $fa-var-glass; } -.#{$fa-css-prefix}-music:before { content: $fa-var-music; } -.#{$fa-css-prefix}-search:before { content: $fa-var-search; } -.#{$fa-css-prefix}-envelope-o:before { content: $fa-var-envelope-o; } -.#{$fa-css-prefix}-heart:before { content: $fa-var-heart; } -.#{$fa-css-prefix}-star:before { content: $fa-var-star; } -.#{$fa-css-prefix}-star-o:before { content: $fa-var-star-o; } -.#{$fa-css-prefix}-user:before { content: $fa-var-user; } -.#{$fa-css-prefix}-film:before { content: $fa-var-film; } -.#{$fa-css-prefix}-th-large:before { content: $fa-var-th-large; } -.#{$fa-css-prefix}-th:before { content: $fa-var-th; } -.#{$fa-css-prefix}-th-list:before { content: $fa-var-th-list; } -.#{$fa-css-prefix}-check:before { content: $fa-var-check; } -.#{$fa-css-prefix}-remove:before, -.#{$fa-css-prefix}-close:before, -.#{$fa-css-prefix}-times:before { content: $fa-var-times; } -.#{$fa-css-prefix}-search-plus:before { content: $fa-var-search-plus; } -.#{$fa-css-prefix}-search-minus:before { content: $fa-var-search-minus; } -.#{$fa-css-prefix}-power-off:before { content: $fa-var-power-off; } -.#{$fa-css-prefix}-signal:before { content: $fa-var-signal; } -.#{$fa-css-prefix}-gear:before, -.#{$fa-css-prefix}-cog:before { content: $fa-var-cog; } -.#{$fa-css-prefix}-trash-o:before { content: $fa-var-trash-o; } -.#{$fa-css-prefix}-home:before { content: $fa-var-home; } -.#{$fa-css-prefix}-file-o:before { content: $fa-var-file-o; } -.#{$fa-css-prefix}-clock-o:before { content: $fa-var-clock-o; } -.#{$fa-css-prefix}-road:before { content: $fa-var-road; } -.#{$fa-css-prefix}-download:before { content: $fa-var-download; } -.#{$fa-css-prefix}-arrow-circle-o-down:before { content: $fa-var-arrow-circle-o-down; } -.#{$fa-css-prefix}-arrow-circle-o-up:before { content: $fa-var-arrow-circle-o-up; } -.#{$fa-css-prefix}-inbox:before { content: $fa-var-inbox; } -.#{$fa-css-prefix}-play-circle-o:before { content: $fa-var-play-circle-o; } -.#{$fa-css-prefix}-rotate-right:before, -.#{$fa-css-prefix}-repeat:before { content: $fa-var-repeat; } -.#{$fa-css-prefix}-refresh:before { content: $fa-var-refresh; } -.#{$fa-css-prefix}-list-alt:before { content: $fa-var-list-alt; } -.#{$fa-css-prefix}-lock:before { content: $fa-var-lock; } -.#{$fa-css-prefix}-flag:before { content: $fa-var-flag; } -.#{$fa-css-prefix}-headphones:before { content: $fa-var-headphones; } -.#{$fa-css-prefix}-volume-off:before { content: $fa-var-volume-off; } -.#{$fa-css-prefix}-volume-down:before { content: $fa-var-volume-down; } -.#{$fa-css-prefix}-volume-up:before { content: $fa-var-volume-up; } -.#{$fa-css-prefix}-qrcode:before { content: $fa-var-qrcode; } -.#{$fa-css-prefix}-barcode:before { content: $fa-var-barcode; } -.#{$fa-css-prefix}-tag:before { content: $fa-var-tag; } -.#{$fa-css-prefix}-tags:before { content: $fa-var-tags; } -.#{$fa-css-prefix}-book:before { content: $fa-var-book; } -.#{$fa-css-prefix}-bookmark:before { content: $fa-var-bookmark; } -.#{$fa-css-prefix}-print:before { content: $fa-var-print; } -.#{$fa-css-prefix}-camera:before { content: $fa-var-camera; } -.#{$fa-css-prefix}-font:before { content: $fa-var-font; } -.#{$fa-css-prefix}-bold:before { content: $fa-var-bold; } -.#{$fa-css-prefix}-italic:before { content: $fa-var-italic; } -.#{$fa-css-prefix}-text-height:before { content: $fa-var-text-height; } -.#{$fa-css-prefix}-text-width:before { content: $fa-var-text-width; } -.#{$fa-css-prefix}-align-left:before { content: $fa-var-align-left; } -.#{$fa-css-prefix}-align-center:before { content: $fa-var-align-center; } -.#{$fa-css-prefix}-align-right:before { content: $fa-var-align-right; } -.#{$fa-css-prefix}-align-justify:before { content: $fa-var-align-justify; } -.#{$fa-css-prefix}-list:before { content: $fa-var-list; } -.#{$fa-css-prefix}-dedent:before, -.#{$fa-css-prefix}-outdent:before { content: $fa-var-outdent; } -.#{$fa-css-prefix}-indent:before { content: $fa-var-indent; } -.#{$fa-css-prefix}-video-camera:before { content: $fa-var-video-camera; } -.#{$fa-css-prefix}-photo:before, -.#{$fa-css-prefix}-image:before, -.#{$fa-css-prefix}-picture-o:before { content: $fa-var-picture-o; } -.#{$fa-css-prefix}-pencil:before { content: $fa-var-pencil; } -.#{$fa-css-prefix}-map-marker:before { content: $fa-var-map-marker; } -.#{$fa-css-prefix}-adjust:before { content: $fa-var-adjust; } -.#{$fa-css-prefix}-tint:before { content: $fa-var-tint; } -.#{$fa-css-prefix}-edit:before, -.#{$fa-css-prefix}-pencil-square-o:before { content: $fa-var-pencil-square-o; } -.#{$fa-css-prefix}-share-square-o:before { content: $fa-var-share-square-o; } -.#{$fa-css-prefix}-check-square-o:before { content: $fa-var-check-square-o; } -.#{$fa-css-prefix}-arrows:before { content: $fa-var-arrows; } -.#{$fa-css-prefix}-step-backward:before { content: $fa-var-step-backward; } -.#{$fa-css-prefix}-fast-backward:before { content: $fa-var-fast-backward; } -.#{$fa-css-prefix}-backward:before { content: $fa-var-backward; } -.#{$fa-css-prefix}-play:before { content: $fa-var-play; } -.#{$fa-css-prefix}-pause:before { content: $fa-var-pause; } -.#{$fa-css-prefix}-stop:before { content: $fa-var-stop; } -.#{$fa-css-prefix}-forward:before { content: $fa-var-forward; } -.#{$fa-css-prefix}-fast-forward:before { content: $fa-var-fast-forward; } -.#{$fa-css-prefix}-step-forward:before { content: $fa-var-step-forward; } -.#{$fa-css-prefix}-eject:before { content: $fa-var-eject; } -.#{$fa-css-prefix}-chevron-left:before { content: $fa-var-chevron-left; } -.#{$fa-css-prefix}-chevron-right:before { content: $fa-var-chevron-right; } -.#{$fa-css-prefix}-plus-circle:before { content: $fa-var-plus-circle; } -.#{$fa-css-prefix}-minus-circle:before { content: $fa-var-minus-circle; } -.#{$fa-css-prefix}-times-circle:before { content: $fa-var-times-circle; } -.#{$fa-css-prefix}-check-circle:before { content: $fa-var-check-circle; } -.#{$fa-css-prefix}-question-circle:before { content: $fa-var-question-circle; } -.#{$fa-css-prefix}-info-circle:before { content: $fa-var-info-circle; } -.#{$fa-css-prefix}-crosshairs:before { content: $fa-var-crosshairs; } -.#{$fa-css-prefix}-times-circle-o:before { content: $fa-var-times-circle-o; } -.#{$fa-css-prefix}-check-circle-o:before { content: $fa-var-check-circle-o; } -.#{$fa-css-prefix}-ban:before { content: $fa-var-ban; } -.#{$fa-css-prefix}-arrow-left:before { content: $fa-var-arrow-left; } -.#{$fa-css-prefix}-arrow-right:before { content: $fa-var-arrow-right; } -.#{$fa-css-prefix}-arrow-up:before { content: $fa-var-arrow-up; } -.#{$fa-css-prefix}-arrow-down:before { content: $fa-var-arrow-down; } -.#{$fa-css-prefix}-mail-forward:before, -.#{$fa-css-prefix}-share:before { content: $fa-var-share; } -.#{$fa-css-prefix}-expand:before { content: $fa-var-expand; } -.#{$fa-css-prefix}-compress:before { content: $fa-var-compress; } -.#{$fa-css-prefix}-plus:before { content: $fa-var-plus; } -.#{$fa-css-prefix}-minus:before { content: $fa-var-minus; } -.#{$fa-css-prefix}-asterisk:before { content: $fa-var-asterisk; } -.#{$fa-css-prefix}-exclamation-circle:before { content: $fa-var-exclamation-circle; } -.#{$fa-css-prefix}-gift:before { content: $fa-var-gift; } -.#{$fa-css-prefix}-leaf:before { content: $fa-var-leaf; } -.#{$fa-css-prefix}-fire:before { content: $fa-var-fire; } -.#{$fa-css-prefix}-eye:before { content: $fa-var-eye; } -.#{$fa-css-prefix}-eye-slash:before { content: $fa-var-eye-slash; } -.#{$fa-css-prefix}-warning:before, -.#{$fa-css-prefix}-exclamation-triangle:before { content: $fa-var-exclamation-triangle; } -.#{$fa-css-prefix}-plane:before { content: $fa-var-plane; } -.#{$fa-css-prefix}-calendar:before { content: $fa-var-calendar; } -.#{$fa-css-prefix}-random:before { content: $fa-var-random; } -.#{$fa-css-prefix}-comment:before { content: $fa-var-comment; } -.#{$fa-css-prefix}-magnet:before { content: $fa-var-magnet; } -.#{$fa-css-prefix}-chevron-up:before { content: $fa-var-chevron-up; } -.#{$fa-css-prefix}-chevron-down:before { content: $fa-var-chevron-down; } -.#{$fa-css-prefix}-retweet:before { content: $fa-var-retweet; } -.#{$fa-css-prefix}-shopping-cart:before { content: $fa-var-shopping-cart; } -.#{$fa-css-prefix}-folder:before { content: $fa-var-folder; } -.#{$fa-css-prefix}-folder-open:before { content: $fa-var-folder-open; } -.#{$fa-css-prefix}-arrows-v:before { content: $fa-var-arrows-v; } -.#{$fa-css-prefix}-arrows-h:before { content: $fa-var-arrows-h; } -.#{$fa-css-prefix}-bar-chart-o:before, -.#{$fa-css-prefix}-bar-chart:before { content: $fa-var-bar-chart; } -.#{$fa-css-prefix}-twitter-square:before { content: $fa-var-twitter-square; } -.#{$fa-css-prefix}-facebook-square:before { content: $fa-var-facebook-square; } -.#{$fa-css-prefix}-camera-retro:before { content: $fa-var-camera-retro; } -.#{$fa-css-prefix}-key:before { content: $fa-var-key; } -.#{$fa-css-prefix}-gears:before, -.#{$fa-css-prefix}-cogs:before { content: $fa-var-cogs; } -.#{$fa-css-prefix}-comments:before { content: $fa-var-comments; } -.#{$fa-css-prefix}-thumbs-o-up:before { content: $fa-var-thumbs-o-up; } -.#{$fa-css-prefix}-thumbs-o-down:before { content: $fa-var-thumbs-o-down; } -.#{$fa-css-prefix}-star-half:before { content: $fa-var-star-half; } -.#{$fa-css-prefix}-heart-o:before { content: $fa-var-heart-o; } -.#{$fa-css-prefix}-sign-out:before { content: $fa-var-sign-out; } -.#{$fa-css-prefix}-linkedin-square:before { content: $fa-var-linkedin-square; } -.#{$fa-css-prefix}-thumb-tack:before { content: $fa-var-thumb-tack; } -.#{$fa-css-prefix}-external-link:before { content: $fa-var-external-link; } -.#{$fa-css-prefix}-sign-in:before { content: $fa-var-sign-in; } -.#{$fa-css-prefix}-trophy:before { content: $fa-var-trophy; } -.#{$fa-css-prefix}-github-square:before { content: $fa-var-github-square; } -.#{$fa-css-prefix}-upload:before { content: $fa-var-upload; } -.#{$fa-css-prefix}-lemon-o:before { content: $fa-var-lemon-o; } -.#{$fa-css-prefix}-phone:before { content: $fa-var-phone; } -.#{$fa-css-prefix}-square-o:before { content: $fa-var-square-o; } -.#{$fa-css-prefix}-bookmark-o:before { content: $fa-var-bookmark-o; } -.#{$fa-css-prefix}-phone-square:before { content: $fa-var-phone-square; } -.#{$fa-css-prefix}-twitter:before { content: $fa-var-twitter; } -.#{$fa-css-prefix}-facebook-f:before, -.#{$fa-css-prefix}-facebook:before { content: $fa-var-facebook; } -.#{$fa-css-prefix}-github:before { content: $fa-var-github; } -.#{$fa-css-prefix}-unlock:before { content: $fa-var-unlock; } -.#{$fa-css-prefix}-credit-card:before { content: $fa-var-credit-card; } -.#{$fa-css-prefix}-feed:before, -.#{$fa-css-prefix}-rss:before { content: $fa-var-rss; } -.#{$fa-css-prefix}-hdd-o:before { content: $fa-var-hdd-o; } -.#{$fa-css-prefix}-bullhorn:before { content: $fa-var-bullhorn; } -.#{$fa-css-prefix}-bell:before { content: $fa-var-bell; } -.#{$fa-css-prefix}-certificate:before { content: $fa-var-certificate; } -.#{$fa-css-prefix}-hand-o-right:before { content: $fa-var-hand-o-right; } -.#{$fa-css-prefix}-hand-o-left:before { content: $fa-var-hand-o-left; } -.#{$fa-css-prefix}-hand-o-up:before { content: $fa-var-hand-o-up; } -.#{$fa-css-prefix}-hand-o-down:before { content: $fa-var-hand-o-down; } -.#{$fa-css-prefix}-arrow-circle-left:before { content: $fa-var-arrow-circle-left; } -.#{$fa-css-prefix}-arrow-circle-right:before { content: $fa-var-arrow-circle-right; } -.#{$fa-css-prefix}-arrow-circle-up:before { content: $fa-var-arrow-circle-up; } -.#{$fa-css-prefix}-arrow-circle-down:before { content: $fa-var-arrow-circle-down; } -.#{$fa-css-prefix}-globe:before { content: $fa-var-globe; } -.#{$fa-css-prefix}-wrench:before { content: $fa-var-wrench; } -.#{$fa-css-prefix}-tasks:before { content: $fa-var-tasks; } -.#{$fa-css-prefix}-filter:before { content: $fa-var-filter; } -.#{$fa-css-prefix}-briefcase:before { content: $fa-var-briefcase; } -.#{$fa-css-prefix}-arrows-alt:before { content: $fa-var-arrows-alt; } -.#{$fa-css-prefix}-group:before, -.#{$fa-css-prefix}-users:before { content: $fa-var-users; } -.#{$fa-css-prefix}-chain:before, -.#{$fa-css-prefix}-link:before { content: $fa-var-link; } -.#{$fa-css-prefix}-cloud:before { content: $fa-var-cloud; } -.#{$fa-css-prefix}-flask:before { content: $fa-var-flask; } -.#{$fa-css-prefix}-cut:before, -.#{$fa-css-prefix}-scissors:before { content: $fa-var-scissors; } -.#{$fa-css-prefix}-copy:before, -.#{$fa-css-prefix}-files-o:before { content: $fa-var-files-o; } -.#{$fa-css-prefix}-paperclip:before { content: $fa-var-paperclip; } -.#{$fa-css-prefix}-save:before, -.#{$fa-css-prefix}-floppy-o:before { content: $fa-var-floppy-o; } -.#{$fa-css-prefix}-square:before { content: $fa-var-square; } -.#{$fa-css-prefix}-navicon:before, -.#{$fa-css-prefix}-reorder:before, -.#{$fa-css-prefix}-bars:before { content: $fa-var-bars; } -.#{$fa-css-prefix}-list-ul:before { content: $fa-var-list-ul; } -.#{$fa-css-prefix}-list-ol:before { content: $fa-var-list-ol; } -.#{$fa-css-prefix}-strikethrough:before { content: $fa-var-strikethrough; } -.#{$fa-css-prefix}-underline:before { content: $fa-var-underline; } -.#{$fa-css-prefix}-table:before { content: $fa-var-table; } -.#{$fa-css-prefix}-magic:before { content: $fa-var-magic; } -.#{$fa-css-prefix}-truck:before { content: $fa-var-truck; } -.#{$fa-css-prefix}-pinterest:before { content: $fa-var-pinterest; } -.#{$fa-css-prefix}-pinterest-square:before { content: $fa-var-pinterest-square; } -.#{$fa-css-prefix}-google-plus-square:before { content: $fa-var-google-plus-square; } -.#{$fa-css-prefix}-google-plus:before { content: $fa-var-google-plus; } -.#{$fa-css-prefix}-money:before { content: $fa-var-money; } -.#{$fa-css-prefix}-caret-down:before { content: $fa-var-caret-down; } -.#{$fa-css-prefix}-caret-up:before { content: $fa-var-caret-up; } -.#{$fa-css-prefix}-caret-left:before { content: $fa-var-caret-left; } -.#{$fa-css-prefix}-caret-right:before { content: $fa-var-caret-right; } -.#{$fa-css-prefix}-columns:before { content: $fa-var-columns; } -.#{$fa-css-prefix}-unsorted:before, -.#{$fa-css-prefix}-sort:before { content: $fa-var-sort; } -.#{$fa-css-prefix}-sort-down:before, -.#{$fa-css-prefix}-sort-desc:before { content: $fa-var-sort-desc; } -.#{$fa-css-prefix}-sort-up:before, -.#{$fa-css-prefix}-sort-asc:before { content: $fa-var-sort-asc; } -.#{$fa-css-prefix}-envelope:before { content: $fa-var-envelope; } -.#{$fa-css-prefix}-linkedin:before { content: $fa-var-linkedin; } -.#{$fa-css-prefix}-rotate-left:before, -.#{$fa-css-prefix}-undo:before { content: $fa-var-undo; } -.#{$fa-css-prefix}-legal:before, -.#{$fa-css-prefix}-gavel:before { content: $fa-var-gavel; } -.#{$fa-css-prefix}-dashboard:before, -.#{$fa-css-prefix}-tachometer:before { content: $fa-var-tachometer; } -.#{$fa-css-prefix}-comment-o:before { content: $fa-var-comment-o; } -.#{$fa-css-prefix}-comments-o:before { content: $fa-var-comments-o; } -.#{$fa-css-prefix}-flash:before, -.#{$fa-css-prefix}-bolt:before { content: $fa-var-bolt; } -.#{$fa-css-prefix}-sitemap:before { content: $fa-var-sitemap; } -.#{$fa-css-prefix}-umbrella:before { content: $fa-var-umbrella; } -.#{$fa-css-prefix}-paste:before, -.#{$fa-css-prefix}-clipboard:before { content: $fa-var-clipboard; } -.#{$fa-css-prefix}-lightbulb-o:before { content: $fa-var-lightbulb-o; } -.#{$fa-css-prefix}-exchange:before { content: $fa-var-exchange; } -.#{$fa-css-prefix}-cloud-download:before { content: $fa-var-cloud-download; } -.#{$fa-css-prefix}-cloud-upload:before { content: $fa-var-cloud-upload; } -.#{$fa-css-prefix}-user-md:before { content: $fa-var-user-md; } -.#{$fa-css-prefix}-stethoscope:before { content: $fa-var-stethoscope; } -.#{$fa-css-prefix}-suitcase:before { content: $fa-var-suitcase; } -.#{$fa-css-prefix}-bell-o:before { content: $fa-var-bell-o; } -.#{$fa-css-prefix}-coffee:before { content: $fa-var-coffee; } -.#{$fa-css-prefix}-cutlery:before { content: $fa-var-cutlery; } -.#{$fa-css-prefix}-file-text-o:before { content: $fa-var-file-text-o; } -.#{$fa-css-prefix}-building-o:before { content: $fa-var-building-o; } -.#{$fa-css-prefix}-hospital-o:before { content: $fa-var-hospital-o; } -.#{$fa-css-prefix}-ambulance:before { content: $fa-var-ambulance; } -.#{$fa-css-prefix}-medkit:before { content: $fa-var-medkit; } -.#{$fa-css-prefix}-fighter-jet:before { content: $fa-var-fighter-jet; } -.#{$fa-css-prefix}-beer:before { content: $fa-var-beer; } -.#{$fa-css-prefix}-h-square:before { content: $fa-var-h-square; } -.#{$fa-css-prefix}-plus-square:before { content: $fa-var-plus-square; } -.#{$fa-css-prefix}-angle-double-left:before { content: $fa-var-angle-double-left; } -.#{$fa-css-prefix}-angle-double-right:before { content: $fa-var-angle-double-right; } -.#{$fa-css-prefix}-angle-double-up:before { content: $fa-var-angle-double-up; } -.#{$fa-css-prefix}-angle-double-down:before { content: $fa-var-angle-double-down; } -.#{$fa-css-prefix}-angle-left:before { content: $fa-var-angle-left; } -.#{$fa-css-prefix}-angle-right:before { content: $fa-var-angle-right; } -.#{$fa-css-prefix}-angle-up:before { content: $fa-var-angle-up; } -.#{$fa-css-prefix}-angle-down:before { content: $fa-var-angle-down; } -.#{$fa-css-prefix}-desktop:before { content: $fa-var-desktop; } -.#{$fa-css-prefix}-laptop:before { content: $fa-var-laptop; } -.#{$fa-css-prefix}-tablet:before { content: $fa-var-tablet; } -.#{$fa-css-prefix}-mobile-phone:before, -.#{$fa-css-prefix}-mobile:before { content: $fa-var-mobile; } -.#{$fa-css-prefix}-circle-o:before { content: $fa-var-circle-o; } -.#{$fa-css-prefix}-quote-left:before { content: $fa-var-quote-left; } -.#{$fa-css-prefix}-quote-right:before { content: $fa-var-quote-right; } -.#{$fa-css-prefix}-spinner:before { content: $fa-var-spinner; } -.#{$fa-css-prefix}-circle:before { content: $fa-var-circle; } -.#{$fa-css-prefix}-mail-reply:before, -.#{$fa-css-prefix}-reply:before { content: $fa-var-reply; } -.#{$fa-css-prefix}-github-alt:before { content: $fa-var-github-alt; } -.#{$fa-css-prefix}-folder-o:before { content: $fa-var-folder-o; } -.#{$fa-css-prefix}-folder-open-o:before { content: $fa-var-folder-open-o; } -.#{$fa-css-prefix}-smile-o:before { content: $fa-var-smile-o; } -.#{$fa-css-prefix}-frown-o:before { content: $fa-var-frown-o; } -.#{$fa-css-prefix}-meh-o:before { content: $fa-var-meh-o; } -.#{$fa-css-prefix}-gamepad:before { content: $fa-var-gamepad; } -.#{$fa-css-prefix}-keyboard-o:before { content: $fa-var-keyboard-o; } -.#{$fa-css-prefix}-flag-o:before { content: $fa-var-flag-o; } -.#{$fa-css-prefix}-flag-checkered:before { content: $fa-var-flag-checkered; } -.#{$fa-css-prefix}-terminal:before { content: $fa-var-terminal; } -.#{$fa-css-prefix}-code:before { content: $fa-var-code; } -.#{$fa-css-prefix}-mail-reply-all:before, -.#{$fa-css-prefix}-reply-all:before { content: $fa-var-reply-all; } -.#{$fa-css-prefix}-star-half-empty:before, -.#{$fa-css-prefix}-star-half-full:before, -.#{$fa-css-prefix}-star-half-o:before { content: $fa-var-star-half-o; } -.#{$fa-css-prefix}-location-arrow:before { content: $fa-var-location-arrow; } -.#{$fa-css-prefix}-crop:before { content: $fa-var-crop; } -.#{$fa-css-prefix}-code-fork:before { content: $fa-var-code-fork; } -.#{$fa-css-prefix}-unlink:before, -.#{$fa-css-prefix}-chain-broken:before { content: $fa-var-chain-broken; } -.#{$fa-css-prefix}-question:before { content: $fa-var-question; } -.#{$fa-css-prefix}-info:before { content: $fa-var-info; } -.#{$fa-css-prefix}-exclamation:before { content: $fa-var-exclamation; } -.#{$fa-css-prefix}-superscript:before { content: $fa-var-superscript; } -.#{$fa-css-prefix}-subscript:before { content: $fa-var-subscript; } -.#{$fa-css-prefix}-eraser:before { content: $fa-var-eraser; } -.#{$fa-css-prefix}-puzzle-piece:before { content: $fa-var-puzzle-piece; } -.#{$fa-css-prefix}-microphone:before { content: $fa-var-microphone; } -.#{$fa-css-prefix}-microphone-slash:before { content: $fa-var-microphone-slash; } -.#{$fa-css-prefix}-shield:before { content: $fa-var-shield; } -.#{$fa-css-prefix}-calendar-o:before { content: $fa-var-calendar-o; } -.#{$fa-css-prefix}-fire-extinguisher:before { content: $fa-var-fire-extinguisher; } -.#{$fa-css-prefix}-rocket:before { content: $fa-var-rocket; } -.#{$fa-css-prefix}-maxcdn:before { content: $fa-var-maxcdn; } -.#{$fa-css-prefix}-chevron-circle-left:before { content: $fa-var-chevron-circle-left; } -.#{$fa-css-prefix}-chevron-circle-right:before { content: $fa-var-chevron-circle-right; } -.#{$fa-css-prefix}-chevron-circle-up:before { content: $fa-var-chevron-circle-up; } -.#{$fa-css-prefix}-chevron-circle-down:before { content: $fa-var-chevron-circle-down; } -.#{$fa-css-prefix}-html5:before { content: $fa-var-html5; } -.#{$fa-css-prefix}-css3:before { content: $fa-var-css3; } -.#{$fa-css-prefix}-anchor:before { content: $fa-var-anchor; } -.#{$fa-css-prefix}-unlock-alt:before { content: $fa-var-unlock-alt; } -.#{$fa-css-prefix}-bullseye:before { content: $fa-var-bullseye; } -.#{$fa-css-prefix}-ellipsis-h:before { content: $fa-var-ellipsis-h; } -.#{$fa-css-prefix}-ellipsis-v:before { content: $fa-var-ellipsis-v; } -.#{$fa-css-prefix}-rss-square:before { content: $fa-var-rss-square; } -.#{$fa-css-prefix}-play-circle:before { content: $fa-var-play-circle; } -.#{$fa-css-prefix}-ticket:before { content: $fa-var-ticket; } -.#{$fa-css-prefix}-minus-square:before { content: $fa-var-minus-square; } -.#{$fa-css-prefix}-minus-square-o:before { content: $fa-var-minus-square-o; } -.#{$fa-css-prefix}-level-up:before { content: $fa-var-level-up; } -.#{$fa-css-prefix}-level-down:before { content: $fa-var-level-down; } -.#{$fa-css-prefix}-check-square:before { content: $fa-var-check-square; } -.#{$fa-css-prefix}-pencil-square:before { content: $fa-var-pencil-square; } -.#{$fa-css-prefix}-external-link-square:before { content: $fa-var-external-link-square; } -.#{$fa-css-prefix}-share-square:before { content: $fa-var-share-square; } -.#{$fa-css-prefix}-compass:before { content: $fa-var-compass; } -.#{$fa-css-prefix}-toggle-down:before, -.#{$fa-css-prefix}-caret-square-o-down:before { content: $fa-var-caret-square-o-down; } -.#{$fa-css-prefix}-toggle-up:before, -.#{$fa-css-prefix}-caret-square-o-up:before { content: $fa-var-caret-square-o-up; } -.#{$fa-css-prefix}-toggle-right:before, -.#{$fa-css-prefix}-caret-square-o-right:before { content: $fa-var-caret-square-o-right; } -.#{$fa-css-prefix}-euro:before, -.#{$fa-css-prefix}-eur:before { content: $fa-var-eur; } -.#{$fa-css-prefix}-gbp:before { content: $fa-var-gbp; } -.#{$fa-css-prefix}-dollar:before, -.#{$fa-css-prefix}-usd:before { content: $fa-var-usd; } -.#{$fa-css-prefix}-rupee:before, -.#{$fa-css-prefix}-inr:before { content: $fa-var-inr; } -.#{$fa-css-prefix}-cny:before, -.#{$fa-css-prefix}-rmb:before, -.#{$fa-css-prefix}-yen:before, -.#{$fa-css-prefix}-jpy:before { content: $fa-var-jpy; } -.#{$fa-css-prefix}-ruble:before, -.#{$fa-css-prefix}-rouble:before, -.#{$fa-css-prefix}-rub:before { content: $fa-var-rub; } -.#{$fa-css-prefix}-won:before, -.#{$fa-css-prefix}-krw:before { content: $fa-var-krw; } -.#{$fa-css-prefix}-bitcoin:before, -.#{$fa-css-prefix}-btc:before { content: $fa-var-btc; } -.#{$fa-css-prefix}-file:before { content: $fa-var-file; } -.#{$fa-css-prefix}-file-text:before { content: $fa-var-file-text; } -.#{$fa-css-prefix}-sort-alpha-asc:before { content: $fa-var-sort-alpha-asc; } -.#{$fa-css-prefix}-sort-alpha-desc:before { content: $fa-var-sort-alpha-desc; } -.#{$fa-css-prefix}-sort-amount-asc:before { content: $fa-var-sort-amount-asc; } -.#{$fa-css-prefix}-sort-amount-desc:before { content: $fa-var-sort-amount-desc; } -.#{$fa-css-prefix}-sort-numeric-asc:before { content: $fa-var-sort-numeric-asc; } -.#{$fa-css-prefix}-sort-numeric-desc:before { content: $fa-var-sort-numeric-desc; } -.#{$fa-css-prefix}-thumbs-up:before { content: $fa-var-thumbs-up; } -.#{$fa-css-prefix}-thumbs-down:before { content: $fa-var-thumbs-down; } -.#{$fa-css-prefix}-youtube-square:before { content: $fa-var-youtube-square; } -.#{$fa-css-prefix}-youtube:before { content: $fa-var-youtube; } -.#{$fa-css-prefix}-xing:before { content: $fa-var-xing; } -.#{$fa-css-prefix}-xing-square:before { content: $fa-var-xing-square; } -.#{$fa-css-prefix}-youtube-play:before { content: $fa-var-youtube-play; } -.#{$fa-css-prefix}-dropbox:before { content: $fa-var-dropbox; } -.#{$fa-css-prefix}-stack-overflow:before { content: $fa-var-stack-overflow; } -.#{$fa-css-prefix}-instagram:before { content: $fa-var-instagram; } -.#{$fa-css-prefix}-flickr:before { content: $fa-var-flickr; } -.#{$fa-css-prefix}-adn:before { content: $fa-var-adn; } -.#{$fa-css-prefix}-bitbucket:before { content: $fa-var-bitbucket; } -.#{$fa-css-prefix}-bitbucket-square:before { content: $fa-var-bitbucket-square; } -.#{$fa-css-prefix}-tumblr:before { content: $fa-var-tumblr; } -.#{$fa-css-prefix}-tumblr-square:before { content: $fa-var-tumblr-square; } -.#{$fa-css-prefix}-long-arrow-down:before { content: $fa-var-long-arrow-down; } -.#{$fa-css-prefix}-long-arrow-up:before { content: $fa-var-long-arrow-up; } -.#{$fa-css-prefix}-long-arrow-left:before { content: $fa-var-long-arrow-left; } -.#{$fa-css-prefix}-long-arrow-right:before { content: $fa-var-long-arrow-right; } -.#{$fa-css-prefix}-apple:before { content: $fa-var-apple; } -.#{$fa-css-prefix}-windows:before { content: $fa-var-windows; } -.#{$fa-css-prefix}-android:before { content: $fa-var-android; } -.#{$fa-css-prefix}-linux:before { content: $fa-var-linux; } -.#{$fa-css-prefix}-dribbble:before { content: $fa-var-dribbble; } -.#{$fa-css-prefix}-skype:before { content: $fa-var-skype; } -.#{$fa-css-prefix}-foursquare:before { content: $fa-var-foursquare; } -.#{$fa-css-prefix}-trello:before { content: $fa-var-trello; } -.#{$fa-css-prefix}-female:before { content: $fa-var-female; } -.#{$fa-css-prefix}-male:before { content: $fa-var-male; } -.#{$fa-css-prefix}-gittip:before, -.#{$fa-css-prefix}-gratipay:before { content: $fa-var-gratipay; } -.#{$fa-css-prefix}-sun-o:before { content: $fa-var-sun-o; } -.#{$fa-css-prefix}-moon-o:before { content: $fa-var-moon-o; } -.#{$fa-css-prefix}-archive:before { content: $fa-var-archive; } -.#{$fa-css-prefix}-bug:before { content: $fa-var-bug; } -.#{$fa-css-prefix}-vk:before { content: $fa-var-vk; } -.#{$fa-css-prefix}-weibo:before { content: $fa-var-weibo; } -.#{$fa-css-prefix}-renren:before { content: $fa-var-renren; } -.#{$fa-css-prefix}-pagelines:before { content: $fa-var-pagelines; } -.#{$fa-css-prefix}-stack-exchange:before { content: $fa-var-stack-exchange; } -.#{$fa-css-prefix}-arrow-circle-o-right:before { content: $fa-var-arrow-circle-o-right; } -.#{$fa-css-prefix}-arrow-circle-o-left:before { content: $fa-var-arrow-circle-o-left; } -.#{$fa-css-prefix}-toggle-left:before, -.#{$fa-css-prefix}-caret-square-o-left:before { content: $fa-var-caret-square-o-left; } -.#{$fa-css-prefix}-dot-circle-o:before { content: $fa-var-dot-circle-o; } -.#{$fa-css-prefix}-wheelchair:before { content: $fa-var-wheelchair; } -.#{$fa-css-prefix}-vimeo-square:before { content: $fa-var-vimeo-square; } -.#{$fa-css-prefix}-turkish-lira:before, -.#{$fa-css-prefix}-try:before { content: $fa-var-try; } -.#{$fa-css-prefix}-plus-square-o:before { content: $fa-var-plus-square-o; } -.#{$fa-css-prefix}-space-shuttle:before { content: $fa-var-space-shuttle; } -.#{$fa-css-prefix}-slack:before { content: $fa-var-slack; } -.#{$fa-css-prefix}-envelope-square:before { content: $fa-var-envelope-square; } -.#{$fa-css-prefix}-wordpress:before { content: $fa-var-wordpress; } -.#{$fa-css-prefix}-openid:before { content: $fa-var-openid; } -.#{$fa-css-prefix}-institution:before, -.#{$fa-css-prefix}-bank:before, -.#{$fa-css-prefix}-university:before { content: $fa-var-university; } -.#{$fa-css-prefix}-mortar-board:before, -.#{$fa-css-prefix}-graduation-cap:before { content: $fa-var-graduation-cap; } -.#{$fa-css-prefix}-yahoo:before { content: $fa-var-yahoo; } -.#{$fa-css-prefix}-google:before { content: $fa-var-google; } -.#{$fa-css-prefix}-reddit:before { content: $fa-var-reddit; } -.#{$fa-css-prefix}-reddit-square:before { content: $fa-var-reddit-square; } -.#{$fa-css-prefix}-stumbleupon-circle:before { content: $fa-var-stumbleupon-circle; } -.#{$fa-css-prefix}-stumbleupon:before { content: $fa-var-stumbleupon; } -.#{$fa-css-prefix}-delicious:before { content: $fa-var-delicious; } -.#{$fa-css-prefix}-digg:before { content: $fa-var-digg; } -.#{$fa-css-prefix}-pied-piper-pp:before { content: $fa-var-pied-piper-pp; } -.#{$fa-css-prefix}-pied-piper-alt:before { content: $fa-var-pied-piper-alt; } -.#{$fa-css-prefix}-drupal:before { content: $fa-var-drupal; } -.#{$fa-css-prefix}-joomla:before { content: $fa-var-joomla; } -.#{$fa-css-prefix}-language:before { content: $fa-var-language; } -.#{$fa-css-prefix}-fax:before { content: $fa-var-fax; } -.#{$fa-css-prefix}-building:before { content: $fa-var-building; } -.#{$fa-css-prefix}-child:before { content: $fa-var-child; } -.#{$fa-css-prefix}-paw:before { content: $fa-var-paw; } -.#{$fa-css-prefix}-spoon:before { content: $fa-var-spoon; } -.#{$fa-css-prefix}-cube:before { content: $fa-var-cube; } -.#{$fa-css-prefix}-cubes:before { content: $fa-var-cubes; } -.#{$fa-css-prefix}-behance:before { content: $fa-var-behance; } -.#{$fa-css-prefix}-behance-square:before { content: $fa-var-behance-square; } -.#{$fa-css-prefix}-steam:before { content: $fa-var-steam; } -.#{$fa-css-prefix}-steam-square:before { content: $fa-var-steam-square; } -.#{$fa-css-prefix}-recycle:before { content: $fa-var-recycle; } -.#{$fa-css-prefix}-automobile:before, -.#{$fa-css-prefix}-car:before { content: $fa-var-car; } -.#{$fa-css-prefix}-cab:before, -.#{$fa-css-prefix}-taxi:before { content: $fa-var-taxi; } -.#{$fa-css-prefix}-tree:before { content: $fa-var-tree; } -.#{$fa-css-prefix}-spotify:before { content: $fa-var-spotify; } -.#{$fa-css-prefix}-deviantart:before { content: $fa-var-deviantart; } -.#{$fa-css-prefix}-soundcloud:before { content: $fa-var-soundcloud; } -.#{$fa-css-prefix}-database:before { content: $fa-var-database; } -.#{$fa-css-prefix}-file-pdf-o:before { content: $fa-var-file-pdf-o; } -.#{$fa-css-prefix}-file-word-o:before { content: $fa-var-file-word-o; } -.#{$fa-css-prefix}-file-excel-o:before { content: $fa-var-file-excel-o; } -.#{$fa-css-prefix}-file-powerpoint-o:before { content: $fa-var-file-powerpoint-o; } -.#{$fa-css-prefix}-file-photo-o:before, -.#{$fa-css-prefix}-file-picture-o:before, -.#{$fa-css-prefix}-file-image-o:before { content: $fa-var-file-image-o; } -.#{$fa-css-prefix}-file-zip-o:before, -.#{$fa-css-prefix}-file-archive-o:before { content: $fa-var-file-archive-o; } -.#{$fa-css-prefix}-file-sound-o:before, -.#{$fa-css-prefix}-file-audio-o:before { content: $fa-var-file-audio-o; } -.#{$fa-css-prefix}-file-movie-o:before, -.#{$fa-css-prefix}-file-video-o:before { content: $fa-var-file-video-o; } -.#{$fa-css-prefix}-file-code-o:before { content: $fa-var-file-code-o; } -.#{$fa-css-prefix}-vine:before { content: $fa-var-vine; } -.#{$fa-css-prefix}-codepen:before { content: $fa-var-codepen; } -.#{$fa-css-prefix}-jsfiddle:before { content: $fa-var-jsfiddle; } -.#{$fa-css-prefix}-life-bouy:before, -.#{$fa-css-prefix}-life-buoy:before, -.#{$fa-css-prefix}-life-saver:before, -.#{$fa-css-prefix}-support:before, -.#{$fa-css-prefix}-life-ring:before { content: $fa-var-life-ring; } -.#{$fa-css-prefix}-circle-o-notch:before { content: $fa-var-circle-o-notch; } -.#{$fa-css-prefix}-ra:before, -.#{$fa-css-prefix}-resistance:before, -.#{$fa-css-prefix}-rebel:before { content: $fa-var-rebel; } -.#{$fa-css-prefix}-ge:before, -.#{$fa-css-prefix}-empire:before { content: $fa-var-empire; } -.#{$fa-css-prefix}-git-square:before { content: $fa-var-git-square; } -.#{$fa-css-prefix}-git:before { content: $fa-var-git; } -.#{$fa-css-prefix}-y-combinator-square:before, -.#{$fa-css-prefix}-yc-square:before, -.#{$fa-css-prefix}-hacker-news:before { content: $fa-var-hacker-news; } -.#{$fa-css-prefix}-tencent-weibo:before { content: $fa-var-tencent-weibo; } -.#{$fa-css-prefix}-qq:before { content: $fa-var-qq; } -.#{$fa-css-prefix}-wechat:before, -.#{$fa-css-prefix}-weixin:before { content: $fa-var-weixin; } -.#{$fa-css-prefix}-send:before, -.#{$fa-css-prefix}-paper-plane:before { content: $fa-var-paper-plane; } -.#{$fa-css-prefix}-send-o:before, -.#{$fa-css-prefix}-paper-plane-o:before { content: $fa-var-paper-plane-o; } -.#{$fa-css-prefix}-history:before { content: $fa-var-history; } -.#{$fa-css-prefix}-circle-thin:before { content: $fa-var-circle-thin; } -.#{$fa-css-prefix}-header:before { content: $fa-var-header; } -.#{$fa-css-prefix}-paragraph:before { content: $fa-var-paragraph; } -.#{$fa-css-prefix}-sliders:before { content: $fa-var-sliders; } -.#{$fa-css-prefix}-share-alt:before { content: $fa-var-share-alt; } -.#{$fa-css-prefix}-share-alt-square:before { content: $fa-var-share-alt-square; } -.#{$fa-css-prefix}-bomb:before { content: $fa-var-bomb; } -.#{$fa-css-prefix}-soccer-ball-o:before, -.#{$fa-css-prefix}-futbol-o:before { content: $fa-var-futbol-o; } -.#{$fa-css-prefix}-tty:before { content: $fa-var-tty; } -.#{$fa-css-prefix}-binoculars:before { content: $fa-var-binoculars; } -.#{$fa-css-prefix}-plug:before { content: $fa-var-plug; } -.#{$fa-css-prefix}-slideshare:before { content: $fa-var-slideshare; } -.#{$fa-css-prefix}-twitch:before { content: $fa-var-twitch; } -.#{$fa-css-prefix}-yelp:before { content: $fa-var-yelp; } -.#{$fa-css-prefix}-newspaper-o:before { content: $fa-var-newspaper-o; } -.#{$fa-css-prefix}-wifi:before { content: $fa-var-wifi; } -.#{$fa-css-prefix}-calculator:before { content: $fa-var-calculator; } -.#{$fa-css-prefix}-paypal:before { content: $fa-var-paypal; } -.#{$fa-css-prefix}-google-wallet:before { content: $fa-var-google-wallet; } -.#{$fa-css-prefix}-cc-visa:before { content: $fa-var-cc-visa; } -.#{$fa-css-prefix}-cc-mastercard:before { content: $fa-var-cc-mastercard; } -.#{$fa-css-prefix}-cc-discover:before { content: $fa-var-cc-discover; } -.#{$fa-css-prefix}-cc-amex:before { content: $fa-var-cc-amex; } -.#{$fa-css-prefix}-cc-paypal:before { content: $fa-var-cc-paypal; } -.#{$fa-css-prefix}-cc-stripe:before { content: $fa-var-cc-stripe; } -.#{$fa-css-prefix}-bell-slash:before { content: $fa-var-bell-slash; } -.#{$fa-css-prefix}-bell-slash-o:before { content: $fa-var-bell-slash-o; } -.#{$fa-css-prefix}-trash:before { content: $fa-var-trash; } -.#{$fa-css-prefix}-copyright:before { content: $fa-var-copyright; } -.#{$fa-css-prefix}-at:before { content: $fa-var-at; } -.#{$fa-css-prefix}-eyedropper:before { content: $fa-var-eyedropper; } -.#{$fa-css-prefix}-paint-brush:before { content: $fa-var-paint-brush; } -.#{$fa-css-prefix}-birthday-cake:before { content: $fa-var-birthday-cake; } -.#{$fa-css-prefix}-area-chart:before { content: $fa-var-area-chart; } -.#{$fa-css-prefix}-pie-chart:before { content: $fa-var-pie-chart; } -.#{$fa-css-prefix}-line-chart:before { content: $fa-var-line-chart; } -.#{$fa-css-prefix}-lastfm:before { content: $fa-var-lastfm; } -.#{$fa-css-prefix}-lastfm-square:before { content: $fa-var-lastfm-square; } -.#{$fa-css-prefix}-toggle-off:before { content: $fa-var-toggle-off; } -.#{$fa-css-prefix}-toggle-on:before { content: $fa-var-toggle-on; } -.#{$fa-css-prefix}-bicycle:before { content: $fa-var-bicycle; } -.#{$fa-css-prefix}-bus:before { content: $fa-var-bus; } -.#{$fa-css-prefix}-ioxhost:before { content: $fa-var-ioxhost; } -.#{$fa-css-prefix}-angellist:before { content: $fa-var-angellist; } -.#{$fa-css-prefix}-cc:before { content: $fa-var-cc; } -.#{$fa-css-prefix}-shekel:before, -.#{$fa-css-prefix}-sheqel:before, -.#{$fa-css-prefix}-ils:before { content: $fa-var-ils; } -.#{$fa-css-prefix}-meanpath:before { content: $fa-var-meanpath; } -.#{$fa-css-prefix}-buysellads:before { content: $fa-var-buysellads; } -.#{$fa-css-prefix}-connectdevelop:before { content: $fa-var-connectdevelop; } -.#{$fa-css-prefix}-dashcube:before { content: $fa-var-dashcube; } -.#{$fa-css-prefix}-forumbee:before { content: $fa-var-forumbee; } -.#{$fa-css-prefix}-leanpub:before { content: $fa-var-leanpub; } -.#{$fa-css-prefix}-sellsy:before { content: $fa-var-sellsy; } -.#{$fa-css-prefix}-shirtsinbulk:before { content: $fa-var-shirtsinbulk; } -.#{$fa-css-prefix}-simplybuilt:before { content: $fa-var-simplybuilt; } -.#{$fa-css-prefix}-skyatlas:before { content: $fa-var-skyatlas; } -.#{$fa-css-prefix}-cart-plus:before { content: $fa-var-cart-plus; } -.#{$fa-css-prefix}-cart-arrow-down:before { content: $fa-var-cart-arrow-down; } -.#{$fa-css-prefix}-diamond:before { content: $fa-var-diamond; } -.#{$fa-css-prefix}-ship:before { content: $fa-var-ship; } -.#{$fa-css-prefix}-user-secret:before { content: $fa-var-user-secret; } -.#{$fa-css-prefix}-motorcycle:before { content: $fa-var-motorcycle; } -.#{$fa-css-prefix}-street-view:before { content: $fa-var-street-view; } -.#{$fa-css-prefix}-heartbeat:before { content: $fa-var-heartbeat; } -.#{$fa-css-prefix}-venus:before { content: $fa-var-venus; } -.#{$fa-css-prefix}-mars:before { content: $fa-var-mars; } -.#{$fa-css-prefix}-mercury:before { content: $fa-var-mercury; } -.#{$fa-css-prefix}-intersex:before, -.#{$fa-css-prefix}-transgender:before { content: $fa-var-transgender; } -.#{$fa-css-prefix}-transgender-alt:before { content: $fa-var-transgender-alt; } -.#{$fa-css-prefix}-venus-double:before { content: $fa-var-venus-double; } -.#{$fa-css-prefix}-mars-double:before { content: $fa-var-mars-double; } -.#{$fa-css-prefix}-venus-mars:before { content: $fa-var-venus-mars; } -.#{$fa-css-prefix}-mars-stroke:before { content: $fa-var-mars-stroke; } -.#{$fa-css-prefix}-mars-stroke-v:before { content: $fa-var-mars-stroke-v; } -.#{$fa-css-prefix}-mars-stroke-h:before { content: $fa-var-mars-stroke-h; } -.#{$fa-css-prefix}-neuter:before { content: $fa-var-neuter; } -.#{$fa-css-prefix}-genderless:before { content: $fa-var-genderless; } -.#{$fa-css-prefix}-facebook-official:before { content: $fa-var-facebook-official; } -.#{$fa-css-prefix}-pinterest-p:before { content: $fa-var-pinterest-p; } -.#{$fa-css-prefix}-whatsapp:before { content: $fa-var-whatsapp; } -.#{$fa-css-prefix}-server:before { content: $fa-var-server; } -.#{$fa-css-prefix}-user-plus:before { content: $fa-var-user-plus; } -.#{$fa-css-prefix}-user-times:before { content: $fa-var-user-times; } -.#{$fa-css-prefix}-hotel:before, -.#{$fa-css-prefix}-bed:before { content: $fa-var-bed; } -.#{$fa-css-prefix}-viacoin:before { content: $fa-var-viacoin; } -.#{$fa-css-prefix}-train:before { content: $fa-var-train; } -.#{$fa-css-prefix}-subway:before { content: $fa-var-subway; } -.#{$fa-css-prefix}-medium:before { content: $fa-var-medium; } -.#{$fa-css-prefix}-yc:before, -.#{$fa-css-prefix}-y-combinator:before { content: $fa-var-y-combinator; } -.#{$fa-css-prefix}-optin-monster:before { content: $fa-var-optin-monster; } -.#{$fa-css-prefix}-opencart:before { content: $fa-var-opencart; } -.#{$fa-css-prefix}-expeditedssl:before { content: $fa-var-expeditedssl; } -.#{$fa-css-prefix}-battery-4:before, -.#{$fa-css-prefix}-battery:before, -.#{$fa-css-prefix}-battery-full:before { content: $fa-var-battery-full; } -.#{$fa-css-prefix}-battery-3:before, -.#{$fa-css-prefix}-battery-three-quarters:before { content: $fa-var-battery-three-quarters; } -.#{$fa-css-prefix}-battery-2:before, -.#{$fa-css-prefix}-battery-half:before { content: $fa-var-battery-half; } -.#{$fa-css-prefix}-battery-1:before, -.#{$fa-css-prefix}-battery-quarter:before { content: $fa-var-battery-quarter; } -.#{$fa-css-prefix}-battery-0:before, -.#{$fa-css-prefix}-battery-empty:before { content: $fa-var-battery-empty; } -.#{$fa-css-prefix}-mouse-pointer:before { content: $fa-var-mouse-pointer; } -.#{$fa-css-prefix}-i-cursor:before { content: $fa-var-i-cursor; } -.#{$fa-css-prefix}-object-group:before { content: $fa-var-object-group; } -.#{$fa-css-prefix}-object-ungroup:before { content: $fa-var-object-ungroup; } -.#{$fa-css-prefix}-sticky-note:before { content: $fa-var-sticky-note; } -.#{$fa-css-prefix}-sticky-note-o:before { content: $fa-var-sticky-note-o; } -.#{$fa-css-prefix}-cc-jcb:before { content: $fa-var-cc-jcb; } -.#{$fa-css-prefix}-cc-diners-club:before { content: $fa-var-cc-diners-club; } -.#{$fa-css-prefix}-clone:before { content: $fa-var-clone; } -.#{$fa-css-prefix}-balance-scale:before { content: $fa-var-balance-scale; } -.#{$fa-css-prefix}-hourglass-o:before { content: $fa-var-hourglass-o; } -.#{$fa-css-prefix}-hourglass-1:before, -.#{$fa-css-prefix}-hourglass-start:before { content: $fa-var-hourglass-start; } -.#{$fa-css-prefix}-hourglass-2:before, -.#{$fa-css-prefix}-hourglass-half:before { content: $fa-var-hourglass-half; } -.#{$fa-css-prefix}-hourglass-3:before, -.#{$fa-css-prefix}-hourglass-end:before { content: $fa-var-hourglass-end; } -.#{$fa-css-prefix}-hourglass:before { content: $fa-var-hourglass; } -.#{$fa-css-prefix}-hand-grab-o:before, -.#{$fa-css-prefix}-hand-rock-o:before { content: $fa-var-hand-rock-o; } -.#{$fa-css-prefix}-hand-stop-o:before, -.#{$fa-css-prefix}-hand-paper-o:before { content: $fa-var-hand-paper-o; } -.#{$fa-css-prefix}-hand-scissors-o:before { content: $fa-var-hand-scissors-o; } -.#{$fa-css-prefix}-hand-lizard-o:before { content: $fa-var-hand-lizard-o; } -.#{$fa-css-prefix}-hand-spock-o:before { content: $fa-var-hand-spock-o; } -.#{$fa-css-prefix}-hand-pointer-o:before { content: $fa-var-hand-pointer-o; } -.#{$fa-css-prefix}-hand-peace-o:before { content: $fa-var-hand-peace-o; } -.#{$fa-css-prefix}-trademark:before { content: $fa-var-trademark; } -.#{$fa-css-prefix}-registered:before { content: $fa-var-registered; } -.#{$fa-css-prefix}-creative-commons:before { content: $fa-var-creative-commons; } -.#{$fa-css-prefix}-gg:before { content: $fa-var-gg; } -.#{$fa-css-prefix}-gg-circle:before { content: $fa-var-gg-circle; } -.#{$fa-css-prefix}-tripadvisor:before { content: $fa-var-tripadvisor; } -.#{$fa-css-prefix}-odnoklassniki:before { content: $fa-var-odnoklassniki; } -.#{$fa-css-prefix}-odnoklassniki-square:before { content: $fa-var-odnoklassniki-square; } -.#{$fa-css-prefix}-get-pocket:before { content: $fa-var-get-pocket; } -.#{$fa-css-prefix}-wikipedia-w:before { content: $fa-var-wikipedia-w; } -.#{$fa-css-prefix}-safari:before { content: $fa-var-safari; } -.#{$fa-css-prefix}-chrome:before { content: $fa-var-chrome; } -.#{$fa-css-prefix}-firefox:before { content: $fa-var-firefox; } -.#{$fa-css-prefix}-opera:before { content: $fa-var-opera; } -.#{$fa-css-prefix}-internet-explorer:before { content: $fa-var-internet-explorer; } -.#{$fa-css-prefix}-tv:before, -.#{$fa-css-prefix}-television:before { content: $fa-var-television; } -.#{$fa-css-prefix}-contao:before { content: $fa-var-contao; } -.#{$fa-css-prefix}-500px:before { content: $fa-var-500px; } -.#{$fa-css-prefix}-amazon:before { content: $fa-var-amazon; } -.#{$fa-css-prefix}-calendar-plus-o:before { content: $fa-var-calendar-plus-o; } -.#{$fa-css-prefix}-calendar-minus-o:before { content: $fa-var-calendar-minus-o; } -.#{$fa-css-prefix}-calendar-times-o:before { content: $fa-var-calendar-times-o; } -.#{$fa-css-prefix}-calendar-check-o:before { content: $fa-var-calendar-check-o; } -.#{$fa-css-prefix}-industry:before { content: $fa-var-industry; } -.#{$fa-css-prefix}-map-pin:before { content: $fa-var-map-pin; } -.#{$fa-css-prefix}-map-signs:before { content: $fa-var-map-signs; } -.#{$fa-css-prefix}-map-o:before { content: $fa-var-map-o; } -.#{$fa-css-prefix}-map:before { content: $fa-var-map; } -.#{$fa-css-prefix}-commenting:before { content: $fa-var-commenting; } -.#{$fa-css-prefix}-commenting-o:before { content: $fa-var-commenting-o; } -.#{$fa-css-prefix}-houzz:before { content: $fa-var-houzz; } -.#{$fa-css-prefix}-vimeo:before { content: $fa-var-vimeo; } -.#{$fa-css-prefix}-black-tie:before { content: $fa-var-black-tie; } -.#{$fa-css-prefix}-fonticons:before { content: $fa-var-fonticons; } -.#{$fa-css-prefix}-reddit-alien:before { content: $fa-var-reddit-alien; } -.#{$fa-css-prefix}-edge:before { content: $fa-var-edge; } -.#{$fa-css-prefix}-credit-card-alt:before { content: $fa-var-credit-card-alt; } -.#{$fa-css-prefix}-codiepie:before { content: $fa-var-codiepie; } -.#{$fa-css-prefix}-modx:before { content: $fa-var-modx; } -.#{$fa-css-prefix}-fort-awesome:before { content: $fa-var-fort-awesome; } -.#{$fa-css-prefix}-usb:before { content: $fa-var-usb; } -.#{$fa-css-prefix}-product-hunt:before { content: $fa-var-product-hunt; } -.#{$fa-css-prefix}-mixcloud:before { content: $fa-var-mixcloud; } -.#{$fa-css-prefix}-scribd:before { content: $fa-var-scribd; } -.#{$fa-css-prefix}-pause-circle:before { content: $fa-var-pause-circle; } -.#{$fa-css-prefix}-pause-circle-o:before { content: $fa-var-pause-circle-o; } -.#{$fa-css-prefix}-stop-circle:before { content: $fa-var-stop-circle; } -.#{$fa-css-prefix}-stop-circle-o:before { content: $fa-var-stop-circle-o; } -.#{$fa-css-prefix}-shopping-bag:before { content: $fa-var-shopping-bag; } -.#{$fa-css-prefix}-shopping-basket:before { content: $fa-var-shopping-basket; } -.#{$fa-css-prefix}-hashtag:before { content: $fa-var-hashtag; } -.#{$fa-css-prefix}-bluetooth:before { content: $fa-var-bluetooth; } -.#{$fa-css-prefix}-bluetooth-b:before { content: $fa-var-bluetooth-b; } -.#{$fa-css-prefix}-percent:before { content: $fa-var-percent; } -.#{$fa-css-prefix}-gitlab:before { content: $fa-var-gitlab; } -.#{$fa-css-prefix}-wpbeginner:before { content: $fa-var-wpbeginner; } -.#{$fa-css-prefix}-wpforms:before { content: $fa-var-wpforms; } -.#{$fa-css-prefix}-envira:before { content: $fa-var-envira; } -.#{$fa-css-prefix}-universal-access:before { content: $fa-var-universal-access; } -.#{$fa-css-prefix}-wheelchair-alt:before { content: $fa-var-wheelchair-alt; } -.#{$fa-css-prefix}-question-circle-o:before { content: $fa-var-question-circle-o; } -.#{$fa-css-prefix}-blind:before { content: $fa-var-blind; } -.#{$fa-css-prefix}-audio-description:before { content: $fa-var-audio-description; } -.#{$fa-css-prefix}-volume-control-phone:before { content: $fa-var-volume-control-phone; } -.#{$fa-css-prefix}-braille:before { content: $fa-var-braille; } -.#{$fa-css-prefix}-assistive-listening-systems:before { content: $fa-var-assistive-listening-systems; } -.#{$fa-css-prefix}-asl-interpreting:before, -.#{$fa-css-prefix}-american-sign-language-interpreting:before { content: $fa-var-american-sign-language-interpreting; } -.#{$fa-css-prefix}-deafness:before, -.#{$fa-css-prefix}-hard-of-hearing:before, -.#{$fa-css-prefix}-deaf:before { content: $fa-var-deaf; } -.#{$fa-css-prefix}-glide:before { content: $fa-var-glide; } -.#{$fa-css-prefix}-glide-g:before { content: $fa-var-glide-g; } -.#{$fa-css-prefix}-signing:before, -.#{$fa-css-prefix}-sign-language:before { content: $fa-var-sign-language; } -.#{$fa-css-prefix}-low-vision:before { content: $fa-var-low-vision; } -.#{$fa-css-prefix}-viadeo:before { content: $fa-var-viadeo; } -.#{$fa-css-prefix}-viadeo-square:before { content: $fa-var-viadeo-square; } -.#{$fa-css-prefix}-snapchat:before { content: $fa-var-snapchat; } -.#{$fa-css-prefix}-snapchat-ghost:before { content: $fa-var-snapchat-ghost; } -.#{$fa-css-prefix}-snapchat-square:before { content: $fa-var-snapchat-square; } -.#{$fa-css-prefix}-pied-piper:before { content: $fa-var-pied-piper; } -.#{$fa-css-prefix}-first-order:before { content: $fa-var-first-order; } -.#{$fa-css-prefix}-yoast:before { content: $fa-var-yoast; } -.#{$fa-css-prefix}-themeisle:before { content: $fa-var-themeisle; } -.#{$fa-css-prefix}-google-plus-circle:before, -.#{$fa-css-prefix}-google-plus-official:before { content: $fa-var-google-plus-official; } -.#{$fa-css-prefix}-fa:before, -.#{$fa-css-prefix}-font-awesome:before { content: $fa-var-font-awesome; } -.#{$fa-css-prefix}-handshake-o:before { content: $fa-var-handshake-o; } -.#{$fa-css-prefix}-envelope-open:before { content: $fa-var-envelope-open; } -.#{$fa-css-prefix}-envelope-open-o:before { content: $fa-var-envelope-open-o; } -.#{$fa-css-prefix}-linode:before { content: $fa-var-linode; } -.#{$fa-css-prefix}-address-book:before { content: $fa-var-address-book; } -.#{$fa-css-prefix}-address-book-o:before { content: $fa-var-address-book-o; } -.#{$fa-css-prefix}-vcard:before, -.#{$fa-css-prefix}-address-card:before { content: $fa-var-address-card; } -.#{$fa-css-prefix}-vcard-o:before, -.#{$fa-css-prefix}-address-card-o:before { content: $fa-var-address-card-o; } -.#{$fa-css-prefix}-user-circle:before { content: $fa-var-user-circle; } -.#{$fa-css-prefix}-user-circle-o:before { content: $fa-var-user-circle-o; } -.#{$fa-css-prefix}-user-o:before { content: $fa-var-user-o; } -.#{$fa-css-prefix}-id-badge:before { content: $fa-var-id-badge; } -.#{$fa-css-prefix}-drivers-license:before, -.#{$fa-css-prefix}-id-card:before { content: $fa-var-id-card; } -.#{$fa-css-prefix}-drivers-license-o:before, -.#{$fa-css-prefix}-id-card-o:before { content: $fa-var-id-card-o; } -.#{$fa-css-prefix}-quora:before { content: $fa-var-quora; } -.#{$fa-css-prefix}-free-code-camp:before { content: $fa-var-free-code-camp; } -.#{$fa-css-prefix}-telegram:before { content: $fa-var-telegram; } -.#{$fa-css-prefix}-thermometer-4:before, -.#{$fa-css-prefix}-thermometer:before, -.#{$fa-css-prefix}-thermometer-full:before { content: $fa-var-thermometer-full; } -.#{$fa-css-prefix}-thermometer-3:before, -.#{$fa-css-prefix}-thermometer-three-quarters:before { content: $fa-var-thermometer-three-quarters; } -.#{$fa-css-prefix}-thermometer-2:before, -.#{$fa-css-prefix}-thermometer-half:before { content: $fa-var-thermometer-half; } -.#{$fa-css-prefix}-thermometer-1:before, -.#{$fa-css-prefix}-thermometer-quarter:before { content: $fa-var-thermometer-quarter; } -.#{$fa-css-prefix}-thermometer-0:before, -.#{$fa-css-prefix}-thermometer-empty:before { content: $fa-var-thermometer-empty; } -.#{$fa-css-prefix}-shower:before { content: $fa-var-shower; } -.#{$fa-css-prefix}-bathtub:before, -.#{$fa-css-prefix}-s15:before, -.#{$fa-css-prefix}-bath:before { content: $fa-var-bath; } -.#{$fa-css-prefix}-podcast:before { content: $fa-var-podcast; } -.#{$fa-css-prefix}-window-maximize:before { content: $fa-var-window-maximize; } -.#{$fa-css-prefix}-window-minimize:before { content: $fa-var-window-minimize; } -.#{$fa-css-prefix}-window-restore:before { content: $fa-var-window-restore; } -.#{$fa-css-prefix}-times-rectangle:before, -.#{$fa-css-prefix}-window-close:before { content: $fa-var-window-close; } -.#{$fa-css-prefix}-times-rectangle-o:before, -.#{$fa-css-prefix}-window-close-o:before { content: $fa-var-window-close-o; } -.#{$fa-css-prefix}-bandcamp:before { content: $fa-var-bandcamp; } -.#{$fa-css-prefix}-grav:before { content: $fa-var-grav; } -.#{$fa-css-prefix}-etsy:before { content: $fa-var-etsy; } -.#{$fa-css-prefix}-imdb:before { content: $fa-var-imdb; } -.#{$fa-css-prefix}-ravelry:before { content: $fa-var-ravelry; } -.#{$fa-css-prefix}-eercast:before { content: $fa-var-eercast; } -.#{$fa-css-prefix}-microchip:before { content: $fa-var-microchip; } -.#{$fa-css-prefix}-snowflake-o:before { content: $fa-var-snowflake-o; } -.#{$fa-css-prefix}-superpowers:before { content: $fa-var-superpowers; } -.#{$fa-css-prefix}-wpexplorer:before { content: $fa-var-wpexplorer; } -.#{$fa-css-prefix}-meetup:before { content: $fa-var-meetup; } diff --git a/manager/src/frontend/scss/fontawesome/_larger.scss b/manager/src/frontend/scss/fontawesome/_larger.scss deleted file mode 100755 index 41e9a818..00000000 --- a/manager/src/frontend/scss/fontawesome/_larger.scss +++ /dev/null @@ -1,13 +0,0 @@ -// Icon Sizes -// ------------------------- - -/* makes the font 33% larger relative to the icon container */ -.#{$fa-css-prefix}-lg { - font-size: (4em / 3); - line-height: (3em / 4); - vertical-align: -15%; -} -.#{$fa-css-prefix}-2x { font-size: 2em; } -.#{$fa-css-prefix}-3x { font-size: 3em; } -.#{$fa-css-prefix}-4x { font-size: 4em; } -.#{$fa-css-prefix}-5x { font-size: 5em; } diff --git a/manager/src/frontend/scss/fontawesome/_list.scss b/manager/src/frontend/scss/fontawesome/_list.scss deleted file mode 100755 index 7d1e4d54..00000000 --- a/manager/src/frontend/scss/fontawesome/_list.scss +++ /dev/null @@ -1,19 +0,0 @@ -// List Icons -// ------------------------- - -.#{$fa-css-prefix}-ul { - padding-left: 0; - margin-left: $fa-li-width; - list-style-type: none; - > li { position: relative; } -} -.#{$fa-css-prefix}-li { - position: absolute; - left: -$fa-li-width; - width: $fa-li-width; - top: (2em / 14); - text-align: center; - &.#{$fa-css-prefix}-lg { - left: -$fa-li-width + (4em / 14); - } -} diff --git a/manager/src/frontend/scss/fontawesome/_mixins.scss b/manager/src/frontend/scss/fontawesome/_mixins.scss deleted file mode 100755 index c3bbd574..00000000 --- a/manager/src/frontend/scss/fontawesome/_mixins.scss +++ /dev/null @@ -1,60 +0,0 @@ -// Mixins -// -------------------------- - -@mixin fa-icon() { - display: inline-block; - font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration - font-size: inherit; // can't have font-size inherit on line above, so need to override - text-rendering: auto; // optimizelegibility throws things off #1094 - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -} - -@mixin fa-icon-rotate($degrees, $rotation) { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation})"; - -webkit-transform: rotate($degrees); - -ms-transform: rotate($degrees); - transform: rotate($degrees); -} - -@mixin fa-icon-flip($horiz, $vert, $rotation) { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}, mirror=1)"; - -webkit-transform: scale($horiz, $vert); - -ms-transform: scale($horiz, $vert); - transform: scale($horiz, $vert); -} - - -// Only display content to screen readers. A la Bootstrap 4. -// -// See: http://a11yproject.com/posts/how-to-hide-content/ - -@mixin sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0,0,0,0); - border: 0; -} - -// Use in conjunction with .sr-only to only display content when it's focused. -// -// Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 -// -// Credit: HTML5 Boilerplate - -@mixin sr-only-focusable { - &:active, - &:focus { - position: static; - width: auto; - height: auto; - margin: 0; - overflow: visible; - clip: auto; - } -} diff --git a/manager/src/frontend/scss/fontawesome/_path.scss b/manager/src/frontend/scss/fontawesome/_path.scss deleted file mode 100755 index bb457c23..00000000 --- a/manager/src/frontend/scss/fontawesome/_path.scss +++ /dev/null @@ -1,15 +0,0 @@ -/* FONT PATH - * -------------------------- */ - -@font-face { - font-family: 'FontAwesome'; - src: url('#{$fa-font-path}/fontawesome-webfont.eot?v=#{$fa-version}'); - src: url('#{$fa-font-path}/fontawesome-webfont.eot?#iefix&v=#{$fa-version}') format('embedded-opentype'), - url('#{$fa-font-path}/fontawesome-webfont.woff2?v=#{$fa-version}') format('woff2'), - url('#{$fa-font-path}/fontawesome-webfont.woff?v=#{$fa-version}') format('woff'), - url('#{$fa-font-path}/fontawesome-webfont.ttf?v=#{$fa-version}') format('truetype'), - url('#{$fa-font-path}/fontawesome-webfont.svg?v=#{$fa-version}#fontawesomeregular') format('svg'); -// src: url('#{$fa-font-path}/FontAwesome.otf') format('opentype'); // used when developing fonts - font-weight: normal; - font-style: normal; -} diff --git a/manager/src/frontend/scss/fontawesome/_rotated-flipped.scss b/manager/src/frontend/scss/fontawesome/_rotated-flipped.scss deleted file mode 100755 index a3558fd0..00000000 --- a/manager/src/frontend/scss/fontawesome/_rotated-flipped.scss +++ /dev/null @@ -1,20 +0,0 @@ -// Rotated & Flipped Icons -// ------------------------- - -.#{$fa-css-prefix}-rotate-90 { @include fa-icon-rotate(90deg, 1); } -.#{$fa-css-prefix}-rotate-180 { @include fa-icon-rotate(180deg, 2); } -.#{$fa-css-prefix}-rotate-270 { @include fa-icon-rotate(270deg, 3); } - -.#{$fa-css-prefix}-flip-horizontal { @include fa-icon-flip(-1, 1, 0); } -.#{$fa-css-prefix}-flip-vertical { @include fa-icon-flip(1, -1, 2); } - -// Hook for IE8-9 -// ------------------------- - -:root .#{$fa-css-prefix}-rotate-90, -:root .#{$fa-css-prefix}-rotate-180, -:root .#{$fa-css-prefix}-rotate-270, -:root .#{$fa-css-prefix}-flip-horizontal, -:root .#{$fa-css-prefix}-flip-vertical { - filter: none; -} diff --git a/manager/src/frontend/scss/fontawesome/_screen-reader.scss b/manager/src/frontend/scss/fontawesome/_screen-reader.scss deleted file mode 100755 index 637426f0..00000000 --- a/manager/src/frontend/scss/fontawesome/_screen-reader.scss +++ /dev/null @@ -1,5 +0,0 @@ -// Screen Readers -// ------------------------- - -.sr-only { @include sr-only(); } -.sr-only-focusable { @include sr-only-focusable(); } diff --git a/manager/src/frontend/scss/fontawesome/_stacked.scss b/manager/src/frontend/scss/fontawesome/_stacked.scss deleted file mode 100755 index aef74036..00000000 --- a/manager/src/frontend/scss/fontawesome/_stacked.scss +++ /dev/null @@ -1,20 +0,0 @@ -// Stacked Icons -// ------------------------- - -.#{$fa-css-prefix}-stack { - position: relative; - display: inline-block; - width: 2em; - height: 2em; - line-height: 2em; - vertical-align: middle; -} -.#{$fa-css-prefix}-stack-1x, .#{$fa-css-prefix}-stack-2x { - position: absolute; - left: 0; - width: 100%; - text-align: center; -} -.#{$fa-css-prefix}-stack-1x { line-height: inherit; } -.#{$fa-css-prefix}-stack-2x { font-size: 2em; } -.#{$fa-css-prefix}-inverse { color: $fa-inverse; } diff --git a/manager/src/frontend/scss/fontawesome/_variables.scss b/manager/src/frontend/scss/fontawesome/_variables.scss deleted file mode 100755 index 498fc4a0..00000000 --- a/manager/src/frontend/scss/fontawesome/_variables.scss +++ /dev/null @@ -1,800 +0,0 @@ -// Variables -// -------------------------- - -$fa-font-path: "../fonts" !default; -$fa-font-size-base: 14px !default; -$fa-line-height-base: 1 !default; -//$fa-font-path: "//netdna.bootstrapcdn.com/font-awesome/4.7.0/fonts" !default; // for referencing Bootstrap CDN font files directly -$fa-css-prefix: fa !default; -$fa-version: "4.7.0" !default; -$fa-border-color: #eee !default; -$fa-inverse: #fff !default; -$fa-li-width: (30em / 14) !default; - -$fa-var-500px: "\f26e"; -$fa-var-address-book: "\f2b9"; -$fa-var-address-book-o: "\f2ba"; -$fa-var-address-card: "\f2bb"; -$fa-var-address-card-o: "\f2bc"; -$fa-var-adjust: "\f042"; -$fa-var-adn: "\f170"; -$fa-var-align-center: "\f037"; -$fa-var-align-justify: "\f039"; -$fa-var-align-left: "\f036"; -$fa-var-align-right: "\f038"; -$fa-var-amazon: "\f270"; -$fa-var-ambulance: "\f0f9"; -$fa-var-american-sign-language-interpreting: "\f2a3"; -$fa-var-anchor: "\f13d"; -$fa-var-android: "\f17b"; -$fa-var-angellist: "\f209"; -$fa-var-angle-double-down: "\f103"; -$fa-var-angle-double-left: "\f100"; -$fa-var-angle-double-right: "\f101"; -$fa-var-angle-double-up: "\f102"; -$fa-var-angle-down: "\f107"; -$fa-var-angle-left: "\f104"; -$fa-var-angle-right: "\f105"; -$fa-var-angle-up: "\f106"; -$fa-var-apple: "\f179"; -$fa-var-archive: "\f187"; -$fa-var-area-chart: "\f1fe"; -$fa-var-arrow-circle-down: "\f0ab"; -$fa-var-arrow-circle-left: "\f0a8"; -$fa-var-arrow-circle-o-down: "\f01a"; -$fa-var-arrow-circle-o-left: "\f190"; -$fa-var-arrow-circle-o-right: "\f18e"; -$fa-var-arrow-circle-o-up: "\f01b"; -$fa-var-arrow-circle-right: "\f0a9"; -$fa-var-arrow-circle-up: "\f0aa"; -$fa-var-arrow-down: "\f063"; -$fa-var-arrow-left: "\f060"; -$fa-var-arrow-right: "\f061"; -$fa-var-arrow-up: "\f062"; -$fa-var-arrows: "\f047"; -$fa-var-arrows-alt: "\f0b2"; -$fa-var-arrows-h: "\f07e"; -$fa-var-arrows-v: "\f07d"; -$fa-var-asl-interpreting: "\f2a3"; -$fa-var-assistive-listening-systems: "\f2a2"; -$fa-var-asterisk: "\f069"; -$fa-var-at: "\f1fa"; -$fa-var-audio-description: "\f29e"; -$fa-var-automobile: "\f1b9"; -$fa-var-backward: "\f04a"; -$fa-var-balance-scale: "\f24e"; -$fa-var-ban: "\f05e"; -$fa-var-bandcamp: "\f2d5"; -$fa-var-bank: "\f19c"; -$fa-var-bar-chart: "\f080"; -$fa-var-bar-chart-o: "\f080"; -$fa-var-barcode: "\f02a"; -$fa-var-bars: "\f0c9"; -$fa-var-bath: "\f2cd"; -$fa-var-bathtub: "\f2cd"; -$fa-var-battery: "\f240"; -$fa-var-battery-0: "\f244"; -$fa-var-battery-1: "\f243"; -$fa-var-battery-2: "\f242"; -$fa-var-battery-3: "\f241"; -$fa-var-battery-4: "\f240"; -$fa-var-battery-empty: "\f244"; -$fa-var-battery-full: "\f240"; -$fa-var-battery-half: "\f242"; -$fa-var-battery-quarter: "\f243"; -$fa-var-battery-three-quarters: "\f241"; -$fa-var-bed: "\f236"; -$fa-var-beer: "\f0fc"; -$fa-var-behance: "\f1b4"; -$fa-var-behance-square: "\f1b5"; -$fa-var-bell: "\f0f3"; -$fa-var-bell-o: "\f0a2"; -$fa-var-bell-slash: "\f1f6"; -$fa-var-bell-slash-o: "\f1f7"; -$fa-var-bicycle: "\f206"; -$fa-var-binoculars: "\f1e5"; -$fa-var-birthday-cake: "\f1fd"; -$fa-var-bitbucket: "\f171"; -$fa-var-bitbucket-square: "\f172"; -$fa-var-bitcoin: "\f15a"; -$fa-var-black-tie: "\f27e"; -$fa-var-blind: "\f29d"; -$fa-var-bluetooth: "\f293"; -$fa-var-bluetooth-b: "\f294"; -$fa-var-bold: "\f032"; -$fa-var-bolt: "\f0e7"; -$fa-var-bomb: "\f1e2"; -$fa-var-book: "\f02d"; -$fa-var-bookmark: "\f02e"; -$fa-var-bookmark-o: "\f097"; -$fa-var-braille: "\f2a1"; -$fa-var-briefcase: "\f0b1"; -$fa-var-btc: "\f15a"; -$fa-var-bug: "\f188"; -$fa-var-building: "\f1ad"; -$fa-var-building-o: "\f0f7"; -$fa-var-bullhorn: "\f0a1"; -$fa-var-bullseye: "\f140"; -$fa-var-bus: "\f207"; -$fa-var-buysellads: "\f20d"; -$fa-var-cab: "\f1ba"; -$fa-var-calculator: "\f1ec"; -$fa-var-calendar: "\f073"; -$fa-var-calendar-check-o: "\f274"; -$fa-var-calendar-minus-o: "\f272"; -$fa-var-calendar-o: "\f133"; -$fa-var-calendar-plus-o: "\f271"; -$fa-var-calendar-times-o: "\f273"; -$fa-var-camera: "\f030"; -$fa-var-camera-retro: "\f083"; -$fa-var-car: "\f1b9"; -$fa-var-caret-down: "\f0d7"; -$fa-var-caret-left: "\f0d9"; -$fa-var-caret-right: "\f0da"; -$fa-var-caret-square-o-down: "\f150"; -$fa-var-caret-square-o-left: "\f191"; -$fa-var-caret-square-o-right: "\f152"; -$fa-var-caret-square-o-up: "\f151"; -$fa-var-caret-up: "\f0d8"; -$fa-var-cart-arrow-down: "\f218"; -$fa-var-cart-plus: "\f217"; -$fa-var-cc: "\f20a"; -$fa-var-cc-amex: "\f1f3"; -$fa-var-cc-diners-club: "\f24c"; -$fa-var-cc-discover: "\f1f2"; -$fa-var-cc-jcb: "\f24b"; -$fa-var-cc-mastercard: "\f1f1"; -$fa-var-cc-paypal: "\f1f4"; -$fa-var-cc-stripe: "\f1f5"; -$fa-var-cc-visa: "\f1f0"; -$fa-var-certificate: "\f0a3"; -$fa-var-chain: "\f0c1"; -$fa-var-chain-broken: "\f127"; -$fa-var-check: "\f00c"; -$fa-var-check-circle: "\f058"; -$fa-var-check-circle-o: "\f05d"; -$fa-var-check-square: "\f14a"; -$fa-var-check-square-o: "\f046"; -$fa-var-chevron-circle-down: "\f13a"; -$fa-var-chevron-circle-left: "\f137"; -$fa-var-chevron-circle-right: "\f138"; -$fa-var-chevron-circle-up: "\f139"; -$fa-var-chevron-down: "\f078"; -$fa-var-chevron-left: "\f053"; -$fa-var-chevron-right: "\f054"; -$fa-var-chevron-up: "\f077"; -$fa-var-child: "\f1ae"; -$fa-var-chrome: "\f268"; -$fa-var-circle: "\f111"; -$fa-var-circle-o: "\f10c"; -$fa-var-circle-o-notch: "\f1ce"; -$fa-var-circle-thin: "\f1db"; -$fa-var-clipboard: "\f0ea"; -$fa-var-clock-o: "\f017"; -$fa-var-clone: "\f24d"; -$fa-var-close: "\f00d"; -$fa-var-cloud: "\f0c2"; -$fa-var-cloud-download: "\f0ed"; -$fa-var-cloud-upload: "\f0ee"; -$fa-var-cny: "\f157"; -$fa-var-code: "\f121"; -$fa-var-code-fork: "\f126"; -$fa-var-codepen: "\f1cb"; -$fa-var-codiepie: "\f284"; -$fa-var-coffee: "\f0f4"; -$fa-var-cog: "\f013"; -$fa-var-cogs: "\f085"; -$fa-var-columns: "\f0db"; -$fa-var-comment: "\f075"; -$fa-var-comment-o: "\f0e5"; -$fa-var-commenting: "\f27a"; -$fa-var-commenting-o: "\f27b"; -$fa-var-comments: "\f086"; -$fa-var-comments-o: "\f0e6"; -$fa-var-compass: "\f14e"; -$fa-var-compress: "\f066"; -$fa-var-connectdevelop: "\f20e"; -$fa-var-contao: "\f26d"; -$fa-var-copy: "\f0c5"; -$fa-var-copyright: "\f1f9"; -$fa-var-creative-commons: "\f25e"; -$fa-var-credit-card: "\f09d"; -$fa-var-credit-card-alt: "\f283"; -$fa-var-crop: "\f125"; -$fa-var-crosshairs: "\f05b"; -$fa-var-css3: "\f13c"; -$fa-var-cube: "\f1b2"; -$fa-var-cubes: "\f1b3"; -$fa-var-cut: "\f0c4"; -$fa-var-cutlery: "\f0f5"; -$fa-var-dashboard: "\f0e4"; -$fa-var-dashcube: "\f210"; -$fa-var-database: "\f1c0"; -$fa-var-deaf: "\f2a4"; -$fa-var-deafness: "\f2a4"; -$fa-var-dedent: "\f03b"; -$fa-var-delicious: "\f1a5"; -$fa-var-desktop: "\f108"; -$fa-var-deviantart: "\f1bd"; -$fa-var-diamond: "\f219"; -$fa-var-digg: "\f1a6"; -$fa-var-dollar: "\f155"; -$fa-var-dot-circle-o: "\f192"; -$fa-var-download: "\f019"; -$fa-var-dribbble: "\f17d"; -$fa-var-drivers-license: "\f2c2"; -$fa-var-drivers-license-o: "\f2c3"; -$fa-var-dropbox: "\f16b"; -$fa-var-drupal: "\f1a9"; -$fa-var-edge: "\f282"; -$fa-var-edit: "\f044"; -$fa-var-eercast: "\f2da"; -$fa-var-eject: "\f052"; -$fa-var-ellipsis-h: "\f141"; -$fa-var-ellipsis-v: "\f142"; -$fa-var-empire: "\f1d1"; -$fa-var-envelope: "\f0e0"; -$fa-var-envelope-o: "\f003"; -$fa-var-envelope-open: "\f2b6"; -$fa-var-envelope-open-o: "\f2b7"; -$fa-var-envelope-square: "\f199"; -$fa-var-envira: "\f299"; -$fa-var-eraser: "\f12d"; -$fa-var-etsy: "\f2d7"; -$fa-var-eur: "\f153"; -$fa-var-euro: "\f153"; -$fa-var-exchange: "\f0ec"; -$fa-var-exclamation: "\f12a"; -$fa-var-exclamation-circle: "\f06a"; -$fa-var-exclamation-triangle: "\f071"; -$fa-var-expand: "\f065"; -$fa-var-expeditedssl: "\f23e"; -$fa-var-external-link: "\f08e"; -$fa-var-external-link-square: "\f14c"; -$fa-var-eye: "\f06e"; -$fa-var-eye-slash: "\f070"; -$fa-var-eyedropper: "\f1fb"; -$fa-var-fa: "\f2b4"; -$fa-var-facebook: "\f09a"; -$fa-var-facebook-f: "\f09a"; -$fa-var-facebook-official: "\f230"; -$fa-var-facebook-square: "\f082"; -$fa-var-fast-backward: "\f049"; -$fa-var-fast-forward: "\f050"; -$fa-var-fax: "\f1ac"; -$fa-var-feed: "\f09e"; -$fa-var-female: "\f182"; -$fa-var-fighter-jet: "\f0fb"; -$fa-var-file: "\f15b"; -$fa-var-file-archive-o: "\f1c6"; -$fa-var-file-audio-o: "\f1c7"; -$fa-var-file-code-o: "\f1c9"; -$fa-var-file-excel-o: "\f1c3"; -$fa-var-file-image-o: "\f1c5"; -$fa-var-file-movie-o: "\f1c8"; -$fa-var-file-o: "\f016"; -$fa-var-file-pdf-o: "\f1c1"; -$fa-var-file-photo-o: "\f1c5"; -$fa-var-file-picture-o: "\f1c5"; -$fa-var-file-powerpoint-o: "\f1c4"; -$fa-var-file-sound-o: "\f1c7"; -$fa-var-file-text: "\f15c"; -$fa-var-file-text-o: "\f0f6"; -$fa-var-file-video-o: "\f1c8"; -$fa-var-file-word-o: "\f1c2"; -$fa-var-file-zip-o: "\f1c6"; -$fa-var-files-o: "\f0c5"; -$fa-var-film: "\f008"; -$fa-var-filter: "\f0b0"; -$fa-var-fire: "\f06d"; -$fa-var-fire-extinguisher: "\f134"; -$fa-var-firefox: "\f269"; -$fa-var-first-order: "\f2b0"; -$fa-var-flag: "\f024"; -$fa-var-flag-checkered: "\f11e"; -$fa-var-flag-o: "\f11d"; -$fa-var-flash: "\f0e7"; -$fa-var-flask: "\f0c3"; -$fa-var-flickr: "\f16e"; -$fa-var-floppy-o: "\f0c7"; -$fa-var-folder: "\f07b"; -$fa-var-folder-o: "\f114"; -$fa-var-folder-open: "\f07c"; -$fa-var-folder-open-o: "\f115"; -$fa-var-font: "\f031"; -$fa-var-font-awesome: "\f2b4"; -$fa-var-fonticons: "\f280"; -$fa-var-fort-awesome: "\f286"; -$fa-var-forumbee: "\f211"; -$fa-var-forward: "\f04e"; -$fa-var-foursquare: "\f180"; -$fa-var-free-code-camp: "\f2c5"; -$fa-var-frown-o: "\f119"; -$fa-var-futbol-o: "\f1e3"; -$fa-var-gamepad: "\f11b"; -$fa-var-gavel: "\f0e3"; -$fa-var-gbp: "\f154"; -$fa-var-ge: "\f1d1"; -$fa-var-gear: "\f013"; -$fa-var-gears: "\f085"; -$fa-var-genderless: "\f22d"; -$fa-var-get-pocket: "\f265"; -$fa-var-gg: "\f260"; -$fa-var-gg-circle: "\f261"; -$fa-var-gift: "\f06b"; -$fa-var-git: "\f1d3"; -$fa-var-git-square: "\f1d2"; -$fa-var-github: "\f09b"; -$fa-var-github-alt: "\f113"; -$fa-var-github-square: "\f092"; -$fa-var-gitlab: "\f296"; -$fa-var-gittip: "\f184"; -$fa-var-glass: "\f000"; -$fa-var-glide: "\f2a5"; -$fa-var-glide-g: "\f2a6"; -$fa-var-globe: "\f0ac"; -$fa-var-google: "\f1a0"; -$fa-var-google-plus: "\f0d5"; -$fa-var-google-plus-circle: "\f2b3"; -$fa-var-google-plus-official: "\f2b3"; -$fa-var-google-plus-square: "\f0d4"; -$fa-var-google-wallet: "\f1ee"; -$fa-var-graduation-cap: "\f19d"; -$fa-var-gratipay: "\f184"; -$fa-var-grav: "\f2d6"; -$fa-var-group: "\f0c0"; -$fa-var-h-square: "\f0fd"; -$fa-var-hacker-news: "\f1d4"; -$fa-var-hand-grab-o: "\f255"; -$fa-var-hand-lizard-o: "\f258"; -$fa-var-hand-o-down: "\f0a7"; -$fa-var-hand-o-left: "\f0a5"; -$fa-var-hand-o-right: "\f0a4"; -$fa-var-hand-o-up: "\f0a6"; -$fa-var-hand-paper-o: "\f256"; -$fa-var-hand-peace-o: "\f25b"; -$fa-var-hand-pointer-o: "\f25a"; -$fa-var-hand-rock-o: "\f255"; -$fa-var-hand-scissors-o: "\f257"; -$fa-var-hand-spock-o: "\f259"; -$fa-var-hand-stop-o: "\f256"; -$fa-var-handshake-o: "\f2b5"; -$fa-var-hard-of-hearing: "\f2a4"; -$fa-var-hashtag: "\f292"; -$fa-var-hdd-o: "\f0a0"; -$fa-var-header: "\f1dc"; -$fa-var-headphones: "\f025"; -$fa-var-heart: "\f004"; -$fa-var-heart-o: "\f08a"; -$fa-var-heartbeat: "\f21e"; -$fa-var-history: "\f1da"; -$fa-var-home: "\f015"; -$fa-var-hospital-o: "\f0f8"; -$fa-var-hotel: "\f236"; -$fa-var-hourglass: "\f254"; -$fa-var-hourglass-1: "\f251"; -$fa-var-hourglass-2: "\f252"; -$fa-var-hourglass-3: "\f253"; -$fa-var-hourglass-end: "\f253"; -$fa-var-hourglass-half: "\f252"; -$fa-var-hourglass-o: "\f250"; -$fa-var-hourglass-start: "\f251"; -$fa-var-houzz: "\f27c"; -$fa-var-html5: "\f13b"; -$fa-var-i-cursor: "\f246"; -$fa-var-id-badge: "\f2c1"; -$fa-var-id-card: "\f2c2"; -$fa-var-id-card-o: "\f2c3"; -$fa-var-ils: "\f20b"; -$fa-var-image: "\f03e"; -$fa-var-imdb: "\f2d8"; -$fa-var-inbox: "\f01c"; -$fa-var-indent: "\f03c"; -$fa-var-industry: "\f275"; -$fa-var-info: "\f129"; -$fa-var-info-circle: "\f05a"; -$fa-var-inr: "\f156"; -$fa-var-instagram: "\f16d"; -$fa-var-institution: "\f19c"; -$fa-var-internet-explorer: "\f26b"; -$fa-var-intersex: "\f224"; -$fa-var-ioxhost: "\f208"; -$fa-var-italic: "\f033"; -$fa-var-joomla: "\f1aa"; -$fa-var-jpy: "\f157"; -$fa-var-jsfiddle: "\f1cc"; -$fa-var-key: "\f084"; -$fa-var-keyboard-o: "\f11c"; -$fa-var-krw: "\f159"; -$fa-var-language: "\f1ab"; -$fa-var-laptop: "\f109"; -$fa-var-lastfm: "\f202"; -$fa-var-lastfm-square: "\f203"; -$fa-var-leaf: "\f06c"; -$fa-var-leanpub: "\f212"; -$fa-var-legal: "\f0e3"; -$fa-var-lemon-o: "\f094"; -$fa-var-level-down: "\f149"; -$fa-var-level-up: "\f148"; -$fa-var-life-bouy: "\f1cd"; -$fa-var-life-buoy: "\f1cd"; -$fa-var-life-ring: "\f1cd"; -$fa-var-life-saver: "\f1cd"; -$fa-var-lightbulb-o: "\f0eb"; -$fa-var-line-chart: "\f201"; -$fa-var-link: "\f0c1"; -$fa-var-linkedin: "\f0e1"; -$fa-var-linkedin-square: "\f08c"; -$fa-var-linode: "\f2b8"; -$fa-var-linux: "\f17c"; -$fa-var-list: "\f03a"; -$fa-var-list-alt: "\f022"; -$fa-var-list-ol: "\f0cb"; -$fa-var-list-ul: "\f0ca"; -$fa-var-location-arrow: "\f124"; -$fa-var-lock: "\f023"; -$fa-var-long-arrow-down: "\f175"; -$fa-var-long-arrow-left: "\f177"; -$fa-var-long-arrow-right: "\f178"; -$fa-var-long-arrow-up: "\f176"; -$fa-var-low-vision: "\f2a8"; -$fa-var-magic: "\f0d0"; -$fa-var-magnet: "\f076"; -$fa-var-mail-forward: "\f064"; -$fa-var-mail-reply: "\f112"; -$fa-var-mail-reply-all: "\f122"; -$fa-var-male: "\f183"; -$fa-var-map: "\f279"; -$fa-var-map-marker: "\f041"; -$fa-var-map-o: "\f278"; -$fa-var-map-pin: "\f276"; -$fa-var-map-signs: "\f277"; -$fa-var-mars: "\f222"; -$fa-var-mars-double: "\f227"; -$fa-var-mars-stroke: "\f229"; -$fa-var-mars-stroke-h: "\f22b"; -$fa-var-mars-stroke-v: "\f22a"; -$fa-var-maxcdn: "\f136"; -$fa-var-meanpath: "\f20c"; -$fa-var-medium: "\f23a"; -$fa-var-medkit: "\f0fa"; -$fa-var-meetup: "\f2e0"; -$fa-var-meh-o: "\f11a"; -$fa-var-mercury: "\f223"; -$fa-var-microchip: "\f2db"; -$fa-var-microphone: "\f130"; -$fa-var-microphone-slash: "\f131"; -$fa-var-minus: "\f068"; -$fa-var-minus-circle: "\f056"; -$fa-var-minus-square: "\f146"; -$fa-var-minus-square-o: "\f147"; -$fa-var-mixcloud: "\f289"; -$fa-var-mobile: "\f10b"; -$fa-var-mobile-phone: "\f10b"; -$fa-var-modx: "\f285"; -$fa-var-money: "\f0d6"; -$fa-var-moon-o: "\f186"; -$fa-var-mortar-board: "\f19d"; -$fa-var-motorcycle: "\f21c"; -$fa-var-mouse-pointer: "\f245"; -$fa-var-music: "\f001"; -$fa-var-navicon: "\f0c9"; -$fa-var-neuter: "\f22c"; -$fa-var-newspaper-o: "\f1ea"; -$fa-var-object-group: "\f247"; -$fa-var-object-ungroup: "\f248"; -$fa-var-odnoklassniki: "\f263"; -$fa-var-odnoklassniki-square: "\f264"; -$fa-var-opencart: "\f23d"; -$fa-var-openid: "\f19b"; -$fa-var-opera: "\f26a"; -$fa-var-optin-monster: "\f23c"; -$fa-var-outdent: "\f03b"; -$fa-var-pagelines: "\f18c"; -$fa-var-paint-brush: "\f1fc"; -$fa-var-paper-plane: "\f1d8"; -$fa-var-paper-plane-o: "\f1d9"; -$fa-var-paperclip: "\f0c6"; -$fa-var-paragraph: "\f1dd"; -$fa-var-paste: "\f0ea"; -$fa-var-pause: "\f04c"; -$fa-var-pause-circle: "\f28b"; -$fa-var-pause-circle-o: "\f28c"; -$fa-var-paw: "\f1b0"; -$fa-var-paypal: "\f1ed"; -$fa-var-pencil: "\f040"; -$fa-var-pencil-square: "\f14b"; -$fa-var-pencil-square-o: "\f044"; -$fa-var-percent: "\f295"; -$fa-var-phone: "\f095"; -$fa-var-phone-square: "\f098"; -$fa-var-photo: "\f03e"; -$fa-var-picture-o: "\f03e"; -$fa-var-pie-chart: "\f200"; -$fa-var-pied-piper: "\f2ae"; -$fa-var-pied-piper-alt: "\f1a8"; -$fa-var-pied-piper-pp: "\f1a7"; -$fa-var-pinterest: "\f0d2"; -$fa-var-pinterest-p: "\f231"; -$fa-var-pinterest-square: "\f0d3"; -$fa-var-plane: "\f072"; -$fa-var-play: "\f04b"; -$fa-var-play-circle: "\f144"; -$fa-var-play-circle-o: "\f01d"; -$fa-var-plug: "\f1e6"; -$fa-var-plus: "\f067"; -$fa-var-plus-circle: "\f055"; -$fa-var-plus-square: "\f0fe"; -$fa-var-plus-square-o: "\f196"; -$fa-var-podcast: "\f2ce"; -$fa-var-power-off: "\f011"; -$fa-var-print: "\f02f"; -$fa-var-product-hunt: "\f288"; -$fa-var-puzzle-piece: "\f12e"; -$fa-var-qq: "\f1d6"; -$fa-var-qrcode: "\f029"; -$fa-var-question: "\f128"; -$fa-var-question-circle: "\f059"; -$fa-var-question-circle-o: "\f29c"; -$fa-var-quora: "\f2c4"; -$fa-var-quote-left: "\f10d"; -$fa-var-quote-right: "\f10e"; -$fa-var-ra: "\f1d0"; -$fa-var-random: "\f074"; -$fa-var-ravelry: "\f2d9"; -$fa-var-rebel: "\f1d0"; -$fa-var-recycle: "\f1b8"; -$fa-var-reddit: "\f1a1"; -$fa-var-reddit-alien: "\f281"; -$fa-var-reddit-square: "\f1a2"; -$fa-var-refresh: "\f021"; -$fa-var-registered: "\f25d"; -$fa-var-remove: "\f00d"; -$fa-var-renren: "\f18b"; -$fa-var-reorder: "\f0c9"; -$fa-var-repeat: "\f01e"; -$fa-var-reply: "\f112"; -$fa-var-reply-all: "\f122"; -$fa-var-resistance: "\f1d0"; -$fa-var-retweet: "\f079"; -$fa-var-rmb: "\f157"; -$fa-var-road: "\f018"; -$fa-var-rocket: "\f135"; -$fa-var-rotate-left: "\f0e2"; -$fa-var-rotate-right: "\f01e"; -$fa-var-rouble: "\f158"; -$fa-var-rss: "\f09e"; -$fa-var-rss-square: "\f143"; -$fa-var-rub: "\f158"; -$fa-var-ruble: "\f158"; -$fa-var-rupee: "\f156"; -$fa-var-s15: "\f2cd"; -$fa-var-safari: "\f267"; -$fa-var-save: "\f0c7"; -$fa-var-scissors: "\f0c4"; -$fa-var-scribd: "\f28a"; -$fa-var-search: "\f002"; -$fa-var-search-minus: "\f010"; -$fa-var-search-plus: "\f00e"; -$fa-var-sellsy: "\f213"; -$fa-var-send: "\f1d8"; -$fa-var-send-o: "\f1d9"; -$fa-var-server: "\f233"; -$fa-var-share: "\f064"; -$fa-var-share-alt: "\f1e0"; -$fa-var-share-alt-square: "\f1e1"; -$fa-var-share-square: "\f14d"; -$fa-var-share-square-o: "\f045"; -$fa-var-shekel: "\f20b"; -$fa-var-sheqel: "\f20b"; -$fa-var-shield: "\f132"; -$fa-var-ship: "\f21a"; -$fa-var-shirtsinbulk: "\f214"; -$fa-var-shopping-bag: "\f290"; -$fa-var-shopping-basket: "\f291"; -$fa-var-shopping-cart: "\f07a"; -$fa-var-shower: "\f2cc"; -$fa-var-sign-in: "\f090"; -$fa-var-sign-language: "\f2a7"; -$fa-var-sign-out: "\f08b"; -$fa-var-signal: "\f012"; -$fa-var-signing: "\f2a7"; -$fa-var-simplybuilt: "\f215"; -$fa-var-sitemap: "\f0e8"; -$fa-var-skyatlas: "\f216"; -$fa-var-skype: "\f17e"; -$fa-var-slack: "\f198"; -$fa-var-sliders: "\f1de"; -$fa-var-slideshare: "\f1e7"; -$fa-var-smile-o: "\f118"; -$fa-var-snapchat: "\f2ab"; -$fa-var-snapchat-ghost: "\f2ac"; -$fa-var-snapchat-square: "\f2ad"; -$fa-var-snowflake-o: "\f2dc"; -$fa-var-soccer-ball-o: "\f1e3"; -$fa-var-sort: "\f0dc"; -$fa-var-sort-alpha-asc: "\f15d"; -$fa-var-sort-alpha-desc: "\f15e"; -$fa-var-sort-amount-asc: "\f160"; -$fa-var-sort-amount-desc: "\f161"; -$fa-var-sort-asc: "\f0de"; -$fa-var-sort-desc: "\f0dd"; -$fa-var-sort-down: "\f0dd"; -$fa-var-sort-numeric-asc: "\f162"; -$fa-var-sort-numeric-desc: "\f163"; -$fa-var-sort-up: "\f0de"; -$fa-var-soundcloud: "\f1be"; -$fa-var-space-shuttle: "\f197"; -$fa-var-spinner: "\f110"; -$fa-var-spoon: "\f1b1"; -$fa-var-spotify: "\f1bc"; -$fa-var-square: "\f0c8"; -$fa-var-square-o: "\f096"; -$fa-var-stack-exchange: "\f18d"; -$fa-var-stack-overflow: "\f16c"; -$fa-var-star: "\f005"; -$fa-var-star-half: "\f089"; -$fa-var-star-half-empty: "\f123"; -$fa-var-star-half-full: "\f123"; -$fa-var-star-half-o: "\f123"; -$fa-var-star-o: "\f006"; -$fa-var-steam: "\f1b6"; -$fa-var-steam-square: "\f1b7"; -$fa-var-step-backward: "\f048"; -$fa-var-step-forward: "\f051"; -$fa-var-stethoscope: "\f0f1"; -$fa-var-sticky-note: "\f249"; -$fa-var-sticky-note-o: "\f24a"; -$fa-var-stop: "\f04d"; -$fa-var-stop-circle: "\f28d"; -$fa-var-stop-circle-o: "\f28e"; -$fa-var-street-view: "\f21d"; -$fa-var-strikethrough: "\f0cc"; -$fa-var-stumbleupon: "\f1a4"; -$fa-var-stumbleupon-circle: "\f1a3"; -$fa-var-subscript: "\f12c"; -$fa-var-subway: "\f239"; -$fa-var-suitcase: "\f0f2"; -$fa-var-sun-o: "\f185"; -$fa-var-superpowers: "\f2dd"; -$fa-var-superscript: "\f12b"; -$fa-var-support: "\f1cd"; -$fa-var-table: "\f0ce"; -$fa-var-tablet: "\f10a"; -$fa-var-tachometer: "\f0e4"; -$fa-var-tag: "\f02b"; -$fa-var-tags: "\f02c"; -$fa-var-tasks: "\f0ae"; -$fa-var-taxi: "\f1ba"; -$fa-var-telegram: "\f2c6"; -$fa-var-television: "\f26c"; -$fa-var-tencent-weibo: "\f1d5"; -$fa-var-terminal: "\f120"; -$fa-var-text-height: "\f034"; -$fa-var-text-width: "\f035"; -$fa-var-th: "\f00a"; -$fa-var-th-large: "\f009"; -$fa-var-th-list: "\f00b"; -$fa-var-themeisle: "\f2b2"; -$fa-var-thermometer: "\f2c7"; -$fa-var-thermometer-0: "\f2cb"; -$fa-var-thermometer-1: "\f2ca"; -$fa-var-thermometer-2: "\f2c9"; -$fa-var-thermometer-3: "\f2c8"; -$fa-var-thermometer-4: "\f2c7"; -$fa-var-thermometer-empty: "\f2cb"; -$fa-var-thermometer-full: "\f2c7"; -$fa-var-thermometer-half: "\f2c9"; -$fa-var-thermometer-quarter: "\f2ca"; -$fa-var-thermometer-three-quarters: "\f2c8"; -$fa-var-thumb-tack: "\f08d"; -$fa-var-thumbs-down: "\f165"; -$fa-var-thumbs-o-down: "\f088"; -$fa-var-thumbs-o-up: "\f087"; -$fa-var-thumbs-up: "\f164"; -$fa-var-ticket: "\f145"; -$fa-var-times: "\f00d"; -$fa-var-times-circle: "\f057"; -$fa-var-times-circle-o: "\f05c"; -$fa-var-times-rectangle: "\f2d3"; -$fa-var-times-rectangle-o: "\f2d4"; -$fa-var-tint: "\f043"; -$fa-var-toggle-down: "\f150"; -$fa-var-toggle-left: "\f191"; -$fa-var-toggle-off: "\f204"; -$fa-var-toggle-on: "\f205"; -$fa-var-toggle-right: "\f152"; -$fa-var-toggle-up: "\f151"; -$fa-var-trademark: "\f25c"; -$fa-var-train: "\f238"; -$fa-var-transgender: "\f224"; -$fa-var-transgender-alt: "\f225"; -$fa-var-trash: "\f1f8"; -$fa-var-trash-o: "\f014"; -$fa-var-tree: "\f1bb"; -$fa-var-trello: "\f181"; -$fa-var-tripadvisor: "\f262"; -$fa-var-trophy: "\f091"; -$fa-var-truck: "\f0d1"; -$fa-var-try: "\f195"; -$fa-var-tty: "\f1e4"; -$fa-var-tumblr: "\f173"; -$fa-var-tumblr-square: "\f174"; -$fa-var-turkish-lira: "\f195"; -$fa-var-tv: "\f26c"; -$fa-var-twitch: "\f1e8"; -$fa-var-twitter: "\f099"; -$fa-var-twitter-square: "\f081"; -$fa-var-umbrella: "\f0e9"; -$fa-var-underline: "\f0cd"; -$fa-var-undo: "\f0e2"; -$fa-var-universal-access: "\f29a"; -$fa-var-university: "\f19c"; -$fa-var-unlink: "\f127"; -$fa-var-unlock: "\f09c"; -$fa-var-unlock-alt: "\f13e"; -$fa-var-unsorted: "\f0dc"; -$fa-var-upload: "\f093"; -$fa-var-usb: "\f287"; -$fa-var-usd: "\f155"; -$fa-var-user: "\f007"; -$fa-var-user-circle: "\f2bd"; -$fa-var-user-circle-o: "\f2be"; -$fa-var-user-md: "\f0f0"; -$fa-var-user-o: "\f2c0"; -$fa-var-user-plus: "\f234"; -$fa-var-user-secret: "\f21b"; -$fa-var-user-times: "\f235"; -$fa-var-users: "\f0c0"; -$fa-var-vcard: "\f2bb"; -$fa-var-vcard-o: "\f2bc"; -$fa-var-venus: "\f221"; -$fa-var-venus-double: "\f226"; -$fa-var-venus-mars: "\f228"; -$fa-var-viacoin: "\f237"; -$fa-var-viadeo: "\f2a9"; -$fa-var-viadeo-square: "\f2aa"; -$fa-var-video-camera: "\f03d"; -$fa-var-vimeo: "\f27d"; -$fa-var-vimeo-square: "\f194"; -$fa-var-vine: "\f1ca"; -$fa-var-vk: "\f189"; -$fa-var-volume-control-phone: "\f2a0"; -$fa-var-volume-down: "\f027"; -$fa-var-volume-off: "\f026"; -$fa-var-volume-up: "\f028"; -$fa-var-warning: "\f071"; -$fa-var-wechat: "\f1d7"; -$fa-var-weibo: "\f18a"; -$fa-var-weixin: "\f1d7"; -$fa-var-whatsapp: "\f232"; -$fa-var-wheelchair: "\f193"; -$fa-var-wheelchair-alt: "\f29b"; -$fa-var-wifi: "\f1eb"; -$fa-var-wikipedia-w: "\f266"; -$fa-var-window-close: "\f2d3"; -$fa-var-window-close-o: "\f2d4"; -$fa-var-window-maximize: "\f2d0"; -$fa-var-window-minimize: "\f2d1"; -$fa-var-window-restore: "\f2d2"; -$fa-var-windows: "\f17a"; -$fa-var-won: "\f159"; -$fa-var-wordpress: "\f19a"; -$fa-var-wpbeginner: "\f297"; -$fa-var-wpexplorer: "\f2de"; -$fa-var-wpforms: "\f298"; -$fa-var-wrench: "\f0ad"; -$fa-var-xing: "\f168"; -$fa-var-xing-square: "\f169"; -$fa-var-y-combinator: "\f23b"; -$fa-var-y-combinator-square: "\f1d4"; -$fa-var-yahoo: "\f19e"; -$fa-var-yc: "\f23b"; -$fa-var-yc-square: "\f1d4"; -$fa-var-yelp: "\f1e9"; -$fa-var-yen: "\f157"; -$fa-var-yoast: "\f2b1"; -$fa-var-youtube: "\f167"; -$fa-var-youtube-play: "\f16a"; -$fa-var-youtube-square: "\f166"; - diff --git a/manager/src/frontend/scss/fontawesome/font-awesome.scss b/manager/src/frontend/scss/fontawesome/font-awesome.scss deleted file mode 100755 index f1c83aaa..00000000 --- a/manager/src/frontend/scss/fontawesome/font-awesome.scss +++ /dev/null @@ -1,18 +0,0 @@ -/*! - * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */ - -@import "variables"; -@import "mixins"; -@import "path"; -@import "core"; -@import "larger"; -@import "fixed-width"; -@import "list"; -@import "bordered-pulled"; -@import "animated"; -@import "rotated-flipped"; -@import "stacked"; -@import "icons"; -@import "screen-reader"; diff --git a/manager/src/frontend/scss/pages/access-list-form.scss b/manager/src/frontend/scss/pages/access-list-form.scss deleted file mode 100644 index 83c6d1ea..00000000 --- a/manager/src/frontend/scss/pages/access-list-form.scss +++ /dev/null @@ -1,3 +0,0 @@ -#access-list-form .items .row { - margin-bottom: 5px; -} diff --git a/manager/src/frontend/scss/pages/advanced-input.scss b/manager/src/frontend/scss/pages/advanced-input.scss deleted file mode 100644 index 0ccdf864..00000000 --- a/manager/src/frontend/scss/pages/advanced-input.scss +++ /dev/null @@ -1,3 +0,0 @@ -#advanced-input { - font-family: monospace; -} diff --git a/manager/src/frontend/scss/pages/app.scss b/manager/src/frontend/scss/pages/app.scss deleted file mode 100644 index 7dfd86c1..00000000 --- a/manager/src/frontend/scss/pages/app.scss +++ /dev/null @@ -1,12 +0,0 @@ -#app { - - &.loading { - min-height: 400px; - overflow: hidden; - - .loader { - display: block; - } - } - -} diff --git a/manager/src/frontend/scss/styles.scss b/manager/src/frontend/scss/styles.scss deleted file mode 100644 index 05b6adb5..00000000 --- a/manager/src/frontend/scss/styles.scss +++ /dev/null @@ -1,13 +0,0 @@ -// Libraries -$fa-font-path: '/fonts/font-awesome'; -$bootstrap-font-path: '/fonts/bootstrap'; - -@import 'fontawesome/font-awesome'; -@import '../../../node_modules/normalize-css/normalize'; -@import '../../../node_modules/bootstrap/dist/css/bootstrap'; - -// Styles -@import 'theme'; -@import 'pages/app'; -@import 'pages/access-list-form.scss'; -@import 'pages/advanced-input'; diff --git a/manager/src/frontend/scss/theme.scss b/manager/src/frontend/scss/theme.scss deleted file mode 100644 index 6819e459..00000000 --- a/manager/src/frontend/scss/theme.scss +++ /dev/null @@ -1,7 +0,0 @@ -$main-bg: #fff; -$loader-bg: #ddd; -$loader-fg: #4f5d73; - -@import "theme/main"; -@import "theme/header"; -@import "theme/loader"; diff --git a/manager/src/frontend/scss/theme/header.scss b/manager/src/frontend/scss/theme/header.scss deleted file mode 100644 index 5a26b00c..00000000 --- a/manager/src/frontend/scss/theme/header.scss +++ /dev/null @@ -1,11 +0,0 @@ -nav { - .navbar-brand img { - width: 32px; - height: 32px; - margin-top: -6px; - } - - .navbar-right button { - margin-top: 9px; - } -} diff --git a/manager/src/frontend/scss/theme/loader.scss b/manager/src/frontend/scss/theme/loader.scss deleted file mode 100644 index 26df0bb7..00000000 --- a/manager/src/frontend/scss/theme/loader.scss +++ /dev/null @@ -1,58 +0,0 @@ -.loader, .loader:after { - border-radius: 50%; - width: 10em; - height: 10em; -} - -.loader { - margin: 60px auto; - font-size: 10px; - position: relative; - text-indent: -9999em; - border-top: 1.1em solid $loader-bg; - border-right: 1.1em solid $loader-bg; - border-bottom: 1.1em solid $loader-bg; - border-left: 1.1em solid $loader-fg; - -webkit-transform: translateZ(0); - -ms-transform: translateZ(0); - transform: translateZ(0); - -webkit-animation: load8 1.1s infinite linear; - animation: load8 1.1s infinite linear; - - &.loader-small { - margin: 30px auto; - border-top: 0.5em solid $loader-bg; - border-right: 0.5em solid $loader-bg; - border-bottom: 0.5em solid $loader-bg; - border-left: 0.5em solid $loader-fg; - } - - &.loader-small, &.loader-small:after { - width: 5em; - height: 5em; - } -} - -@-webkit-keyframes load8 { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} - -@keyframes load8 { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} diff --git a/manager/src/frontend/scss/theme/main.scss b/manager/src/frontend/scss/theme/main.scss deleted file mode 100644 index 5ca72dc7..00000000 --- a/manager/src/frontend/scss/theme/main.scss +++ /dev/null @@ -1,8 +0,0 @@ -body { - background-color: $main-bg; - padding-top: 70px; -} - -.monospace { - font-family: monospace; -} diff --git a/manager/src/frontend/views/includes/footer.ejs b/manager/src/frontend/views/includes/footer.ejs deleted file mode 100644 index 308b1d01..00000000 --- a/manager/src/frontend/views/includes/footer.ejs +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/manager/src/frontend/views/includes/header.ejs b/manager/src/frontend/views/includes/header.ejs deleted file mode 100644 index f1d28cd9..00000000 --- a/manager/src/frontend/views/includes/header.ejs +++ /dev/null @@ -1,19 +0,0 @@ - - - - - <%= title %> - - - - - - - - - - - - - - id="<%= bodyId %>"<% } %>> diff --git a/manager/src/frontend/views/index.ejs b/manager/src/frontend/views/index.ejs deleted file mode 100644 index 412f94ec..00000000 --- a/manager/src/frontend/views/index.ejs +++ /dev/null @@ -1,9 +0,0 @@ -<% var title = 'Nginx Proxy Manager' %> -<%- include includes/header.ejs %> - -
- Loading... -
- - -<%- include includes/footer.ejs %> diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 00000000..bcbb216c --- /dev/null +++ b/nodemon.json @@ -0,0 +1,9 @@ +{ + "verbose": false, + "ignore": [ + "dist", + "data", + "src/frontend" + ], + "ext": "js json ejs" +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..de2052b4 --- /dev/null +++ b/package.json @@ -0,0 +1,77 @@ +{ + "name": "nginx-proxy-manager", + "version": "2.0.0", + "description": "A beautiful interface for creating Nginx endpoints", + "main": "src/backend/index.js", + "devDependencies": { + "babel-core": "^6.26.3", + "babel-loader": "^7.1.4", + "babel-minify-webpack-plugin": "^0.3.1", + "babel-preset-env": "^1.7.0", + "backbone": "^1.3.3", + "backbone.marionette": "^4.0.0", + "copy-webpack-plugin": "^4.5.1", + "css-loader": "^1.0.0", + "ejs-loader": "^0.3.1", + "file-loader": "^1.1.11", + "imports-loader": "^0.8.0", + "jquery": "^3.3.1", + "jquery-mask-plugin": "^1.14.15", + "jquery-serializejson": "^2.8.1", + "marionette.approuter": "^1.0.0", + "marionette.templatecache": "^1.0.0", + "messageformat": "^2.0.2", + "messageformat-loader": "^0.7.0", + "mini-css-extract-plugin": "^0.4.0", + "node-sass": "^4.9.0", + "nodemon": "^1.17.5", + "numeral": "^2.0.6", + "sass-loader": "^7.0.3", + "style-loader": "^0.22.1", + "tabler-ui": "git+https://github.com/tabler/tabler.git", + "underscore": "^1.8.3", + "webpack": "^4.12.0", + "webpack-cli": "^3.0.8", + "webpack-visualizer-plugin": "^0.1.11" + }, + "dependencies": { + "ajv": "^6.5.1", + "batchflow": "^0.4.0", + "bcrypt": "^3.0.0", + "body-parser": "^1.18.3", + "compression": "^1.7.2", + "config": "^2.0.1", + "diskdb": "^0.1.17", + "ejs": "^2.6.1", + "express": "^4.16.3", + "express-fileupload": "^0.4.0", + "gravatar": "^1.6.0", + "html-entities": "^1.2.1", + "json-schema-ref-parser": "^5.0.3", + "jsonwebtoken": "^8.3.0", + "knex": "^0.15.2", + "liquidjs": "^5.1.1", + "lodash": "^4.17.10", + "moment": "^2.22.2", + "mysql": "^2.15.0", + "node-rsa": "^1.0.0", + "objection": "^1.1.10", + "path": "^0.12.7", + "restler": "^3.4.0", + "signale": "^1.2.1", + "temp-write": "^3.4.0", + "unix-timestamp": "^0.2.0" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "webpack --mode development", + "build": "webpack --mode production", + "watch": "webpack-dev-server --mode development" + }, + "signale": { + "displayDate": true, + "displayTimestamp": true + }, + "author": "Jamie Curnow ", + "license": "MIT" +} diff --git a/rootfs/etc/nginx/conf.d/default.conf b/rootfs/etc/nginx/conf.d/default.conf index c363c552..bc70e8de 100644 --- a/rootfs/etc/nginx/conf.d/default.conf +++ b/rootfs/etc/nginx/conf.d/default.conf @@ -4,13 +4,13 @@ server { listen 9876 default; server_name localhost; - access_log /config/logs/manager.log proxy; + access_log /data/logs/manager.log proxy; + + include conf.d/include/block-exploits.conf; set $server 127.0.0.1; set $port 81; - include conf.d/include/block-exploits.conf; - location /health { access_log off; include conf.d/include/proxy.conf; @@ -26,7 +26,7 @@ server { listen 80 default; server_name localhost; - access_log /config/logs/default.log proxy; + access_log /data/logs/default.log proxy; include conf.d/include/assets.conf; include conf.d/include/block-exploits.conf; diff --git a/rootfs/etc/nginx/conf.d/include/letsencrypt-acme-challenge.conf b/rootfs/etc/nginx/conf.d/include/letsencrypt-acme-challenge.conf index c2c21b54..750c9b29 100644 --- a/rootfs/etc/nginx/conf.d/include/letsencrypt-acme-challenge.conf +++ b/rootfs/etc/nginx/conf.d/include/letsencrypt-acme-challenge.conf @@ -15,7 +15,7 @@ location ^~ /.well-known/acme-challenge/ { # there to "webroot". # Do NOT use alias, use root! Target directory is located here: # /var/www/common/letsencrypt/.well-known/acme-challenge/ - root /config/letsencrypt-acme-challenge; + root /data/letsencrypt-acme-challenge; } # Hide /acme-challenge subdirectory and return 404 on all requests. diff --git a/rootfs/etc/nginx/mime.types b/rootfs/etc/nginx/mime.types new file mode 100644 index 00000000..7c7cdef2 --- /dev/null +++ b/rootfs/etc/nginx/mime.types @@ -0,0 +1,96 @@ +types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/svg+xml svg svgz; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/webp webp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + + font/woff woff; + font/woff2 woff2; + + application/java-archive jar war ear; + application/json json; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.apple.mpegurl m3u8; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/vnd.ms-excel xls; + application/vnd.ms-fontobject eot; + application/vnd.ms-powerpoint ppt; + application/vnd.oasis.opendocument.graphics odg; + application/vnd.oasis.opendocument.presentation odp; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.text odt; + application/vnd.openxmlformats-officedocument.presentationml.presentation + pptx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + xlsx; + application/vnd.openxmlformats-officedocument.wordprocessingml.document + docx; + application/vnd.wap.wmlc wmlc; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/xspf+xml xspf; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp2t ts; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} diff --git a/rootfs/etc/nginx/nginx.conf b/rootfs/etc/nginx/nginx.conf index 6007c413..a4b672c8 100644 --- a/rootfs/etc/nginx/nginx.conf +++ b/rootfs/etc/nginx/nginx.conf @@ -9,7 +9,7 @@ worker_processes auto; # Enables the use of JIT for regular expressions to speed-up their processing. pcre_jit on; -error_log /config/logs/error.log warn; +error_log /data/logs/error.log warn; # Includes files with directives to load dynamic modules. include /etc/nginx/modules/*.conf; @@ -47,13 +47,18 @@ http { # HIT # - (dash) - request never reached to upstream module. Most likely it was processed at Nginx-level only (e.g. forbidden, redirects, etc) (Ref: Mail Thread log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"'; + log_format standard '[$time_local] $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] "$http_user_agent" "$http_referer"'; - access_log /config/logs/default.log proxy; + access_log /data/logs/default.log proxy; include /etc/nginx/conf.d/*.conf; - include /config/nginx/*.conf; + include /data/nginx/proxy_host/*.conf; + include /data/nginx/redirection_host/*.conf; + include /data/nginx/dead_host/*.conf; + include /data/nginx/temp/*.conf; } stream { - include /config/nginx/stream/*.conf; + include /data/nginx/stream/*.conf; } + diff --git a/rootfs/etc/services.d/manager/run b/rootfs/etc/services.d/manager/run index a6f66329..f70905d0 100755 --- a/rootfs/etc/services.d/manager/run +++ b/rootfs/etc/services.d/manager/run @@ -1,6 +1,6 @@ #!/usr/bin/with-contenv bash -mkdir -p /config/letsencrypt-acme-challenge +mkdir -p /data/letsencrypt-acme-challenge -cd /srv/manager -node --abort_on_uncaught_exception --max_old_space_size=250 /srv/manager/src/backend/index.js +cd /app +node --abort_on_uncaught_exception --max_old_space_size=250 /app/src/backend/index.js diff --git a/rootfs/etc/services.d/nginx/run b/rootfs/etc/services.d/nginx/run index d0884241..27f4bd1f 100755 --- a/rootfs/etc/services.d/nginx/run +++ b/rootfs/etc/services.d/nginx/run @@ -1,5 +1,20 @@ #!/usr/bin/with-contenv bash -mkdir -p /var/cache/nginx/proxy_temp /tmp/nginx /config/{nginx,logs,access} /config/nginx/stream /var/lib/nginx/cache/{public,private} +mkdir -p /tmp/nginx/body \ + /var/log/nginx \ + /data/nginx \ + /data/custom_ssl \ + /data/logs \ + /data/access \ + /data/nginx/proxy_host \ + /data/nginx/redirection_host \ + /data/nginx/stream \ + /data/nginx/dead_host \ + /data/nginx/temp \ + /var/lib/nginx/cache/public \ + /var/lib/nginx/cache/private + +touch /var/log/nginx/error.log && chmod 777 /var/log/nginx/error.log chown root /tmp/nginx + exec nginx diff --git a/rootfs/root/.config/letsencrypt/cli.ini b/rootfs/root/.config/letsencrypt/cli.ini index 561b770d..3565d6e5 100644 --- a/rootfs/root/.config/letsencrypt/cli.ini +++ b/rootfs/root/.config/letsencrypt/cli.ini @@ -1,4 +1,4 @@ text = True non-interactive = True authenticator = webroot -webroot-path = /config/letsencrypt-acme-challenge +webroot-path = /data/letsencrypt-acme-challenge diff --git a/rootfs/var/www/html/index.html b/rootfs/var/www/html/index.html index bb292b47..8478b47f 100644 --- a/rootfs/var/www/html/index.html +++ b/rootfs/var/www/html/index.html @@ -18,7 +18,7 @@

If you're seeing this site then you're trying to access a host that isn't set up yet.

Log in to the Admin panel to get started.

-

Powered by Nginx Proxy Manager

+

Powered by Nginx Proxy Manager

diff --git a/manager/src/backend/app.js b/src/backend/app.js similarity index 64% rename from manager/src/backend/app.js rename to src/backend/app.js index f5382015..e433013a 100644 --- a/manager/src/backend/app.js +++ b/src/backend/app.js @@ -1,21 +1,27 @@ 'use strict'; +const path = require('path'); const express = require('express'); const bodyParser = require('body-parser'); +const fileUpload = require('express-fileupload'); const compression = require('compression'); -const logger = require('./logger'); +const log = require('./logger').express; /** * App */ const app = express(); +app.use(fileUpload()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({extended: true})); + +// Gzip app.use(compression()); /** * General Logging, BEFORE routes */ + app.disable('x-powered-by'); app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); app.enable('strict routing'); @@ -25,6 +31,13 @@ if (process.env.NODE_ENV !== 'production') { app.set('json spaces', 2); } +// set the view engine to ejs +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, '/views')); + +// CORS for everything +app.use(require('./lib/express/cors')); + // General security/cache related headers + server header app.use(function (req, res, next) { res.set({ @@ -39,19 +52,24 @@ app.use(function (req, res, next) { next(); }); +// ATTACH JWT value - FOR ANY RATE LIMITERS and JWT DECODE +app.use(require('./lib/express/jwt')()); + /** * Routes */ -app.use('/css', express.static('dist/css')); -app.use('/fonts', express.static('dist/fonts')); +app.use('/assets', express.static('dist/assets')); +app.use('/css', express.static('dist/css')); +app.use('/fonts', express.static('dist/fonts')); app.use('/images', express.static('dist/images')); -app.use('/js', express.static('dist/js')); -app.use('/api', require('./routes/api/main')); -app.use('/', require('./routes/main')); +app.use('/js', express.static('dist/js')); +app.use('/api', require('./routes/api/main')); +app.use('/', require('./routes/main')); // production error handler // no stacktraces leaked to user app.use(function (err, req, res, next) { + let payload = { error: { code: err.status, @@ -67,11 +85,17 @@ app.use(function (err, req, res, next) { } // Not every error is worth logging - but this is good for now until it gets annoying. - if (!err.public && typeof err.stack !== 'undefined' && err.stack) { - logger.warn(err.stack); + if (typeof err.stack !== 'undefined' && err.stack) { + if (process.env.NODE_ENV === 'development') { + log.warn(err.stack); + } else { + log.warn(err.message); + } } - res.status(err.status || 500).send(payload); + res + .status(err.status || 500) + .send(payload); }); module.exports = app; diff --git a/src/backend/db.js b/src/backend/db.js new file mode 100644 index 00000000..6fcf831e --- /dev/null +++ b/src/backend/db.js @@ -0,0 +1,27 @@ +'use strict'; + +const config = require('config'); + +if (!config.has('database')) { + throw new Error('Database config does not exist! Please read the instructions: https://github.com/jc21/nginx-proxy-manager/blob/master/doc/INSTALL.md'); +} + +let data = { + client: config.database.engine, + connection: { + host: config.database.host, + user: config.database.user, + password: config.database.password, + database: config.database.name, + port: config.database.port + }, + migrations: { + tableName: 'migrations' + } +}; + +if (typeof config.database.version !== 'undefined') { + data.version = config.database.version; +} + +module.exports = require('knex')(data); diff --git a/src/backend/importer.js b/src/backend/importer.js new file mode 100644 index 00000000..25a36b6a --- /dev/null +++ b/src/backend/importer.js @@ -0,0 +1,540 @@ +'use strict'; + +const fs = require('fs'); +const logger = require('./logger').import; +const utils = require('./lib/utils'); +const batchflow = require('batchflow'); + +const internalProxyHost = require('./internal/proxy-host'); +const internalRedirectionHost = require('./internal/redirection-host'); +const internalDeadHost = require('./internal/dead-host'); +const internalNginx = require('./internal/nginx'); +const internalAccessList = require('./internal/access-list'); +const internalStream = require('./internal/stream'); +const internalCertificate = require('./internal/certificate'); + +const accessListModel = require('./models/access_list'); +const accessListAuthModel = require('./models/access_list_auth'); +const proxyHostModel = require('./models/proxy_host'); +const redirectionHostModel = require('./models/redirection_host'); +const deadHostModel = require('./models/dead_host'); +const streamModel = require('./models/stream'); +const certificateModel = require('./models/certificate'); + +module.exports = function () { + + let access_map = {}; + let certificate_map = {}; + + /** + * @param {Access} access + * @param {Object} db + * @returns {Promise} + */ + const importAccessLists = function (access, db) { + return new Promise((resolve, reject) => { + let lists = db.access.find(); + + batchflow(lists).sequential() + .each((i, list, next) => { + + importAccessList(access, list) + .then(() => { + next(); + }) + .catch(err => { + next(err); + }); + }) + .end(results => { + resolve(results); + }); + }); + }; + + /** + * @param {Access} access + * @param {Object} list + * @returns {Promise} + */ + const importAccessList = function (access, list) { + // Create the list + logger.info('Creating Access List: ' + list.name); + + return accessListModel + .query() + .insertAndFetch({ + name: list.name, + owner_user_id: 1 + }) + .then(row => { + access_map[list._id] = row.id; + + return new Promise((resolve, reject) => { + batchflow(list.items).sequential() + .each((i, item, next) => { + if (typeof item.password !== 'undefined' && item.password.length) { + logger.info('Adding to Access List: ' + item.username); + + accessListAuthModel + .query() + .insert({ + access_list_id: row.id, + username: item.username, + password: item.password + }) + .then(() => { + next(); + }) + .catch(err => { + logger.error(err); + next(err); + }); + } + }) + .error(err => { + logger.error(err); + reject(err); + }) + .end(results => { + logger.success('Finished importing Access List: ' + list.name); + resolve(results); + }); + }) + .then(() => { + return internalAccessList.get(access, { + id: row.id, + expand: ['owner', 'items'] + }, true /* <- skip masking */); + }) + .then(full_list => { + return internalAccessList.build(full_list); + }); + }); + }; + + /** + * @param {Access} access + * @returns {Promise} + */ + const importCertificates = function (access) { + // This step involves transforming the letsencrypt folder structure significantly. + + // - /etc/letsencrypt/accounts Do not touch + // - /etc/letsencrypt/archive Modify directory names + // - /etc/letsencrypt/csr Do not touch + // - /etc/letsencrypt/keys Do not touch + // - /etc/letsencrypt/live Modify directory names, modify file symlinks + // - /etc/letsencrypt/renewal Modify filenames and file content + + return new Promise((resolve, reject) => { + // 1. List all folders in `archive` + // 2. Create certificates from those folders, rename them, add to map + // 3. + + try { + resolve(fs.readdirSync('/etc/letsencrypt/archive')); + } catch (err) { + reject(err); + } + }) + .then(archive_dirs => { + return new Promise((resolve, reject) => { + batchflow(archive_dirs).sequential() + .each((i, archive_dir_name, next) => { + importCertificate(access, archive_dir_name) + .then(() => { + next(); + }) + .catch(err => { + next(err); + }); + }) + .end(results => { + resolve(results); + }); + }); + + }); + }; + + /** + * @param {Access} access + * @param {String} archive_dir_name + * @returns {Promise} + */ + const importCertificate = function (access, archive_dir_name) { + logger.info('Importing Certificate: ' + archive_dir_name); + + let full_archive_path = '/etc/letsencrypt/archive/' + archive_dir_name; + let full_live_path = '/etc/letsencrypt/live/' + archive_dir_name; + + let new_archive_path = '/etc/letsencrypt/archive/'; + let new_live_path = '/etc/letsencrypt/live/'; + + // 1. Create certificate row to get the ID + return certificateModel + .query() + .insertAndFetch({ + owner_user_id: 1, + provider: 'letsencrypt', + nice_name: archive_dir_name, + domain_names: [archive_dir_name] + }) + .then(certificate => { + certificate_map[archive_dir_name] = certificate.id; + + // 2. rename archive folder name + new_archive_path = new_archive_path + 'npm-' + certificate.id; + fs.renameSync(full_archive_path, new_archive_path); + + return certificate; + }) + .then(certificate => { + // 3. rename live folder name + new_live_path = new_live_path + 'npm-' + certificate.id; + fs.renameSync(full_live_path, new_live_path); + + // and also update the symlinks in this folder: + process.chdir(new_live_path); + let version = getCertificateVersion(new_archive_path); + let names = [ + ['cert.pem', 'cert' + version + '.pem'], + ['chain.pem', 'chain' + version + '.pem'], + ['fullchain.pem', 'fullchain' + version + '.pem'], + ['privkey.pem', 'privkey' + version + '.pem'] + ]; + + names.map(function (name) { + // remove symlink + try { + fs.unlinkSync(new_live_path + '/' + name[0]); + } catch (err) { + // do nothing + logger.error(err); + } + + // create new symlink + fs.symlinkSync('../../archive/npm-' + certificate.id + '/' + name[1], name[0]); + }); + + return certificate; + }) + .then(certificate => { + // 4. rename and update renewal config file + let config_file = '/etc/letsencrypt/renewal/' + archive_dir_name + '.conf'; + + return utils.exec('sed -i \'s/\\/config/\\/data/g\' ' + config_file) + .then(() => { + let escaped = archive_dir_name.split('.').join('\\.'); + return utils.exec('sed -i \'s/\\/' + escaped + '/\\/npm-' + certificate.id + '/g\' ' + config_file); + }) + .then(() => { + //rename config file + fs.renameSync(config_file, '/etc/letsencrypt/renewal/npm-' + certificate.id + '.conf'); + return certificate; + }); + }) + .then(certificate => { + // 5. read the cert info back in to the db + return internalCertificate.getCertificateInfoFromFile(new_live_path + '/fullchain.pem') + .then(cert_info => { + return certificateModel + .query() + .patchAndFetchById(certificate.id, { + expires_on: certificateModel.raw('FROM_UNIXTIME(' + cert_info.dates.to + ')') + }); + }); + }); + }; + + /** + * @param {String} archive_path + * @returns {Integer} + */ + const getCertificateVersion = function (archive_path) { + let version = 1; + + try { + let files = fs.readdirSync(archive_path); + + files.map(function (file) { + let res = file.match(/fullchain([0-9])+?\.pem/im); + if (res && parseInt(res[1], 10) > version) { + version = parseInt(res[1], 10); + } + }); + + } catch (err) { + // do nothing + } + + return version; + }; + + /** + * @param {Access} access + * @param {Object} db + * @returns {Promise} + */ + const importHosts = function (access, db) { + return new Promise((resolve, reject) => { + let hosts = db.hosts.find(); + + batchflow(hosts).sequential() + .each((i, host, next) => { + importHost(access, host) + .then(() => { + next(); + }) + .catch(err => { + next(err); + }); + }) + .end(results => { + resolve(results); + }); + }); + }; + + /** + * @param {Access} access + * @param {Object} host + * @returns {Promise} + */ + const importHost = function (access, host) { + // Create the list + if (typeof host.type === 'undefined') { + host.type = 'proxy'; + } + + switch (host.type) { + case 'proxy': + return importProxyHost(access, host); + case '404': + return importDeadHost(access, host); + case 'redirection': + return importRedirectionHost(access, host); + case 'stream': + return importStream(access, host); + default: + return Promise.resolve(); + } + }; + + /** + * @param {Access} access + * @param {Object} host + * @returns {Promise} + */ + const importProxyHost = function (access, host) { + logger.info('Creating Proxy Host: ' + host.hostname); + + let access_list_id = 0; + let certificate_id = 0; + let meta = {}; + + if (typeof host.letsencrypt_email !== 'undefined') { + meta.letsencrypt_email = host.letsencrypt_email; + } + + // determine access_list_id + if (typeof host.access_list_id !== 'undefined' && host.access_list_id && typeof access_map[host.access_list_id] !== 'undefined') { + access_list_id = access_map[host.access_list_id]; + } + + // determine certificate_id + if (host.ssl && typeof certificate_map[host.hostname] !== 'undefined') { + certificate_id = certificate_map[host.hostname]; + } + + return proxyHostModel + .query() + .insertAndFetch({ + owner_user_id: 1, + domain_names: [host.hostname], + forward_ip: host.forward_server, + forward_port: host.forward_port, + access_list_id: access_list_id, + certificate_id: certificate_id, + ssl_forced: host.force_ssl || false, + caching_enabled: host.asset_caching || false, + block_exploits: host.block_exploits || false, + advanced_config: host.advanced || '', + meta: meta + }) + .then(row => { + // re-fetch with cert + return internalProxyHost.get(access, { + id: row.id, + expand: ['certificate', 'owner', 'access_list'] + }); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(proxyHostModel, 'proxy_host', row); + }); + }; + + /** + * @param {Access} access + * @param {Object} host + * @returns {Promise} + */ + const importDeadHost = function (access, host) { + logger.info('Creating 404 Host: ' + host.hostname); + + let certificate_id = 0; + let meta = {}; + + if (typeof host.letsencrypt_email !== 'undefined') { + meta.letsencrypt_email = host.letsencrypt_email; + } + + // determine certificate_id + if (host.ssl && typeof certificate_map[host.hostname] !== 'undefined') { + certificate_id = certificate_map[host.hostname]; + } + + return deadHostModel + .query() + .insertAndFetch({ + owner_user_id: 1, + domain_names: [host.hostname], + certificate_id: certificate_id, + ssl_forced: host.force_ssl || false, + advanced_config: host.advanced || '', + meta: meta + }) + .then(row => { + // re-fetch with cert + return internalDeadHost.get(access, { + id: row.id, + expand: ['certificate', 'owner'] + }); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(deadHostModel, 'dead_host', row); + }); + }; + + /** + * @param {Access} access + * @param {Object} host + * @returns {Promise} + */ + const importRedirectionHost = function (access, host) { + logger.info('Creating Redirection Host: ' + host.hostname); + + let certificate_id = 0; + let meta = {}; + + if (typeof host.letsencrypt_email !== 'undefined') { + meta.letsencrypt_email = host.letsencrypt_email; + } + + // determine certificate_id + if (host.ssl && typeof certificate_map[host.hostname] !== 'undefined') { + certificate_id = certificate_map[host.hostname]; + } + + return redirectionHostModel + .query() + .insertAndFetch({ + owner_user_id: 1, + domain_names: [host.hostname], + forward_domain_name: host.forward_host, + block_exploits: host.block_exploits || false, + certificate_id: certificate_id, + ssl_forced: host.force_ssl || false, + advanced_config: host.advanced || '', + meta: meta + }) + .then(row => { + // re-fetch with cert + return internalRedirectionHost.get(access, { + id: row.id, + expand: ['certificate', 'owner'] + }); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(redirectionHostModel, 'redirection_host', row); + }); + }; + + /** + * @param {Access} access + * @param {Object} host + * @returns {Promise} + */ + const importStream = function (access, host) { + logger.info('Creating Stream: ' + host.incoming_port); + + return streamModel + .query() + .insertAndFetch({ + owner_user_id: 1, + incoming_port: host.incoming_port, + forward_ip: host.forward_server, + forwarding_port: host.forward_port, + tcp_forwarding: host.protocols.indexOf('tcp') !== -1, + udp_forwarding: host.protocols.indexOf('udp') !== -1 + }) + .then(row => { + // re-fetch with cert + return internalStream.get(access, { + id: row.id, + expand: ['owner'] + }); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(streamModel, 'stream', row); + }); + }; + + /** + * Returned Promise + */ + return new Promise((resolve, reject) => { + if (fs.existsSync('/config') && !fs.existsSync('/config/v2-imported')) { + + logger.info('Beginning import from V1 ...'); + + const db = require('diskdb'); + module.exports = db.connect('/config', ['hosts', 'access']); + + // Create a fake access object + const Access = require('./lib/access'); + let access = new Access(null); + + resolve(access.load(true) + .then(() => { + // Import access lists first + return importAccessLists(access, db) + .then(() => { + // Then import Lets Encrypt Certificates + return importCertificates(access); + }) + .then(() => { + // then hosts + return importHosts(access, db); + }) + .then(() => { + // Write the /config/v2-imported file so we don't import again + fs.writeFile('/config/v2-imported', 'true', function (err) { + if (err) { + logger.err(err); + } + }); + }); + }) + ); + + } else { + resolve(); + } + }); +}; diff --git a/src/backend/index.js b/src/backend/index.js new file mode 100644 index 00000000..d646df07 --- /dev/null +++ b/src/backend/index.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +'use strict'; + +const logger = require('./logger').global; + +function appStart () { + const migrate = require('./migrate'); + const setup = require('./setup'); + const importer = require('./importer'); + const app = require('./app'); + const apiValidator = require('./lib/validator/api'); + const internalCertificate = require('./internal/certificate'); + + return migrate.latest() + .then(setup) + .then(importer) + .then(() => { + return apiValidator.loadSchemas; + }) + .then(() => { + + internalCertificate.initTimer(); + + const server = app.listen(81, () => { + logger.info('PID ' + process.pid + ' listening on port 81 ...'); + + process.on('SIGTERM', () => { + logger.info('PID ' + process.pid + ' received SIGTERM'); + server.close(() => { + logger.info('Stopping.'); + process.exit(0); + }); + }); + }); + }) + .catch(err => { + logger.error(err.message); + setTimeout(appStart, 1000); + }); +} + +try { + appStart(); +} catch (err) { + logger.error(err.message, err); + process.exit(1); +} diff --git a/src/backend/internal/access-list.js b/src/backend/internal/access-list.js new file mode 100644 index 00000000..83dd2e58 --- /dev/null +++ b/src/backend/internal/access-list.js @@ -0,0 +1,484 @@ +'use strict'; + +const _ = require('lodash'); +const fs = require('fs'); +const batchflow = require('batchflow'); +const logger = require('../logger').access; +const error = require('../lib/error'); +const accessListModel = require('../models/access_list'); +const accessListAuthModel = require('../models/access_list_auth'); +const proxyHostModel = require('../models/proxy_host'); +const internalAuditLog = require('./audit-log'); +const internalNginx = require('./nginx'); +const utils = require('../lib/utils'); + +function omissions () { + return ['is_deleted']; +} + +const internalAccessList = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + return access.can('access_lists:create', data) + .then(access_data => { + return accessListModel + .query() + .omit(omissions()) + .insertAndFetch({ + name: data.name, + owner_user_id: access.token.getUserId(1) + }); + }) + .then(row => { + data.id = row.id; + + // Now add the items + let promises = []; + data.items.map(function (item) { + promises.push(accessListAuthModel + .query() + .insert({ + access_list_id: row.id, + username: item.username, + password: item.password + }) + ); + }); + + return Promise.all(promises); + }) + .then(() => { + // re-fetch with expansions + return internalAccessList.get(access, { + id: data.id, + expand: ['owner', 'items'] + }, true /* <- skip masking */); + }) + .then(row => { + // Audit log + data.meta = _.assign({}, data.meta || {}, row.meta); + + return internalAccessList.build(row) + .then(() => { + if (row.proxy_host_count) { + return internalNginx.reload(); + } + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'access-list', + object_id: row.id, + meta: internalAccessList.maskItems(data) + }); + }) + .then(() => { + return internalAccessList.maskItems(row); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.name] + * @param {String} [data.items] + * @return {Promise} + */ + update: (access, data) => { + return access.can('access_lists:update', data.id) + .then(access_data => { + return internalAccessList.get(access, {id: data.id}); + }) + .then(row => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('Access List could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + }) + .then(() => { + // patch name if specified + if (typeof data.name !== 'undefined' && data.name) { + return accessListModel + .query() + .where({id: data.id}) + .patch({ + name: data.name + }); + } + }) + .then(() => { + // Check for items and add/update/remove them + if (typeof data.items !== 'undefined' && data.items) { + let promises = []; + let items_to_keep = []; + + data.items.map(function (item) { + if (item.password) { + promises.push(accessListAuthModel + .query() + .insert({ + access_list_id: data.id, + username: item.username, + password: item.password + }) + ); + } else { + // This was supplied with an empty password, which means keep it but don't change the password + items_to_keep.push(item.username); + } + }); + + let query = accessListAuthModel + .query() + .delete() + .where('access_list_id', data.id); + + if (items_to_keep.length) { + query.andWhere('username', 'NOT IN', items_to_keep); + } + + return query + .then(() => { + // Add new items + if (promises.length) { + return Promise.all(promises); + } + }); + } + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'access-list', + object_id: data.id, + meta: internalAccessList.maskItems(data) + }); + }) + .then(() => { + // re-fetch with expansions + return internalAccessList.get(access, { + id: data.id, + expand: ['owner', 'items'] + }, true /* <- skip masking */); + }) + .then(row => { + return internalAccessList.build(row) + .then(() => { + if (row.proxy_host_count) { + return internalNginx.reload(); + } + }) + .then(() => { + return internalAccessList.maskItems(row); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @param {Boolean} [skip_masking] + * @return {Promise} + */ + get: (access, data, skip_masking) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('access_lists:get', data.id) + .then(access_data => { + let query = accessListModel + .query() + .select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count')) + .joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0') + .where('access_list.is_deleted', 0) + .andWhere('access_list.id', data.id) + .allowEager('[owner,items,proxy_hosts]') + .omit(['access_list.is_deleted']) + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('access_list.owner_user_id', access.token.getUserId(1)); + } + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + if (!skip_masking && typeof row.items !== 'undefined' && row.items) { + row = internalAccessList.maskItems(row); + } + + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('access_lists:delete', data.id) + .then(() => { + return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items']}); + }) + .then(row => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } + + // 1. update row to be deleted + // 2. update any proxy hosts that were using it (ignoring permissions) + // 3. reconfigure those hosts + // 4. audit log + + // 1. update row to be deleted + return accessListModel + .query() + .where('id', row.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // 2. update any proxy hosts that were using it (ignoring permissions) + if (row.proxy_hosts) { + return proxyHostModel + .query() + .where('access_list_id', '=', row.id) + .patch({access_list_id: 0}) + .then(() => { + // 3. reconfigure those hosts, then reload nginx + + // set the access_list_id to zero for these items + row.proxy_hosts.map(function (val, idx) { + row.proxy_hosts[idx].access_list_id = 0; + }); + + return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts); + }) + .then(() => { + return internalNginx.reload(); + }); + } + }) + .then(() => { + // delete the htpasswd file + let htpasswd_file = internalAccessList.getFilename(row); + + try { + fs.unlinkSync(htpasswd_file); + } catch (err) { + // do nothing + } + }) + .then(() => { + // 4. audit log + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'access-list', + object_id: row.id, + meta: _.omit(internalAccessList.maskItems(row), ['is_deleted', 'proxy_hosts']) + }); + }) + }) + .then(() => { + return true; + }); + }, + + /** + * All Lists + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('access_lists:list') + .then(access_data => { + let query = accessListModel + .query() + .select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count')) + .joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0') + .where('access_list.is_deleted', 0) + .groupBy('access_list.id') + .omit(['access_list.is_deleted']) + .allowEager('[owner,items]') + .orderBy('access_list.name', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('name', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }) + .then(rows => { + if (rows) { + rows.map(function (row, idx) { + if (typeof row.items !== 'undefined' && row.items) { + rows[idx] = internalAccessList.maskItems(row); + } + }); + } + + return rows; + }); + }, + + /** + * Report use + * + * @param {Integer} user_id + * @param {String} visibility + * @returns {Promise} + */ + getCount: (user_id, visibility) => { + let query = accessListModel + .query() + .count('id as count') + .where('is_deleted', 0); + + if (visibility !== 'all') { + query.andWhere('owner_user_id', user_id); + } + + return query.first() + .then(row => { + return parseInt(row.count, 10); + }); + }, + + /** + * @param {Object} list + * @returns {Object} + */ + maskItems: list => { + if (list && typeof list.items !== 'undefined') { + list.items.map(function (val, idx) { + let repeat_for = 8; + let first_char = '*'; + + if (typeof val.password !== 'undefined' && val.password) { + repeat_for = val.password.length - 1; + first_char = val.password.charAt(0); + } + + list.items[idx].hint = first_char + ('*').repeat(repeat_for); + list.items[idx].password = ''; + }); + } + + return list; + }, + + /** + * @param {Object} list + * @param {Integer} list.id + * @returns {String} + */ + getFilename: list => { + return '/data/access/' + list.id; + }, + + /** + * @param {Object} list + * @param {Integer} list.id + * @param {String} list.name + * @param {Array} list.items + * @returns {Promise} + */ + build: list => { + logger.info('Building Access file #' + list.id + ' for: ' + list.name); + + return new Promise((resolve, reject) => { + let htpasswd_file = internalAccessList.getFilename(list); + + // 1. remove any existing access file + try { + fs.unlinkSync(htpasswd_file); + } catch (err) { + // do nothing + } + + // 2. create empty access file + try { + fs.writeFileSync(htpasswd_file, '', {encoding: 'utf8'}); + resolve(htpasswd_file); + } catch (err) { + reject(err); + } + }) + .then(htpasswd_file => { + // 3. generate password for each user + if (list.items.length) { + return new Promise((resolve, reject) => { + batchflow(list.items).sequential() + .each((i, item, next) => { + if (typeof item.password !== 'undefined' && item.password.length) { + logger.info('Adding: ' + item.username); + + utils.exec('/usr/bin/htpasswd -b "' + htpasswd_file + '" "' + item.username + '" "' + item.password + '"') + .then((/*result*/) => { + next(); + }) + .catch(err => { + logger.error(err); + next(err); + }); + } + }) + .error(err => { + logger.error(err); + reject(err); + }) + .end(results => { + logger.success('Built Access file #' + list.id + ' for: ' + list.name); + resolve(results); + }); + }); + } + }); + } +}; + +module.exports = internalAccessList; diff --git a/src/backend/internal/audit-log.js b/src/backend/internal/audit-log.js new file mode 100644 index 00000000..543b1662 --- /dev/null +++ b/src/backend/internal/audit-log.js @@ -0,0 +1,80 @@ +'use strict'; + +const error = require('../lib/error'); +const auditLogModel = require('../models/audit-log'); + +const internalAuditLog = { + + /** + * All logs + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('auditlog:list') + .then(() => { + let query = auditLogModel + .query() + .orderBy('created_on', 'DESC') + .orderBy('id', 'DESC') + .limit(100) + .allowEager('[user]'); + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('meta', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * This method should not be publicly used, it doesn't check certain things. It will be assumed + * that permission to add to audit log is already considered, however the access token is used for + * default user id determination. + * + * @param {Access} access + * @param {Object} data + * @param {String} data.action + * @param {Integer} [data.user_id] + * @param {Integer} [data.object_id] + * @param {Integer} [data.object_type] + * @param {Object} [data.meta] + * @returns {Promise} + */ + add: (access, data) => { + return new Promise((resolve, reject) => { + // Default the user id + if (typeof data.user_id === 'undefined' || !data.user_id) { + data.user_id = access.token.getUserId(1); + } + + if (typeof data.action === 'undefined' || !data.action) { + reject(new error.InternalValidationError('Audit log entry must contain an Action')); + } else { + // Make sure at least 1 of the IDs are set and action + resolve(auditLogModel + .query() + .insert({ + user_id: data.user_id, + action: data.action, + object_type: data.object_type || '', + object_id: data.object_id || 0, + meta: data.meta || {} + })); + } + }); + } +}; + +module.exports = internalAuditLog; diff --git a/src/backend/internal/certificate.js b/src/backend/internal/certificate.js new file mode 100644 index 00000000..d5028ba1 --- /dev/null +++ b/src/backend/internal/certificate.js @@ -0,0 +1,852 @@ +'use strict'; + +const fs = require('fs'); +const _ = require('lodash'); +const logger = require('../logger').ssl; +const error = require('../lib/error'); +const certificateModel = require('../models/certificate'); +const internalAuditLog = require('./audit-log'); +const tempWrite = require('temp-write'); +const utils = require('../lib/utils'); +const moment = require('moment'); +const debug_mode = process.env.NODE_ENV !== 'production'; +const internalNginx = require('./nginx'); +const internalHost = require('./host'); +const certbot_command = '/usr/bin/certbot'; + +function omissions () { + return ['is_deleted']; +} + +const internalCertificate = { + + allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'], + interval_timeout: 1000 * 60 * 60 * 12, // 12 hours + interval: null, + interval_processing: false, + + initTimer: () => { + logger.info('Let\'s Encrypt Renewal Timer initialized'); + internalCertificate.interval = setInterval(internalCertificate.processExpiringHosts, internalCertificate.interval_timeout); + }, + + /** + * Triggered by a timer, this will check for expiring hosts and renew their ssl certs if required + */ + processExpiringHosts: () => { + if (!internalCertificate.interval_processing) { + internalCertificate.interval_processing = true; + logger.info('Renewing SSL certs close to expiry...'); + + return utils.exec(certbot_command + ' renew -q ' + (debug_mode ? '--staging' : '')) + .then(result => { + logger.info(result); + + return internalNginx.reload() + .then(() => { + logger.info('Renew Complete'); + return result; + }); + }) + .then(() => { + // Now go and fetch all the letsencrypt certs from the db and query the files and update expiry times + return certificateModel + .query() + .where('is_deleted', 0) + .andWhere('provider', 'letsencrypt') + .then(certificates => { + if (certificates && certificates.length) { + let promises = []; + + certificates.map(function (certificate) { + promises.push( + internalCertificate.getCertificateInfoFromFile('/etc/letsencrypt/live/npm-' + certificate.id + '/fullchain.pem') + .then(cert_info => { + return certificateModel + .query() + .where('id', certificate.id) + .andWhere('provider', 'letsencrypt') + .patch({ + expires_on: certificateModel.raw('FROM_UNIXTIME(' + cert_info.dates.to + ')') + }); + }) + .catch(err => { + // Don't want to stop the train here, just log the error + logger.error(err.message); + }) + ); + }); + + return Promise.all(promises); + } + }); + }) + .then(() => { + internalCertificate.interval_processing = false; + }) + .catch(err => { + logger.error(err); + internalCertificate.interval_processing = false; + }); + } + }, + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + return access.can('certificates:create', data) + .then(() => { + data.owner_user_id = access.token.getUserId(1); + + if (data.provider === 'letsencrypt') { + data.nice_name = data.domain_names.sort().join(', '); + } + + return certificateModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then(certificate => { + if (certificate.provider === 'letsencrypt') { + // Request a new Cert from LE. Let the fun begin. + + // 1. Find out any hosts that are using any of the hostnames in this cert + // 2. Disable them in nginx temporarily + // 3. Generate the LE config + // 4. Request cert + // 5. Remove LE config + // 6. Re-instate previously disabled hosts + + // 1. Find out any hosts that are using any of the hostnames in this cert + return internalHost.getHostsWithDomains(certificate.domain_names) + .then(in_use_result => { + // 2. Disable them in nginx temporarily + return internalCertificate.disableInUseHosts(in_use_result) + .then(() => { + return in_use_result; + }); + }) + .then(in_use_result => { + // 3. Generate the LE config + return internalNginx.generateLetsEncryptRequestConfig(certificate) + .then(internalNginx.reload) + .then(() => { + // 4. Request cert + return internalCertificate.requestLetsEncryptSsl(certificate); + }) + .then(() => { + // 5. Remove LE config + return internalNginx.deleteLetsEncryptRequestConfig(certificate); + }) + .then(internalNginx.reload) + .then(() => { + // 6. Re-instate previously disabled hosts + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(() => { + return certificate; + }) + .catch(err => { + // In the event of failure, revert things and throw err back + return internalNginx.deleteLetsEncryptRequestConfig(certificate) + .then(() => { + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(internalNginx.reload) + .then(() => { + throw err; + }); + }); + }) + .then(() => { + // At this point, the letsencrypt cert should exist on disk. + // Lets get the expiry date from the file and update the row silently + return internalCertificate.getCertificateInfoFromFile('/etc/letsencrypt/live/npm-' + certificate.id + '/fullchain.pem') + .then(cert_info => { + return certificateModel + .query() + .patchAndFetchById(certificate.id, { + expires_on: certificateModel.raw('FROM_UNIXTIME(' + cert_info.dates.to + ')') + }) + .then(saved_row => { + // Add cert data for audit log + saved_row.meta = _.assign({}, saved_row.meta, { + letsencrypt_certificate: cert_info + }); + + return saved_row; + }); + }); + }); + } else { + return internalCertificate.writeCustomCert(certificate) + .then(() => { + return certificate; + }); + } + }).then(certificate => { + + data.meta = _.assign({}, data.meta || {}, certificate.meta); + + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'certificate', + object_id: certificate.id, + meta: data + }) + .then(() => { + return certificate; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.email] + * @param {String} [data.name] + * @return {Promise} + */ + update: (access, data) => { + return access.can('certificates:update', data.id) + .then(access_data => { + return internalCertificate.get(access, {id: data.id}); + }) + .then(row => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('Certificate could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + return certificateModel + .query() + .omit(omissions()) + .patchAndFetchById(row.id, data) + .then(saved_row => { + saved_row.meta = internalCertificate.cleanMeta(saved_row.meta); + data.meta = internalCertificate.cleanMeta(data.meta); + + // Add row.nice_name for custom certs + if (saved_row.provider === 'other') { + data.nice_name = saved_row.nice_name; + } + + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'certificate', + object_id: row.id, + meta: _.omit(data, ['expires_on']) // this prevents json circular reference because expires_on might be raw + }) + .then(() => { + return _.omit(saved_row, omissions()); + }); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('certificates:get', data.id) + .then(access_data => { + let query = certificateModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowEager('[owner]') + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('certificates:delete', data.id) + .then(() => { + return internalCertificate.get(access, {id: data.id}); + }) + .then(row => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } + + return certificateModel + .query() + .where('id', row.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // Add to audit log + row.meta = internalCertificate.cleanMeta(row.meta); + + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'certificate', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }) + .then(() => { + if (row.provider === 'letsencrypt') { + // Revoke the cert + return internalCertificate.revokeLetsEncryptSsl(row); + } + }); + }) + .then(() => { + return true; + }); + }, + + /** + * All Certs + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('certificates:list') + .then(access_data => { + let query = certificateModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']) + .allowEager('[owner]') + .orderBy('nice_name', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('name', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * Report use + * + * @param {Integer} user_id + * @param {String} visibility + * @returns {Promise} + */ + getCount: (user_id, visibility) => { + let query = certificateModel + .query() + .count('id as count') + .where('is_deleted', 0); + + if (visibility !== 'all') { + query.andWhere('owner_user_id', user_id); + } + + return query.first() + .then(row => { + return parseInt(row.count, 10); + }); + }, + + /** + * @param {Object} certificate + * @returns {Promise} + */ + writeCustomCert: certificate => { + return new Promise((resolve, reject) => { + let dir = '/data/custom_ssl/npm-' + certificate.id; + + if (certificate.provider === 'letsencrypt') { + reject(new Error('Refusing to write letsencrypt certs here')); + return; + } + + let cert_data = certificate.meta.certificate; + if (typeof certificate.meta.intermediate_certificate !== 'undefined') { + cert_data = cert_data + "\n" + certificate.meta.intermediate_certificate; + } + + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } + } catch (err) { + reject(err); + return; + } + + fs.writeFile(dir + '/fullchain.pem', cert_data, function (err) { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }) + .then(() => { + return new Promise((resolve, reject) => { + fs.writeFile(dir + '/privkey.pem', certificate.meta.certificate_key, function (err) { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Array} data.domain_names + * @param {String} data.meta.letsencrypt_email + * @param {Boolean} data.meta.letsencrypt_agree + * @returns {Promise} + */ + createQuickCertificate: (access, data) => { + return internalCertificate.create(access, { + provider: 'letsencrypt', + domain_names: data.domain_names, + meta: data.meta + }); + }, + + /** + * Validates that the certs provided are good. + * No access required here, nothing is changed or stored. + * + * @param {Object} data + * @param {Object} data.files + * @returns {Promise} + */ + validate: data => { + return new Promise(resolve => { + // Put file contents into an object + let files = {}; + _.map(data.files, (file, name) => { + if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) { + files[name] = file.data.toString(); + } + }); + + resolve(files); + }) + .then(files => { + // For each file, create a temp file and write the contents to it + // Then test it depending on the file type + let promises = []; + _.map(files, (content, type) => { + promises.push(new Promise((resolve, reject) => { + if (type === 'certificate_key') { + resolve(internalCertificate.checkPrivateKey(content)); + } else { + // this should handle `certificate` and intermediate certificate + resolve(internalCertificate.getCertificateInfo(content, true)); + } + }).then(res => { + return {[type]: res}; + })); + }); + + return Promise.all(promises) + .then(files => { + let data = {}; + + _.each(files, file => { + data = _.assign({}, data, file); + }); + + return data; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Object} data.files + * @returns {Promise} + */ + upload: (access, data) => { + return internalCertificate.get(access, {id: data.id}) + .then(row => { + if (row.provider !== 'other') { + throw new error.ValidationError('Cannot upload certificates for this type of provider'); + } + + return internalCertificate.validate(data) + .then(validations => { + if (typeof validations.certificate === 'undefined') { + throw new error.ValidationError('Certificate file was not provided'); + } + + _.map(data.files, (file, name) => { + if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) { + row.meta[name] = file.data.toString(); + } + }); + + // TODO: This uses a mysql only raw function that won't translate to postgres + return internalCertificate.update(access, { + id: data.id, + expires_on: certificateModel.raw('FROM_UNIXTIME(' + validations.certificate.dates.to + ')'), + domain_names: [validations.certificate.cn], + meta: row.meta + }); + }) + .then(() => { + return _.pick(row.meta, internalCertificate.allowed_ssl_files); + }); + }); + }, + + /** + * Uses the openssl command to validate the private key. + * It will save the file to disk first, then run commands on it, then delete the file. + * + * @param {String} private_key This is the entire key contents as a string + */ + checkPrivateKey: private_key => { + return tempWrite(private_key, '/tmp') + .then(filepath => { + return utils.exec('openssl rsa -in ' + filepath + ' -check -noout') + .then(result => { + if (!result.toLowerCase().includes('key ok')) { + throw new error.ValidationError(result); + } + + fs.unlinkSync(filepath); + return true; + }).catch(err => { + fs.unlinkSync(filepath); + throw new error.ValidationError('Certificate Key is not valid (' + err.message + ')', err); + }); + }); + }, + + /** + * Uses the openssl command to both validate and get info out of the certificate. + * It will save the file to disk first, then run commands on it, then delete the file. + * + * @param {String} certificate This is the entire cert contents as a string + * @param {Boolean} [throw_expired] Throw when the certificate is out of date + */ + getCertificateInfo: (certificate, throw_expired) => { + return tempWrite(certificate, '/tmp') + .then(filepath => { + return internalCertificate.getCertificateInfoFromFile(filepath, throw_expired) + .then(cert_data => { + fs.unlinkSync(filepath); + return cert_data; + }).catch(err => { + fs.unlinkSync(filepath); + throw err; + }); + }); + }, + + /** + * Uses the openssl command to both validate and get info out of the certificate. + * It will save the file to disk first, then run commands on it, then delete the file. + * + * @param {String} certificate_file The file location on disk + * @param {Boolean} [throw_expired] Throw when the certificate is out of date + */ + getCertificateInfoFromFile: (certificate_file, throw_expired) => { + let cert_data = {}; + + return utils.exec('openssl x509 -in ' + certificate_file + ' -subject -noout') + .then(result => { + // subject=CN = something.example.com + let regex = /(?:subject=)?[^=]+=\s+(\S+)/gim; + let match = regex.exec(result); + + if (typeof match[1] === 'undefined') { + throw new error.ValidationError('Could not determine subject from certificate: ' + result); + } + + cert_data['cn'] = match[1]; + }) + .then(() => { + return utils.exec('openssl x509 -in ' + certificate_file + ' -issuer -noout'); + }) + .then(result => { + // issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3 + let regex = /^(?:issuer=)?(.*)$/gim; + let match = regex.exec(result); + + if (typeof match[1] === 'undefined') { + throw new error.ValidationError('Could not determine issuer from certificate: ' + result); + } + + cert_data['issuer'] = match[1]; + }) + .then(() => { + return utils.exec('openssl x509 -in ' + certificate_file + ' -dates -noout'); + }) + .then(result => { + // notBefore=Jul 14 04:04:29 2018 GMT + // notAfter=Oct 12 04:04:29 2018 GMT + let valid_from = null; + let valid_to = null; + + let lines = result.split('\n'); + lines.map(function (str) { + let regex = /^(\S+)=(.*)$/gim; + let match = regex.exec(str.trim()); + + if (match && typeof match[2] !== 'undefined') { + let date = parseInt(moment(match[2], 'MMM DD HH:mm:ss YYYY z').format('X'), 10); + + if (match[1].toLowerCase() === 'notbefore') { + valid_from = date; + } else if (match[1].toLowerCase() === 'notafter') { + valid_to = date; + } + } + }); + + if (!valid_from || !valid_to) { + throw new error.ValidationError('Could not determine dates from certificate: ' + result); + } + + if (throw_expired && valid_to < parseInt(moment().format('X'), 10)) { + throw new error.ValidationError('Certificate has expired'); + } + + cert_data['dates'] = { + from: valid_from, + to: valid_to + }; + + return cert_data; + }).catch(err => { + throw new error.ValidationError('Certificate is not valid (' + err.message + ')', err); + }); + }, + + /** + * Cleans the ssl keys from the meta object and sets them to "true" + * + * @param {Object} meta + * @param {Boolean} [remove] + * @returns {Object} + */ + cleanMeta: function (meta, remove) { + internalCertificate.allowed_ssl_files.map(key => { + if (typeof meta[key] !== 'undefined' && meta[key]) { + if (remove) { + delete meta[key]; + } else { + meta[key] = true; + } + } + }); + + return meta; + }, + + /** + * @param {Object} certificate the certificate row + * @returns {Promise} + */ + requestLetsEncryptSsl: certificate => { + logger.info('Requesting Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); + + let cmd = certbot_command + ' certonly --cert-name "npm-' + certificate.id + '" --agree-tos ' + + '--email "' + certificate.meta.letsencrypt_email + '" ' + + '--preferred-challenges "http" ' + + '-n -a webroot -d "' + certificate.domain_names.join(',') + '" ' + + (debug_mode ? '--staging' : ''); + + if (debug_mode) { + logger.info('Command:', cmd); + } + + return utils.exec(cmd) + .then(result => { + logger.success(result); + return result; + }); + }, + + /** + * @param {Object} certificate the certificate row + * @returns {Promise} + */ + renewLetsEncryptSsl: certificate => { + logger.info('Renewing Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); + + let cmd = certbot_command + ' renew -n --force-renewal --disable-hook-validation --cert-name "npm-' + certificate.id + '" ' + (debug_mode ? '--staging' : ''); + + if (debug_mode) { + logger.info('Command:', cmd); + } + + return utils.exec(cmd) + .then(result => { + logger.info(result); + return result; + }); + }, + + /** + * @param {Object} certificate the certificate row + * @param {Boolean} [throw_errors] + * @returns {Promise} + */ + revokeLetsEncryptSsl: (certificate, throw_errors) => { + logger.info('Revoking Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); + + let cmd = certbot_command + ' revoke --cert-path "/etc/letsencrypt/live/npm-' + certificate.id + '/fullchain.pem" ' + (debug_mode ? '--staging' : ''); + + if (debug_mode) { + logger.info('Command:', cmd); + } + + return utils.exec(cmd) + .then(result => { + logger.info(result); + return result; + }) + .catch(err => { + if (debug_mode) { + logger.error(err.message); + } + + if (throw_errors) { + throw err; + } + }); + }, + + /** + * @param {Object} certificate + * @returns {Boolean} + */ + hasLetsEncryptSslCerts: certificate => { + let le_path = '/etc/letsencrypt/live/npm-' + certificate.id; + + return fs.existsSync(le_path + '/fullchain.pem') && fs.existsSync(le_path + '/privkey.pem'); + }, + + /** + * @param {Object} in_use_result + * @param {Integer} in_use_result.total_count + * @param {Array} in_use_result.proxy_hosts + * @param {Array} in_use_result.redirection_hosts + * @param {Array} in_use_result.dead_hosts + */ + disableInUseHosts: in_use_result => { + if (in_use_result.total_count) { + let promises = []; + + if (in_use_result.proxy_hosts.length) { + promises.push(internalNginx.bulkDeleteConfigs('proxy_host', in_use_result.proxy_hosts)); + } + + if (in_use_result.redirection_hosts.length) { + promises.push(internalNginx.bulkDeleteConfigs('redirection_host', in_use_result.redirection_hosts)); + } + + if (in_use_result.dead_hosts.length) { + promises.push(internalNginx.bulkDeleteConfigs('dead_host', in_use_result.dead_hosts)); + } + + return Promise.all(promises); + + } else { + return Promise.resolve(); + } + }, + + /** + * @param {Object} in_use_result + * @param {Integer} in_use_result.total_count + * @param {Array} in_use_result.proxy_hosts + * @param {Array} in_use_result.redirection_hosts + * @param {Array} in_use_result.dead_hosts + */ + enableInUseHosts: in_use_result => { + if (in_use_result.total_count) { + let promises = []; + + if (in_use_result.proxy_hosts.length) { + promises.push(internalNginx.bulkGenerateConfigs('proxy_host', in_use_result.proxy_hosts)); + } + + if (in_use_result.redirection_hosts.length) { + promises.push(internalNginx.bulkGenerateConfigs('redirection_host', in_use_result.redirection_hosts)); + } + + if (in_use_result.dead_hosts.length) { + promises.push(internalNginx.bulkGenerateConfigs('dead_host', in_use_result.dead_hosts)); + } + + return Promise.all(promises); + + } else { + return Promise.resolve(); + } + } +}; + +module.exports = internalCertificate; diff --git a/src/backend/internal/dead-host.js b/src/backend/internal/dead-host.js new file mode 100644 index 00000000..e7956201 --- /dev/null +++ b/src/backend/internal/dead-host.js @@ -0,0 +1,352 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const deadHostModel = require('../models/dead_host'); +const internalHost = require('./host'); +const internalNginx = require('./nginx'); +const internalAuditLog = require('./audit-log'); +const internalCertificate = require('./certificate'); + +function omissions () { + return ['is_deleted']; +} + +const internalDeadHost = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + + return access.can('dead_hosts:create', data) + .then(access_data => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name)); + }); + + return Promise.all(domain_name_check_promises) + .then(check_results => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + }) + .then(() => { + // At this point the domains should have been checked + data.owner_user_id = access.token.getUserId(1); + + return deadHostModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then(row => { + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, data) + .then(cert => { + // update host with cert id + return internalDeadHost.update(access, { + id: row.id, + certificate_id: cert.id + }); + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then(row => { + // re-fetch with cert + return internalDeadHost.get(access, { + id: row.id, + expand: ['certificate', 'owner'] + }); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(deadHostModel, 'dead_host', row) + .then(() => { + return row; + }); + }) + .then(row => { + data.meta = _.assign({}, data.meta || {}, row.meta); + + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'dead-host', + object_id: row.id, + meta: data + }) + .then(() => { + return row; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @return {Promise} + */ + update: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + + return access.can('dead_hosts:update', data.id) + .then(access_data => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + if (typeof data.domain_names !== 'undefined') { + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'dead', data.id)); + }); + + return Promise.all(domain_name_check_promises) + .then(check_results => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + } + }) + .then(() => { + return internalDeadHost.get(access, {id: data.id}); + }) + .then(row => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('404 Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, { + domain_names: data.domain_names || row.domain_names, + meta: _.assign({}, row.meta, data.meta) + }) + .then(cert => { + // update host with cert id + data.certificate_id = cert.id; + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then(row => { + // Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here. + data = _.assign({}, { + domain_names: row.domain_names + },data); + + return deadHostModel + .query() + .where({id: data.id}) + .patch(data) + .then(saved_row => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'dead-host', + object_id: row.id, + meta: data + }) + .then(() => { + return _.omit(saved_row, omissions()); + }); + }); + }) + .then(() => { + return internalDeadHost.get(access, { + id: data.id, + expand: ['owner', 'certificate'] + }) + .then(row => { + // Configure nginx + return internalNginx.configure(deadHostModel, 'dead_host', row) + .then(() => { + return _.omit(row, omissions()); + }); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('dead_hosts:get', data.id) + .then(access_data => { + let query = deadHostModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowEager('[owner,certificate]') + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('dead_hosts:delete', data.id) + .then(() => { + return internalDeadHost.get(access, {id: data.id}); + }) + .then(row => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } + + return deadHostModel + .query() + .where('id', row.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // Delete Nginx Config + return internalNginx.deleteConfig('dead_host', row) + .then(() => { + return internalNginx.reload(); + }); + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'dead-host', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * All Hosts + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('dead_hosts:list') + .then(access_data => { + let query = deadHostModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']) + .allowEager('[owner,certificate]') + .orderBy('domain_names', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('domain_names', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * Report use + * + * @param {Integer} user_id + * @param {String} visibility + * @returns {Promise} + */ + getCount: (user_id, visibility) => { + let query = deadHostModel + .query() + .count('id as count') + .where('is_deleted', 0); + + if (visibility !== 'all') { + query.andWhere('owner_user_id', user_id); + } + + return query.first() + .then(row => { + return parseInt(row.count, 10); + }); + } +}; + +module.exports = internalDeadHost; diff --git a/src/backend/internal/host.js b/src/backend/internal/host.js new file mode 100644 index 00000000..4b56003a --- /dev/null +++ b/src/backend/internal/host.js @@ -0,0 +1,176 @@ +'use strict'; + +const proxyHostModel = require('../models/proxy_host'); +const redirectionHostModel = require('../models/redirection_host'); +const deadHostModel = require('../models/dead_host'); + +const internalHost = { + + /** + * This returns all the host types with any domain listed in the provided domain_names array. + * This is used by the certificates to temporarily disable any host that is using the domain + * + * @param {Array} domain_names + * @returns {Promise} + */ + getHostsWithDomains: function (domain_names) { + let promises = [ + proxyHostModel + .query() + .where('is_deleted', 0), + redirectionHostModel + .query() + .where('is_deleted', 0), + deadHostModel + .query() + .where('is_deleted', 0) + ]; + + return Promise.all(promises) + .then(promises_results => { + let response_object = { + total_count: 0, + dead_hosts: [], + proxy_hosts: [], + redirection_hosts: [] + }; + + if (promises_results[0]) { + // Proxy Hosts + response_object.proxy_hosts = internalHost._getHostsWithDomains(promises_results[0], domain_names); + response_object.total_count += response_object.proxy_hosts.length; + } + + if (promises_results[1]) { + // Redirection Hosts + response_object.redirection_hosts = internalHost._getHostsWithDomains(promises_results[1], domain_names); + response_object.total_count += response_object.redirection_hosts.length; + } + + if (promises_results[1]) { + // Dead Hosts + response_object.dead_hosts = internalHost._getHostsWithDomains(promises_results[2], domain_names); + response_object.total_count += response_object.dead_hosts.length; + } + + return response_object; + }); + }, + + /** + * Internal use only, checks to see if the domain is already taken by any other record + * + * @param {String} hostname + * @param {String} [ignore_type] 'proxy', 'redirection', 'dead' + * @param {Integer} [ignore_id] Must be supplied if type was also supplied + * @returns {Promise} + */ + isHostnameTaken: function (hostname, ignore_type, ignore_id) { + let promises = [ + proxyHostModel + .query() + .where('is_deleted', 0) + .andWhere('domain_names', 'like', '%' + hostname + '%'), + redirectionHostModel + .query() + .where('is_deleted', 0) + .andWhere('domain_names', 'like', '%' + hostname + '%'), + deadHostModel + .query() + .where('is_deleted', 0) + .andWhere('domain_names', 'like', '%' + hostname + '%') + ]; + + return Promise.all(promises) + .then(promises_results => { + let is_taken = false; + + if (promises_results[0]) { + // Proxy Hosts + if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[0], ignore_type === 'proxy' && ignore_id ? ignore_id : 0)) { + is_taken = true; + } + } + + if (promises_results[1]) { + // Redirection Hosts + if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[1], ignore_type === 'redirection' && ignore_id ? ignore_id : 0)) { + is_taken = true; + } + } + + if (promises_results[1]) { + // Dead Hosts + if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[2], ignore_type === 'dead' && ignore_id ? ignore_id : 0)) { + is_taken = true; + } + } + + return { + hostname: hostname, + is_taken: is_taken + }; + }); + }, + + /** + * Private call only + * + * @param {String} hostname + * @param {Array} existing_rows + * @param {Integer} [ignore_id] + * @returns {Boolean} + */ + _checkHostnameRecordsTaken: function (hostname, existing_rows, ignore_id) { + let is_taken = false; + + if (existing_rows && existing_rows.length) { + existing_rows.map(function (existing_row) { + existing_row.domain_names.map(function (existing_hostname) { + // Does this domain match? + if (existing_hostname.toLowerCase() === hostname.toLowerCase()) { + if (!ignore_id || ignore_id !== existing_row.id) { + is_taken = true; + } + } + }); + }); + } + + return is_taken; + }, + + /** + * Private call only + * + * @param {Array} hosts + * @param {Array} domain_names + * @returns {Array} + */ + _getHostsWithDomains: function (hosts, domain_names) { + let response = []; + + if (hosts && hosts.length) { + hosts.map(function (host) { + let host_matches = false; + + domain_names.map(function (domain_name) { + host.domain_names.map(function (host_domain_name) { + if (domain_name.toLowerCase() === host_domain_name.toLowerCase()) { + host_matches = true; + } + }); + }); + + if (host_matches) { + response.push(host); + } + }); + } + + return response; + } + +}; + +module.exports = internalHost; diff --git a/src/backend/internal/nginx.js b/src/backend/internal/nginx.js new file mode 100644 index 00000000..a3c3f688 --- /dev/null +++ b/src/backend/internal/nginx.js @@ -0,0 +1,297 @@ +'use strict'; + +const _ = require('lodash'); +const fs = require('fs'); +const Liquid = require('liquidjs'); +const logger = require('../logger').nginx; +const utils = require('../lib/utils'); +const error = require('../lib/error'); +const debug_mode = process.env.NODE_ENV !== 'production'; + +const internalNginx = { + + /** + * This will: + * - test the nginx config first to make sure it's OK + * - create / recreate the config for the host + * - test again + * - IF OK: update the meta with online status + * - 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 + * @returns {Promise} + */ + configure: (model, host_type, host) => { + return internalNginx.test() + .then(() => { + // Nginx is OK + // We're deleting this config regardless. + return internalNginx.deleteConfig(host_type, host); // Don't throw errors, as the file may not exist at all + }) + .then(() => { + return internalNginx.generateConfig(host_type, host); + }) + .then(() => { + // Test nginx again and update meta with result + return internalNginx.test() + .then(() => { + // nginx is ok + return model + .query() + .where('id', host.id) + .patch({ + meta: _.assign({}, host.meta, { + nginx_online: true, + nginx_err: null + }) + }); + }) + .catch(err => { + if (debug_mode) { + logger.error('Nginx test failed:', err.message); + } + + // config is bad, update meta and delete config + return model + .query() + .where('id', host.id) + .patch({ + meta: _.assign({}, host.meta, { + nginx_online: false, + nginx_err: err.message + }) + }) + .then(() => { + return internalNginx.deleteConfig(host_type, host, true); + }); + }); + }) + .then(() => { + return internalNginx.reload(); + }); + }, + + /** + * @returns {Promise} + */ + test: () => { + if (debug_mode) { + logger.info('Testing Nginx configuration'); + } + + return utils.exec('/usr/sbin/nginx -t'); + }, + + /** + * @returns {Promise} + */ + reload: () => { + return internalNginx.test() + .then(() => { + logger.info('Reloading Nginx'); + return utils.exec('/usr/sbin/nginx -s reload'); + }); + }, + + /** + * @param {String} host_type + * @param {Integer} host_id + * @returns {String} + */ + getConfigName: (host_type, host_id) => { + host_type = host_type.replace(new RegExp('-', 'g'), '_'); + return '/data/nginx/' + host_type + '/' + host_id + '.conf'; + }, + + /** + * @param {String} host_type + * @param {Object} host + * @returns {Promise} + */ + generateConfig: (host_type, host) => { + host_type = host_type.replace(new RegExp('-', 'g'), '_'); + + if (debug_mode) { + logger.info('Generating ' + host_type + ' Config:', host); + } + + let renderEngine = Liquid({ + root: __dirname + '/../templates/' + }); + + return new Promise((resolve, reject) => { + let template = null; + let filename = internalNginx.getConfigName(host_type, host.id); + try { + template = fs.readFileSync(__dirname + '/../templates/' + host_type + '.conf', {encoding: 'utf8'}); + } catch (err) { + reject(new error.ConfigurationError(err.message)); + return; + } + + 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)); + }); + }); + }, + + /** + * This generates a temporary nginx config listening on port 80 for the domain names listed + * in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt + * when requesting a certificate without having a hostname set up already. + * + * @param {Object} certificate + * @returns {Promise} + */ + generateLetsEncryptRequestConfig: certificate => { + if (debug_mode) { + logger.info('Generating LetsEncrypt Request Config:', certificate); + } + + let renderEngine = Liquid({ + root: __dirname + '/../templates/' + }); + + return new Promise((resolve, reject) => { + let template = null; + let filename = '/data/nginx/temp/letsencrypt_' + certificate.id + '.conf'; + try { + template = fs.readFileSync(__dirname + '/../templates/letsencrypt-request.conf', {encoding: 'utf8'}); + } catch (err) { + reject(new error.ConfigurationError(err.message)); + return; + } + + renderEngine + .parseAndRender(template, certificate) + .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)); + }); + }); + }, + + /** + * This removes the temporary nginx config file generated by `generateLetsEncryptRequestConfig` + * + * @param {Object} certificate + * @param {Boolean} [throw_errors] + * @returns {Promise} + */ + deleteLetsEncryptRequestConfig: (certificate, throw_errors) => { + return new Promise((resolve, reject) => { + try { + let config_file = '/data/nginx/temp/letsencrypt_' + certificate.id + '.conf'; + + if (debug_mode) { + logger.warn('Deleting nginx config: ' + config_file); + } + + fs.unlinkSync(config_file); + } catch (err) { + if (debug_mode) { + logger.warn('Could not delete config:', err.message); + } + + if (throw_errors) { + reject(err); + } + } + + resolve(); + }); + }, + + /** + * @param {String} host_type + * @param {Object} host + * @param {Boolean} [throw_errors] + * @returns {Promise} + */ + deleteConfig: (host_type, host, throw_errors) => { + host_type = host_type.replace(new RegExp('-', 'g'), '_'); + + return new Promise((resolve, reject) => { + try { + let config_file = internalNginx.getConfigName(host_type, host.id); + + if (debug_mode) { + logger.warn('Deleting nginx config: ' + config_file); + } + + fs.unlinkSync(config_file); + } catch (err) { + if (debug_mode) { + logger.warn('Could not delete config:', err.message); + } + + if (throw_errors) { + reject(err); + } + } + + resolve(); + }); + }, + + /** + * @param {String} host_type + * @param {Array} hosts + * @returns {Promise} + */ + bulkGenerateConfigs: (host_type, hosts) => { + let promises = []; + hosts.map(function (host) { + promises.push(internalNginx.generateConfig(host_type, host)); + }); + + return Promise.all(promises); + }, + + /** + * @param {String} host_type + * @param {Array} hosts + * @param {Boolean} [throw_errors] + * @returns {Promise} + */ + bulkDeleteConfigs: (host_type, hosts, throw_errors) => { + let promises = []; + hosts.map(function (host) { + promises.push(internalNginx.deleteConfig(host_type, host, throw_errors)); + }); + + return Promise.all(promises); + } +}; + +module.exports = internalNginx; diff --git a/src/backend/internal/proxy-host.js b/src/backend/internal/proxy-host.js new file mode 100644 index 00000000..2d1c9b2e --- /dev/null +++ b/src/backend/internal/proxy-host.js @@ -0,0 +1,353 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const proxyHostModel = require('../models/proxy_host'); +const internalHost = require('./host'); +const internalNginx = require('./nginx'); +const internalAuditLog = require('./audit-log'); +const internalCertificate = require('./certificate'); + +function omissions () { + return ['is_deleted']; +} + +const internalProxyHost = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + + return access.can('proxy_hosts:create', data) + .then(access_data => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name)); + }); + + return Promise.all(domain_name_check_promises) + .then(check_results => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + }) + .then(() => { + // At this point the domains should have been checked + data.owner_user_id = access.token.getUserId(1); + + return proxyHostModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then(row => { + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, data) + .then(cert => { + // update host with cert id + return internalProxyHost.update(access, { + id: row.id, + certificate_id: cert.id + }); + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then(row => { + // re-fetch with cert + return internalProxyHost.get(access, { + id: row.id, + expand: ['certificate', 'owner', 'access_list'] + }); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(proxyHostModel, 'proxy_host', row) + .then(() => { + return row; + }); + }) + .then(row => { + // Audit log + data.meta = _.assign({}, data.meta || {}, row.meta); + + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'proxy-host', + object_id: row.id, + meta: data + }) + .then(() => { + return row; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @return {Promise} + */ + update: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + + return access.can('proxy_hosts:update', data.id) + .then(access_data => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + if (typeof data.domain_names !== 'undefined') { + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'proxy', data.id)); + }); + + return Promise.all(domain_name_check_promises) + .then(check_results => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + } + }) + .then(() => { + return internalProxyHost.get(access, {id: data.id}); + }) + .then(row => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('Proxy Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, { + domain_names: data.domain_names || row.domain_names, + meta: _.assign({}, row.meta, data.meta) + }) + .then(cert => { + // update host with cert id + data.certificate_id = cert.id; + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then(row => { + // Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here. + data = _.assign({}, { + domain_names: row.domain_names + },data); + + return proxyHostModel + .query() + .where({id: data.id}) + .patch(data) + .then(saved_row => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'proxy-host', + object_id: row.id, + meta: data + }) + .then(() => { + return _.omit(saved_row, omissions()); + }); + }); + }) + .then(() => { + return internalProxyHost.get(access, { + id: data.id, + expand: ['owner', 'certificate', 'access_list'] + }) + .then(row => { + // Configure nginx + return internalNginx.configure(proxyHostModel, 'proxy_host', row) + .then(() => { + return _.omit(row, omissions()); + }); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('proxy_hosts:get', data.id) + .then(access_data => { + let query = proxyHostModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowEager('[owner,access_list,certificate]') + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('proxy_hosts:delete', data.id) + .then(() => { + return internalProxyHost.get(access, {id: data.id}); + }) + .then(row => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } + + return proxyHostModel + .query() + .where('id', row.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // Delete Nginx Config + return internalNginx.deleteConfig('proxy_host', row) + .then(() => { + return internalNginx.reload(); + }); + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'proxy-host', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * All Hosts + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('proxy_hosts:list') + .then(access_data => { + let query = proxyHostModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']) + .allowEager('[owner,access_list,certificate]') + .orderBy('domain_names', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('domain_names', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * Report use + * + * @param {Integer} user_id + * @param {String} visibility + * @returns {Promise} + */ + getCount: (user_id, visibility) => { + let query = proxyHostModel + .query() + .count('id as count') + .where('is_deleted', 0); + + if (visibility !== 'all') { + query.andWhere('owner_user_id', user_id); + } + + return query.first() + .then(row => { + return parseInt(row.count, 10); + }); + } +}; + +module.exports = internalProxyHost; diff --git a/src/backend/internal/redirection-host.js b/src/backend/internal/redirection-host.js new file mode 100644 index 00000000..15df7d92 --- /dev/null +++ b/src/backend/internal/redirection-host.js @@ -0,0 +1,352 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const redirectionHostModel = require('../models/redirection_host'); +const internalHost = require('./host'); +const internalNginx = require('./nginx'); +const internalAuditLog = require('./audit-log'); +const internalCertificate = require('./certificate'); + +function omissions () { + return ['is_deleted']; +} + +const internalRedirectionHost = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + + return access.can('redirection_hosts:create', data) + .then(access_data => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name)); + }); + + return Promise.all(domain_name_check_promises) + .then(check_results => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + }) + .then(() => { + // At this point the domains should have been checked + data.owner_user_id = access.token.getUserId(1); + + return redirectionHostModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then(row => { + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, data) + .then(cert => { + // update host with cert id + return internalRedirectionHost.update(access, { + id: row.id, + certificate_id: cert.id + }); + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then(row => { + // re-fetch with cert + return internalRedirectionHost.get(access, { + id: row.id, + expand: ['certificate', 'owner'] + }); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(redirectionHostModel, 'redirection_host', row) + .then(() => { + return row; + }); + }) + .then(row => { + data.meta = _.assign({}, data.meta || {}, row.meta); + + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'redirection-host', + object_id: row.id, + meta: data + }) + .then(() => { + return row; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @return {Promise} + */ + update: (access, data) => { + let create_certificate = data.certificate_id === 'new'; + + if (create_certificate) { + delete data.certificate_id; + } + + return access.can('redirection_hosts:update', data.id) + .then(access_data => { + // Get a list of the domain names and check each of them against existing records + let domain_name_check_promises = []; + + if (typeof data.domain_names !== 'undefined') { + data.domain_names.map(function (domain_name) { + domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'redirection', data.id)); + }); + + return Promise.all(domain_name_check_promises) + .then(check_results => { + check_results.map(function (result) { + if (result.is_taken) { + throw new error.ValidationError(result.hostname + ' is already in use'); + } + }); + }); + } + }) + .then(() => { + return internalRedirectionHost.get(access, {id: data.id}); + }) + .then(row => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('Redirection Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + if (create_certificate) { + return internalCertificate.createQuickCertificate(access, { + domain_names: data.domain_names || row.domain_names, + meta: _.assign({}, row.meta, data.meta) + }) + .then(cert => { + // update host with cert id + data.certificate_id = cert.id; + }) + .then(() => { + return row; + }); + } else { + return row; + } + }) + .then(row => { + // Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here. + data = _.assign({}, { + domain_names: row.domain_names + },data); + + return redirectionHostModel + .query() + .where({id: data.id}) + .patch(data) + .then(saved_row => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'redirection-host', + object_id: row.id, + meta: data + }) + .then(() => { + return _.omit(saved_row, omissions()); + }); + }); + }) + .then(() => { + return internalRedirectionHost.get(access, { + id: data.id, + expand: ['owner', 'certificate'] + }) + .then(row => { + // Configure nginx + return internalNginx.configure(redirectionHostModel, 'redirection_host', row) + .then(() => { + return _.omit(row, omissions()); + }); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('redirection_hosts:get', data.id) + .then(access_data => { + let query = redirectionHostModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowEager('[owner,certificate]') + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('redirection_hosts:delete', data.id) + .then(() => { + return internalRedirectionHost.get(access, {id: data.id}); + }) + .then(row => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } + + return redirectionHostModel + .query() + .where('id', row.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // Delete Nginx Config + return internalNginx.deleteConfig('redirection_host', row) + .then(() => { + return internalNginx.reload(); + }); + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'redirection-host', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * All Hosts + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('redirection_hosts:list') + .then(access_data => { + let query = redirectionHostModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']) + .allowEager('[owner,certificate]') + .orderBy('domain_names', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('domain_names', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * Report use + * + * @param {Integer} user_id + * @param {String} visibility + * @returns {Promise} + */ + getCount: (user_id, visibility) => { + let query = redirectionHostModel + .query() + .count('id as count') + .where('is_deleted', 0); + + if (visibility !== 'all') { + query.andWhere('owner_user_id', user_id); + } + + return query.first() + .then(row => { + return parseInt(row.count, 10); + }); + } +}; + +module.exports = internalRedirectionHost; diff --git a/src/backend/internal/report.js b/src/backend/internal/report.js new file mode 100644 index 00000000..bd4facae --- /dev/null +++ b/src/backend/internal/report.js @@ -0,0 +1,40 @@ +'use strict'; + +const internalProxyHost = require('./proxy-host'); +const internalRedirectionHost = require('./redirection-host'); +const internalDeadHost = require('./dead-host'); +const internalStream = require('./stream'); + +const internalReport = { + + /** + * @param {Access} access + * @return {Promise} + */ + getHostsReport: access => { + return access.can('reports:hosts', 1) + .then(access_data => { + let user_id = access.token.getUserId(1); + + let promises = [ + internalProxyHost.getCount(user_id, access_data.visibility), + internalRedirectionHost.getCount(user_id, access_data.visibility), + internalStream.getCount(user_id, access_data.visibility), + internalDeadHost.getCount(user_id, access_data.visibility) + ]; + + return Promise.all(promises); + }) + .then(counts => { + return { + proxy: counts.shift(), + redirection: counts.shift(), + stream: counts.shift(), + dead: counts.shift() + }; + }); + + } +}; + +module.exports = internalReport; diff --git a/src/backend/internal/stream.js b/src/backend/internal/stream.js new file mode 100644 index 00000000..b47b9fa6 --- /dev/null +++ b/src/backend/internal/stream.js @@ -0,0 +1,246 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const streamModel = require('../models/stream'); +const internalNginx = require('./nginx'); +const internalAuditLog = require('./audit-log'); + +function omissions () { + return ['is_deleted']; +} + +const internalStream = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + return access.can('streams:create', data) + .then(access_data => { + // TODO: At this point the existing ports should have been checked + data.owner_user_id = access.token.getUserId(1); + + if (typeof data.meta === 'undefined') { + data.meta = {}; + } + + return streamModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then(row => { + // Configure nginx + return internalNginx.configure(streamModel, 'stream', row) + .then(() => { + return internalStream.get(access, {id: row.id, expand: ['owner']}); + }); + }) + .then(row => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'stream', + object_id: row.id, + meta: data + }) + .then(() => { + return row; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @return {Promise} + */ + update: (access, data) => { + return access.can('streams:update', data.id) + .then(access_data => { + // TODO: at this point the existing streams should have been checked + return internalStream.get(access, {id: data.id}); + }) + .then(row => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('Stream could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + return streamModel + .query() + .omit(omissions()) + .patchAndFetchById(row.id, data) + .then(saved_row => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'stream', + object_id: row.id, + meta: data + }) + .then(() => { + return _.omit(saved_row, omissions()); + }); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('streams:get', data.id) + .then(access_data => { + let query = streamModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowEager('[owner]') + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('streams:delete', data.id) + .then(() => { + return internalStream.get(access, {id: data.id}); + }) + .then(row => { + if (!row) { + throw new error.ItemNotFoundError(data.id); + } + + return streamModel + .query() + .where('id', row.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // Delete Nginx Config + return internalNginx.deleteConfig('stream', row) + .then(() => { + return internalNginx.reload(); + }); + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'stream', + object_id: row.id, + meta: _.omit(row, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * All Streams + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('streams:list') + .then(access_data => { + let query = streamModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']) + .allowEager('[owner]') + .orderBy('incoming_port', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('incoming_port', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * Report use + * + * @param {Integer} user_id + * @param {String} visibility + * @returns {Promise} + */ + getCount: (user_id, visibility) => { + let query = streamModel + .query() + .count('id as count') + .where('is_deleted', 0); + + if (visibility !== 'all') { + query.andWhere('owner_user_id', user_id); + } + + return query.first() + .then(row => { + return parseInt(row.count, 10); + }); + } +}; + +module.exports = internalStream; diff --git a/src/backend/internal/token.js b/src/backend/internal/token.js new file mode 100644 index 00000000..5b074d32 --- /dev/null +++ b/src/backend/internal/token.js @@ -0,0 +1,166 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const userModel = require('../models/user'); +const authModel = require('../models/auth'); +const helpers = require('../lib/helpers'); +const TokenModel = require('../models/token'); + +module.exports = { + + /** + * @param {Object} data + * @param {String} data.identity + * @param {String} data.secret + * @param {String} [data.scope] + * @param {String} [data.expiry] + * @param {String} [issuer] + * @returns {Promise} + */ + getTokenFromEmail: (data, issuer) => { + let Token = new TokenModel(); + + data.scope = data.scope || 'user'; + data.expiry = data.expiry || '30d'; + + return userModel + .query() + .where('email', data.identity) + .andWhere('is_deleted', 0) + .andWhere('is_disabled', 0) + .first() + .then(user => { + if (user) { + // Get auth + return authModel + .query() + .where('user_id', '=', user.id) + .where('type', '=', 'password') + .first() + .then(auth => { + if (auth) { + return auth.verifyPassword(data.secret) + .then(valid => { + if (valid) { + + if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) { + // The scope requested doesn't exist as a role against the user, + // you shall not pass. + throw new error.AuthError('Invalid scope: ' + data.scope); + } + + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + return Token.create({ + iss: issuer || 'api', + attrs: { + id: user.id + }, + scope: [data.scope] + }, { + expiresIn: expiry.unix() + }) + .then(signed => { + return { + token: signed.token, + expires: expiry.toISOString() + }; + }); + } else { + throw new error.AuthError('Invalid password'); + } + }); + } else { + throw new error.AuthError('No password auth for user'); + } + }); + } else { + throw new error.AuthError('No relevant user found'); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} [data] + * @param {String} [data.expiry] + * @param {String} [data.scope] Only considered if existing token scope is admin + * @returns {Promise} + */ + getFreshToken: (access, data) => { + let Token = new TokenModel(); + + data = data || {}; + data.expiry = data.expiry || '30d'; + + if (access && access.token.getUserId(0)) { + + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + let token_attrs = { + id: access.token.getUserId(0) + }; + + // Only admins can request otherwise scoped tokens + let scope = access.token.get('scope'); + if (data.scope && access.token.hasScope('admin')) { + scope = [data.scope]; + + if (data.scope === 'job-board' || data.scope === 'worker') { + token_attrs.id = 0; + } + } + + return Token.create({ + iss: 'api', + scope: scope, + attrs: token_attrs + }, { + expiresIn: expiry.unix() + }) + .then(signed => { + return { + token: signed.token, + expires: expiry.toISOString() + }; + }); + } else { + throw new error.AssertionFailedError('Existing token contained invalid user data'); + } + }, + + /** + * @param {Object} user + * @returns {Promise} + */ + getTokenFromUser: user => { + let Token = new TokenModel(); + let expiry = helpers.parseDatePeriod('1d'); + + return Token.create({ + iss: 'api', + attrs: { + id: user.id + }, + scope: ['user'] + }, { + expiresIn: expiry.unix() + }) + .then(signed => { + return { + token: signed.token, + expires: expiry.toISOString(), + user: user + }; + }); + } +}; diff --git a/src/backend/internal/user.js b/src/backend/internal/user.js new file mode 100644 index 00000000..ff9853f7 --- /dev/null +++ b/src/backend/internal/user.js @@ -0,0 +1,520 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const userModel = require('../models/user'); +const userPermissionModel = require('../models/user_permission'); +const authModel = require('../models/auth'); +const gravatar = require('gravatar'); +const internalToken = require('./token'); +const internalAuditLog = require('./audit-log'); + +function omissions () { + return ['is_deleted']; +} + +const internalUser = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + let auth = data.auth || null; + delete data.auth; + + data.avatar = data.avatar || ''; + data.roles = data.roles || []; + + if (typeof data.is_disabled !== 'undefined') { + data.is_disabled = data.is_disabled ? 1 : 0; + } + + return access.can('users:create', data) + .then(() => { + data.avatar = gravatar.url(data.email, {default: 'mm'}); + + return userModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then(user => { + if (auth) { + return authModel + .query() + .insert({ + user_id: user.id, + type: auth.type, + secret: auth.secret, + meta: {} + }) + .then(() => { + return user; + }); + } else { + return user; + } + }) + .then(user => { + // Create permissions row as well + let is_admin = data.roles.indexOf('admin') !== -1; + + return userPermissionModel + .query() + .insert({ + user_id: user.id, + visibility: is_admin ? 'all' : 'user', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + dead_hosts: 'manage', + streams: 'manage', + access_lists: 'manage', + certificates: 'manage' + }) + .then(() => { + return internalUser.get(access, {id: user.id, expand: ['permissions']}); + }); + }) + .then(user => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'user', + object_id: user.id, + meta: user + }) + .then(() => { + return user; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.email] + * @param {String} [data.name] + * @return {Promise} + */ + update: (access, data) => { + if (typeof data.is_disabled !== 'undefined') { + data.is_disabled = data.is_disabled ? 1 : 0; + } + + return access.can('users:update', data.id) + .then(() => { + + // Make sure that the user being updated doesn't change their email to another user that is already using it + // 1. get user we want to update + return internalUser.get(access, {id: data.id}) + .then(user => { + + // 2. if email is to be changed, find other users with that email + if (typeof data.email !== 'undefined') { + data.email = data.email.toLowerCase().trim(); + + if (user.email !== data.email) { + return internalUser.isEmailAvailable(data.email, data.id) + .then(available => { + if (!available) { + throw new error.ValidationError('Email address already in use - ' + data.email); + } + + return user; + }); + } + } + + // No change to email: + return user; + }); + }) + .then(user => { + if (user.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id); + } + + data.avatar = gravatar.url(data.email || user.email, {default: 'mm'}); + + return userModel + .query() + .omit(omissions()) + .patchAndFetchById(user.id, data) + .then(saved_user => { + return _.omit(saved_user, omissions()); + }); + }) + .then(() => { + return internalUser.get(access, {id: data.id}); + }) + .then(user => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'user', + object_id: user.id, + meta: data + }) + .then(() => { + return user; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} [data] + * @param {Integer} [data.id] Defaults to the token user + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + if (typeof data.id === 'undefined' || !data.id) { + data.id = access.token.getUserId(0); + } + + return access.can('users:get', data.id) + .then(() => { + let query = userModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowEager('[permissions]') + .first(); + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * Checks if an email address is available, but if a user_id is supplied, it will ignore checking + * against that user. + * + * @param email + * @param user_id + */ + isEmailAvailable: (email, user_id) => { + let query = userModel + .query() + .where('email', '=', email.toLowerCase().trim()) + .where('is_deleted', 0) + .first(); + + if (typeof user_id !== 'undefined') { + query.where('id', '!=', user_id); + } + + return query + .then(user => { + return !user; + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('users:delete', data.id) + .then(() => { + return internalUser.get(access, {id: data.id}); + }) + .then(user => { + if (!user) { + throw new error.ItemNotFoundError(data.id); + } + + // Make sure user can't delete themselves + if (user.id === access.token.getUserId(0)) { + throw new error.PermissionError('You cannot delete yourself.'); + } + + return userModel + .query() + .where('id', user.id) + .patch({ + is_deleted: 1 + }) + .then(() => { + // Add to audit log + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'user', + object_id: user.id, + meta: _.omit(user, omissions()) + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * This will only count the users + * + * @param {Access} access + * @param {String} [search_query] + * @returns {*} + */ + getCount: (access, search_query) => { + return access.can('users:list') + .then(() => { + let query = userModel + .query() + .count('id as count') + .where('is_deleted', 0) + .first(); + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('user.name', 'like', '%' + search_query + '%') + .orWhere('user.email', 'like', '%' + search_query + '%'); + }); + } + + return query; + }) + .then(row => { + return parseInt(row.count, 10); + }); + }, + + /** + * All users + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('users:list') + .then(() => { + let query = userModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .omit(['is_deleted']) + .allowEager('[permissions]') + .orderBy('name', 'ASC'); + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('name', 'like', '%' + search_query + '%') + .orWhere('email', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * @param {Access} access + * @param {Integer} [id_requested] + * @returns {[String]} + */ + getUserOmisionsByAccess: (access, id_requested) => { + let response = []; // Admin response + + if (!access.token.hasScope('admin') && access.token.getUserId(0) !== id_requested) { + response = ['roles', 'is_deleted']; // Restricted response + } + + return response; + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} data.type + * @param {String} data.secret + * @return {Promise} + */ + setPassword: (access, data) => { + return access.can('users:password', data.id) + .then(() => { + return internalUser.get(access, {id: data.id}); + }) + .then(user => { + if (user.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id); + } + + if (user.id === access.token.getUserId(0)) { + // they're setting their own password. Make sure their current password is correct + if (typeof data.current === 'undefined' || !data.current) { + throw new error.ValidationError('Current password was not supplied'); + } + + return internalToken.getTokenFromEmail({ + identity: user.email, + secret: data.current + }) + .then(() => { + return user; + }); + } + + return user; + }) + .then(user => { + // Get auth, patch if it exists + return authModel + .query() + .where('user_id', user.id) + .andWhere('type', data.type) + .first() + .then(existing_auth => { + if (existing_auth) { + // patch + return authModel + .query() + .where('user_id', user.id) + .andWhere('type', data.type) + .patch({ + type: data.type, // This is required for the model to encrypt on save + secret: data.secret + }); + } else { + // insert + return authModel + .query() + .insert({ + user_id: user.id, + type: data.type, + secret: data.secret, + meta: {} + }); + } + }) + .then(() => { + // Add to Audit Log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'user', + object_id: user.id, + meta: { + name: user.name, + password_changed: true, + auth_type: data.type + } + }); + }); + }) + .then(() => { + return true; + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @return {Promise} + */ + setPermissions: (access, data) => { + return access.can('users:permissions', data.id) + .then(() => { + return internalUser.get(access, {id: data.id}); + }) + .then(user => { + if (user.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id); + } + + return user; + }) + .then(user => { + // Get perms row, patch if it exists + return userPermissionModel + .query() + .where('user_id', user.id) + .first() + .then(existing_auth => { + if (existing_auth) { + // patch + return userPermissionModel + .query() + .where('user_id', user.id) + .patchAndFetchById(existing_auth.id, _.assign({user_id: user.id}, data)); + } else { + // insert + return userPermissionModel + .query() + .insertAndFetch(_.assign({user_id: user.id}, data)); + } + }) + .then(permissions => { + // Add to Audit Log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'user', + object_id: user.id, + meta: { + name: user.name, + permissions: permissions + } + }); + + }); + }) + .then(() => { + return true; + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + */ + loginAs: (access, data) => { + return access.can('users:loginas', data.id) + .then(() => { + return internalUser.get(access, data); + }) + .then(user => { + return internalToken.getTokenFromUser(user); + }); + } +}; + +module.exports = internalUser; diff --git a/src/backend/lib/access.js b/src/backend/lib/access.js new file mode 100644 index 00000000..6b9ab8b6 --- /dev/null +++ b/src/backend/lib/access.js @@ -0,0 +1,315 @@ +'use strict'; + +/** + * Some Notes: This is a friggin complicated piece of code. + * + * "scope" in this file means "where did this token come from and what is using it", so 99% of the time + * the "scope" is going to be "user" because it would be a user token. This is not to be confused with + * the "role" which could be "user" or "admin". The scope in fact, could be "worker" or anything else. + * + * + */ + +const _ = require('lodash'); +const logger = require('../logger').access; +const validator = require('ajv'); +const error = require('./error'); +const userModel = require('../models/user'); +const proxyHostModel = require('../models/proxy_host'); +const TokenModel = require('../models/token'); +const roleSchema = require('./access/roles.json'); +const permsSchema = require('./access/permissions.json'); + +module.exports = function (token_string) { + let Token = new TokenModel(); + let token_data = null; + let initialised = false; + let object_cache = {}; + let allow_internal_access = false; + let user_roles = []; + let permissions = {}; + + /** + * Loads the Token object from the token string + * + * @returns {Promise} + */ + this.init = () => { + return new Promise((resolve, reject) => { + if (initialised) { + resolve(); + } else if (!token_string) { + reject(new error.PermissionError('Permission Denied')); + } else { + resolve(Token.load(token_string) + .then(data => { + token_data = data; + + // At this point we need to load the user from the DB and make sure they: + // - exist (and not soft deleted) + // - still have the appropriate scopes for this token + // This is only required when the User ID is supplied or if the token scope has `user` + + if (token_data.attrs.id || (typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'user') !== -1)) { + // Has token user id or token user scope + return userModel + .query() + .where('id', token_data.attrs.id) + .andWhere('is_deleted', 0) + .andWhere('is_disabled', 0) + .allowEager('[permissions]') + .eager('[permissions]') + .first() + .then(user => { + if (user) { + // make sure user has all scopes of the token + // The `user` role is not added against the user row, so we have to just add it here to get past this check. + user.roles.push('user'); + + let is_ok = true; + _.forEach(token_data.scope, (scope_item) => { + if (_.indexOf(user.roles, scope_item) === -1) { + is_ok = false; + } + }); + + if (!is_ok) { + throw new error.AuthError('Invalid token scope for User'); + } else { + initialised = true; + user_roles = user.roles; + permissions = user.permissions; + } + + } else { + throw new error.AuthError('User cannot be loaded for Token'); + } + }); + } else { + initialised = true; + } + })); + } + }); + }; + + /** + * Fetches the object ids from the database, only once per object type, for this token. + * This only applies to USER token scopes, as all other tokens are not really bound + * by object scopes + * + * @param {String} object_type + * @returns {Promise} + */ + this.loadObjects = object_type => { + return new Promise((resolve, reject) => { + if (Token.hasScope('user')) { + if (typeof token_data.attrs.id === 'undefined' || !token_data.attrs.id) { + reject(new error.AuthError('User Token supplied without a User ID')); + } else { + let token_user_id = token_data.attrs.id ? token_data.attrs.id : 0; + + if (typeof object_cache[object_type] === 'undefined') { + switch (object_type) { + + // USERS - should only return yourself + case 'users': + resolve(token_user_id ? [token_user_id] : []); + break; + + // Proxy Hosts + case 'proxy_hosts': + let query = proxyHostModel + .query() + .select('id') + .andWhere('is_deleted', 0); + + if (permissions.visibility === 'user') { + query.andWhere('owner_user_id', token_user_id); + } + + resolve(query + .then(rows => { + let result = []; + _.forEach(rows, (rule_row) => { + result.push(rule_row.id); + }); + + // enum should not have less than 1 item + if (!result.length) { + result.push(0); + } + + return result; + }) + ); + break; + + // DEFAULT: null + default: + resolve(null); + break; + } + } else { + resolve(object_cache[object_type]); + } + } + } else { + resolve(null); + } + }) + .then(objects => { + object_cache[object_type] = objects; + return objects; + }); + }; + + /** + * Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema + * + * @param {String} permission_label + * @returns {Object} + */ + this.getObjectSchema = permission_label => { + let base_object_type = permission_label.split(':').shift(); + + let schema = { + $id: 'objects', + $schema: 'http://json-schema.org/draft-07/schema#', + description: 'Actor Properties', + type: 'object', + additionalProperties: false, + properties: { + user_id: { + anyOf: [ + { + type: 'number', + enum: [Token.get('attrs').id] + } + ] + }, + scope: { + type: 'string', + pattern: '^' + Token.get('scope') + '$' + } + } + }; + + return this.loadObjects(base_object_type) + .then(object_result => { + if (typeof object_result === 'object' && object_result !== null) { + schema.properties[base_object_type] = { + type: 'number', + enum: object_result, + minimum: 1 + }; + } else { + schema.properties[base_object_type] = { + type: 'number', + minimum: 1 + }; + } + + return schema; + }); + }; + + return { + + token: Token, + + /** + * + * @param {Boolean} [allow_internal] + * @returns {Promise} + */ + load: allow_internal => { + return new Promise(function (resolve/*, reject*/) { + if (token_string) { + resolve(Token.load(token_string)); + } else { + allow_internal_access = allow_internal; + resolve(allow_internal_access || null); + } + }); + }, + + reloadObjects: this.loadObjects, + + /** + * + * @param {String} permission + * @param {*} [data] + * @returns {Promise} + */ + can: (permission, data) => { + if (allow_internal_access === true) { + return Promise.resolve(true); + //return true; + } else { + return this.init() + .then(() => { + // Initialised, token decoded ok + return this.getObjectSchema(permission) + .then(objectSchema => { + let data_schema = { + [permission]: { + data: data, + scope: Token.get('scope'), + roles: user_roles, + permission_visibility: permissions.visibility, + permission_proxy_hosts: permissions.proxy_hosts, + permission_redirection_hosts: permissions.redirection_hosts, + permission_dead_hosts: permissions.dead_hosts, + permission_streams: permissions.streams, + permission_access_lists: permissions.access_lists, + permission_certificates: permissions.certificates + } + }; + + let permissionSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $async: true, + $id: 'permissions', + additionalProperties: false, + properties: {} + }; + + permissionSchema.properties[permission] = require('./access/' + permission.replace(/:/gim, '-') + '.json'); + + // logger.info('objectSchema', JSON.stringify(objectSchema, null, 2)); + // logger.info('permissionSchema', JSON.stringify(permissionSchema, null, 2)); + // logger.info('data_schema', JSON.stringify(data_schema, null, 2)); + + let ajv = validator({ + verbose: true, + allErrors: true, + format: 'full', + missingRefs: 'fail', + breakOnError: true, + coerceTypes: true, + schemas: [ + roleSchema, + permsSchema, + objectSchema, + permissionSchema + ] + }); + + return ajv.validate('permissions', data_schema) + .then(() => { + return data_schema[permission]; + }); + }); + }) + .catch(err => { + err.permission = permission; + err.permission_data = data; + logger.error(permission, data, err.message); + + throw new error.PermissionError('Permission Denied', err); + }); + } + } + }; +}; diff --git a/src/backend/lib/access/access_lists-create.json b/src/backend/lib/access/access_lists-create.json new file mode 100644 index 00000000..f2a91ffe --- /dev/null +++ b/src/backend/lib/access/access_lists-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_access_lists", "roles"], + "properties": { + "permission_access_lists": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/access_lists-delete.json b/src/backend/lib/access/access_lists-delete.json new file mode 100644 index 00000000..f2a91ffe --- /dev/null +++ b/src/backend/lib/access/access_lists-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_access_lists", "roles"], + "properties": { + "permission_access_lists": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/access_lists-get.json b/src/backend/lib/access/access_lists-get.json new file mode 100644 index 00000000..12203b39 --- /dev/null +++ b/src/backend/lib/access/access_lists-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_access_lists", "roles"], + "properties": { + "permission_access_lists": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/access_lists-list.json b/src/backend/lib/access/access_lists-list.json new file mode 100644 index 00000000..12203b39 --- /dev/null +++ b/src/backend/lib/access/access_lists-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_access_lists", "roles"], + "properties": { + "permission_access_lists": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/access_lists-update.json b/src/backend/lib/access/access_lists-update.json new file mode 100644 index 00000000..f2a91ffe --- /dev/null +++ b/src/backend/lib/access/access_lists-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_access_lists", "roles"], + "properties": { + "permission_access_lists": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/auditlog-list.json b/src/backend/lib/access/auditlog-list.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/auditlog-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/certificates-create.json b/src/backend/lib/access/certificates-create.json new file mode 100644 index 00000000..3eea8a26 --- /dev/null +++ b/src/backend/lib/access/certificates-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_certificates", "roles"], + "properties": { + "permission_certificates": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/certificates-delete.json b/src/backend/lib/access/certificates-delete.json new file mode 100644 index 00000000..3eea8a26 --- /dev/null +++ b/src/backend/lib/access/certificates-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_certificates", "roles"], + "properties": { + "permission_certificates": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/certificates-get.json b/src/backend/lib/access/certificates-get.json new file mode 100644 index 00000000..8966a4ac --- /dev/null +++ b/src/backend/lib/access/certificates-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_certificates", "roles"], + "properties": { + "permission_certificates": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/certificates-list.json b/src/backend/lib/access/certificates-list.json new file mode 100644 index 00000000..8966a4ac --- /dev/null +++ b/src/backend/lib/access/certificates-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_certificates", "roles"], + "properties": { + "permission_certificates": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/certificates-update.json b/src/backend/lib/access/certificates-update.json new file mode 100644 index 00000000..3eea8a26 --- /dev/null +++ b/src/backend/lib/access/certificates-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_certificates", "roles"], + "properties": { + "permission_certificates": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/dead_hosts-create.json b/src/backend/lib/access/dead_hosts-create.json new file mode 100644 index 00000000..12fc4af0 --- /dev/null +++ b/src/backend/lib/access/dead_hosts-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_dead_hosts", "roles"], + "properties": { + "permission_dead_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/dead_hosts-delete.json b/src/backend/lib/access/dead_hosts-delete.json new file mode 100644 index 00000000..12fc4af0 --- /dev/null +++ b/src/backend/lib/access/dead_hosts-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_dead_hosts", "roles"], + "properties": { + "permission_dead_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/dead_hosts-get.json b/src/backend/lib/access/dead_hosts-get.json new file mode 100644 index 00000000..925b52ce --- /dev/null +++ b/src/backend/lib/access/dead_hosts-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_dead_hosts", "roles"], + "properties": { + "permission_dead_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/dead_hosts-list.json b/src/backend/lib/access/dead_hosts-list.json new file mode 100644 index 00000000..925b52ce --- /dev/null +++ b/src/backend/lib/access/dead_hosts-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_dead_hosts", "roles"], + "properties": { + "permission_dead_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/dead_hosts-update.json b/src/backend/lib/access/dead_hosts-update.json new file mode 100644 index 00000000..12fc4af0 --- /dev/null +++ b/src/backend/lib/access/dead_hosts-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_dead_hosts", "roles"], + "properties": { + "permission_dead_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/permissions.json b/src/backend/lib/access/permissions.json new file mode 100644 index 00000000..cf64a7d6 --- /dev/null +++ b/src/backend/lib/access/permissions.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "perms", + "definitions": { + "view": { + "type": "string", + "pattern": "^(view|manage)$" + }, + "manage": { + "type": "string", + "pattern": "^(manage)$" + } + } +} + diff --git a/src/backend/lib/access/proxy_hosts-create.json b/src/backend/lib/access/proxy_hosts-create.json new file mode 100644 index 00000000..3ceb86ca --- /dev/null +++ b/src/backend/lib/access/proxy_hosts-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_proxy_hosts", "roles"], + "properties": { + "permission_proxy_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/proxy_hosts-delete.json b/src/backend/lib/access/proxy_hosts-delete.json new file mode 100644 index 00000000..3ceb86ca --- /dev/null +++ b/src/backend/lib/access/proxy_hosts-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_proxy_hosts", "roles"], + "properties": { + "permission_proxy_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/proxy_hosts-get.json b/src/backend/lib/access/proxy_hosts-get.json new file mode 100644 index 00000000..10c47465 --- /dev/null +++ b/src/backend/lib/access/proxy_hosts-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_proxy_hosts", "roles"], + "properties": { + "permission_proxy_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/proxy_hosts-list.json b/src/backend/lib/access/proxy_hosts-list.json new file mode 100644 index 00000000..10c47465 --- /dev/null +++ b/src/backend/lib/access/proxy_hosts-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_proxy_hosts", "roles"], + "properties": { + "permission_proxy_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/proxy_hosts-update.json b/src/backend/lib/access/proxy_hosts-update.json new file mode 100644 index 00000000..3ceb86ca --- /dev/null +++ b/src/backend/lib/access/proxy_hosts-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_proxy_hosts", "roles"], + "properties": { + "permission_proxy_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/redirection_hosts-create.json b/src/backend/lib/access/redirection_hosts-create.json new file mode 100644 index 00000000..b27c1f48 --- /dev/null +++ b/src/backend/lib/access/redirection_hosts-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_redirection_hosts", "roles"], + "properties": { + "permission_redirection_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/redirection_hosts-delete.json b/src/backend/lib/access/redirection_hosts-delete.json new file mode 100644 index 00000000..b27c1f48 --- /dev/null +++ b/src/backend/lib/access/redirection_hosts-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_redirection_hosts", "roles"], + "properties": { + "permission_redirection_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/redirection_hosts-get.json b/src/backend/lib/access/redirection_hosts-get.json new file mode 100644 index 00000000..227fc545 --- /dev/null +++ b/src/backend/lib/access/redirection_hosts-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_redirection_hosts", "roles"], + "properties": { + "permission_redirection_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/redirection_hosts-list.json b/src/backend/lib/access/redirection_hosts-list.json new file mode 100644 index 00000000..227fc545 --- /dev/null +++ b/src/backend/lib/access/redirection_hosts-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_redirection_hosts", "roles"], + "properties": { + "permission_redirection_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/redirection_hosts-update.json b/src/backend/lib/access/redirection_hosts-update.json new file mode 100644 index 00000000..b27c1f48 --- /dev/null +++ b/src/backend/lib/access/redirection_hosts-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_redirection_hosts", "roles"], + "properties": { + "permission_redirection_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/reports-hosts.json b/src/backend/lib/access/reports-hosts.json new file mode 100644 index 00000000..4b02c77e --- /dev/null +++ b/src/backend/lib/access/reports-hosts.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/user" + } + ] +} diff --git a/src/backend/lib/access/roles.json b/src/backend/lib/access/roles.json new file mode 100644 index 00000000..18922a15 --- /dev/null +++ b/src/backend/lib/access/roles.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "roles", + "definitions": { + "admin": { + "type": "object", + "required": [ + "scope", + "roles" + ], + "properties": { + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + }, + "roles": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^admin$" + } + } + } + }, + "user": { + "type": "object", + "required": [ + "scope" + ], + "properties": { + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + } + } + } + } +} + diff --git a/src/backend/lib/access/streams-create.json b/src/backend/lib/access/streams-create.json new file mode 100644 index 00000000..6a745ec4 --- /dev/null +++ b/src/backend/lib/access/streams-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_streams", "roles"], + "properties": { + "permission_streams": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/streams-delete.json b/src/backend/lib/access/streams-delete.json new file mode 100644 index 00000000..6a745ec4 --- /dev/null +++ b/src/backend/lib/access/streams-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_streams", "roles"], + "properties": { + "permission_streams": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/streams-get.json b/src/backend/lib/access/streams-get.json new file mode 100644 index 00000000..3443aa85 --- /dev/null +++ b/src/backend/lib/access/streams-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_streams", "roles"], + "properties": { + "permission_streams": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/streams-list.json b/src/backend/lib/access/streams-list.json new file mode 100644 index 00000000..3443aa85 --- /dev/null +++ b/src/backend/lib/access/streams-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_streams", "roles"], + "properties": { + "permission_streams": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/streams-update.json b/src/backend/lib/access/streams-update.json new file mode 100644 index 00000000..6a745ec4 --- /dev/null +++ b/src/backend/lib/access/streams-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_streams", "roles"], + "properties": { + "permission_streams": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/src/backend/lib/access/users-create.json b/src/backend/lib/access/users-create.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/users-create.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/users-delete.json b/src/backend/lib/access/users-delete.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/users-delete.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/users-get.json b/src/backend/lib/access/users-get.json new file mode 100644 index 00000000..04b4e9e9 --- /dev/null +++ b/src/backend/lib/access/users-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["data", "scope"], + "properties": { + "data": { + "$ref": "objects#/properties/users" + }, + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + } + } + } + ] +} diff --git a/src/backend/lib/access/users-list.json b/src/backend/lib/access/users-list.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/users-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/users-loginas.json b/src/backend/lib/access/users-loginas.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/users-loginas.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/users-password.json b/src/backend/lib/access/users-password.json new file mode 100644 index 00000000..04b4e9e9 --- /dev/null +++ b/src/backend/lib/access/users-password.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["data", "scope"], + "properties": { + "data": { + "$ref": "objects#/properties/users" + }, + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + } + } + } + ] +} diff --git a/src/backend/lib/access/users-permissions.json b/src/backend/lib/access/users-permissions.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/users-permissions.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/access/users-update.json b/src/backend/lib/access/users-update.json new file mode 100644 index 00000000..a638780b --- /dev/null +++ b/src/backend/lib/access/users-update.json @@ -0,0 +1,26 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": [ + "data", + "scope" + ], + "properties": { + "data": { + "$ref": "objects#/properties/users" + }, + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + } + } + } + ] +} diff --git a/src/backend/lib/error.js b/src/backend/lib/error.js new file mode 100644 index 00000000..070952f1 --- /dev/null +++ b/src/backend/lib/error.js @@ -0,0 +1,92 @@ +'use strict'; + +const _ = require('lodash'); +const util = require('util'); + +module.exports = { + + PermissionError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = 'Permission Denied'; + this.public = true; + this.status = 403; + }, + + ItemNotFoundError: function (id, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = 'Item Not Found - ' + id; + this.public = true; + this.status = 404; + }, + + AuthError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.public = true; + this.status = 401; + }, + + InternalError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.status = 500; + this.public = false; + }, + + InternalValidationError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.status = 400; + this.public = false; + }, + + ConfigurationError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.status = 400; + this.public = true; + }, + + CacheError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.message = message; + this.previous = previous; + this.status = 500; + this.public = false; + }, + + ValidationError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.public = true; + this.status = 400; + }, + + AssertionFailedError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.public = false; + this.status = 400; + } +}; + +_.forEach(module.exports, function (error) { + util.inherits(error, Error); +}); diff --git a/src/backend/lib/express/cors.js b/src/backend/lib/express/cors.js new file mode 100644 index 00000000..1a5778e3 --- /dev/null +++ b/src/backend/lib/express/cors.js @@ -0,0 +1,32 @@ +'use strict'; + +const validator = require('../validator'); + +module.exports = function (req, res, next) { + + if (req.headers.origin) { + + // very relaxed validation.... + validator({ + type: 'string', + pattern: '^[a-z\\-]+:\\/\\/(?:[\\w\\-\\.]+(:[0-9]+)?/?)?$' + }, req.headers.origin) + .then(function () { + res.set({ + 'Access-Control-Allow-Origin': req.headers.origin, + 'Access-Control-Allow-Credentials': true, + 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', + 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control, Pragma, Expires, Authorization, X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit', + 'Access-Control-Max-Age': 5 * 60, + 'Access-Control-Expose-Headers': 'X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit' + }); + next(); + }) + .catch(next); + + } else { + // No origin + next(); + } + +}; diff --git a/src/backend/lib/express/jwt-decode.js b/src/backend/lib/express/jwt-decode.js new file mode 100644 index 00000000..ce386e4b --- /dev/null +++ b/src/backend/lib/express/jwt-decode.js @@ -0,0 +1,17 @@ +'use strict'; + +const Access = require('../access'); + +module.exports = () => { + return function (req, res, next) { + res.locals.access = null; + let access = new Access(res.locals.token || null); + access.load() + .then(() => { + res.locals.access = access; + next(); + }) + .catch(next); + }; +}; + diff --git a/src/backend/lib/express/jwt.js b/src/backend/lib/express/jwt.js new file mode 100644 index 00000000..b4e7dfe3 --- /dev/null +++ b/src/backend/lib/express/jwt.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = function () { + return function (req, res, next) { + if (req.headers.authorization) { + let parts = req.headers.authorization.split(' '); + + if (parts && parts[0] === 'Bearer' && parts[1]) { + res.locals.token = parts[1]; + } + } + + next(); + }; +}; diff --git a/src/backend/lib/express/pagination.js b/src/backend/lib/express/pagination.js new file mode 100644 index 00000000..c948edf2 --- /dev/null +++ b/src/backend/lib/express/pagination.js @@ -0,0 +1,57 @@ +'use strict'; + +let _ = require('lodash'); + +module.exports = function (default_sort, default_offset, default_limit, max_limit) { + + /** + * This will setup the req query params with filtered data and defaults + * + * sort will be an array of fields and their direction + * offset will be an int, defaulting to zero if no other default supplied + * limit will be an int, defaulting to 50 if no other default supplied, and limited to the max if that was supplied + * + */ + + return function (req, res, next) { + + req.query.offset = typeof req.query.limit === 'undefined' ? default_offset || 0 : parseInt(req.query.offset, 10); + req.query.limit = typeof req.query.limit === 'undefined' ? default_limit || 50 : parseInt(req.query.limit, 10); + + if (max_limit && req.query.limit > max_limit) { + req.query.limit = max_limit; + } + + // Sorting + let sort = typeof req.query.sort === 'undefined' ? default_sort : req.query.sort; + let myRegexp = /.*\.(asc|desc)$/ig; + let sort_array = []; + + sort = sort.split(','); + _.map(sort, function (val) { + let matches = myRegexp.exec(val); + + if (matches !== null) { + let dir = matches[1]; + sort_array.push({ + field: val.substr(0, val.length - (dir.length + 1)), + dir: dir.toLowerCase() + }); + } else { + sort_array.push({ + field: val, + dir: 'asc' + }); + } + }); + + // Sort will now be in this format: + // [ + // { field: 'field1', dir: 'asc' }, + // { field: 'field2', dir: 'desc' } + // ] + + req.query.sort = sort_array; + next(); + }; +}; diff --git a/src/backend/lib/express/user-id-from-me.js b/src/backend/lib/express/user-id-from-me.js new file mode 100644 index 00000000..259b186f --- /dev/null +++ b/src/backend/lib/express/user-id-from-me.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = (req, res, next) => { + if (req.params.user_id === 'me' && res.locals.access) { + req.params.user_id = res.locals.access.token.get('attrs').id; + } else { + req.params.user_id = parseInt(req.params.user_id, 10); + } + + next(); +}; diff --git a/src/backend/lib/helpers.js b/src/backend/lib/helpers.js new file mode 100644 index 00000000..389cfc5c --- /dev/null +++ b/src/backend/lib/helpers.js @@ -0,0 +1,35 @@ +'use strict'; + +const moment = require('moment'); +const _ = require('lodash'); + +module.exports = { + + /** + * Takes an expression such as 30d and returns a moment object of that date in future + * + * Key Shorthand + * ================== + * years y + * quarters Q + * months M + * weeks w + * days d + * hours h + * minutes m + * seconds s + * milliseconds ms + * + * @param {String} expression + * @returns {Object} + */ + parseDatePeriod: function (expression) { + let matches = expression.match(/^([0-9]+)(y|Q|M|w|d|h|m|s|ms)$/m); + if (matches) { + return moment().add(matches[1], matches[2]); + } + + return null; + } + +}; diff --git a/src/backend/lib/migrate_template.js b/src/backend/lib/migrate_template.js new file mode 100644 index 00000000..68ba6d69 --- /dev/null +++ b/src/backend/lib/migrate_template.js @@ -0,0 +1,57 @@ +'use strict'; + +const migrate_name = 'identifier_for_migrate'; +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...'); + + // Create Table example: + + /*return knex.schema.createTable('notification', (table) => { + table.increments().primary(); + table.string('name').notNull(); + table.string('type').notNull(); + table.integer('created_on').notNull(); + table.integer('modified_on').notNull(); + }) + .then(function () { + logger.info('[' + migrate_name + '] Notification Table created'); + });*/ + + logger.info('[' + migrate_name + '] Migrating Up Complete'); + + return Promise.resolve(true); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex, Promise) { + logger.info('[' + migrate_name + '] Migrating Down...'); + + // Drop table example: + + /*return knex.schema.dropTable('notification') + .then(() => { + logger.info('[' + migrate_name + '] Notification Table dropped'); + });*/ + + logger.info('[' + migrate_name + '] Migrating Down Complete'); + + return Promise.resolve(true); +}; diff --git a/manager/src/backend/lib/utils.js b/src/backend/lib/utils.js similarity index 100% rename from manager/src/backend/lib/utils.js rename to src/backend/lib/utils.js diff --git a/manager/src/backend/lib/validator/api.js b/src/backend/lib/validator/api.js similarity index 81% rename from manager/src/backend/lib/validator/api.js rename to src/backend/lib/validator/api.js index cc44fd8a..4f259efe 100644 --- a/manager/src/backend/lib/validator/api.js +++ b/src/backend/lib/validator/api.js @@ -8,7 +8,7 @@ const ajv = require('ajv')({ verbose: true, validateSchema: true, allErrors: false, - format: 'full', // strict regexes for format checks + format: 'full', coerceTypes: true }); @@ -30,10 +30,8 @@ function apiValidator (schema, payload/*, description*/) { resolve(payload); } else { let message = ajv.errorsText(validate.errors); - //console.log(validate.errors); - - let err = new error.ValidationError(message); - err.debug = [validate.errors, payload]; + let err = new error.ValidationError(message); + err.debug = [validate.errors, payload]; reject(err); } }); @@ -41,7 +39,7 @@ function apiValidator (schema, payload/*, description*/) { apiValidator.loadSchemas = parser .dereference(path.resolve('src/backend/schema/index.json')) - .then((schema) => { + .then(schema => { ajv.addSchema(schema); return schema; }); diff --git a/manager/src/backend/lib/validator/index.js b/src/backend/lib/validator/index.js similarity index 87% rename from manager/src/backend/lib/validator/index.js rename to src/backend/lib/validator/index.js index 00f9393e..46d32f20 100644 --- a/manager/src/backend/lib/validator/index.js +++ b/src/backend/lib/validator/index.js @@ -18,8 +18,8 @@ const ajv = require('ajv')({ /** * - * @param {Object} schema - * @param {Object} payload + * @param {Object} schema + * @param {Object} payload * @returns {Promise} */ function validator (schema, payload) { @@ -29,19 +29,23 @@ function validator (schema, payload) { } else { try { let validate = ajv.compile(schema); - let valid = validate(payload); + + let valid = validate(payload); if (valid && !validate.errors) { resolve(_.cloneDeep(payload)); } else { - //debug('Validation failed:', schema, payload); let message = ajv.errorsText(validate.errors); reject(new error.InternalValidationError(message)); } + } catch (err) { reject(err); } + } + }); + } module.exports = validator; diff --git a/src/backend/logger.js b/src/backend/logger.js new file mode 100644 index 00000000..2c618fdc --- /dev/null +++ b/src/backend/logger.js @@ -0,0 +1,11 @@ +const {Signale} = require('signale'); + +module.exports = { + global: new Signale({scope: 'Global '}), + migrate: new Signale({scope: 'Migrate '}), + express: new Signale({scope: 'Express '}), + access: new Signale({scope: 'Access '}), + nginx: new Signale({scope: 'Nginx '}), + ssl: new Signale({scope: 'SSL '}), + import: new Signale({scope: 'Importer'}), +}; diff --git a/src/backend/migrate.js b/src/backend/migrate.js new file mode 100644 index 00000000..68267e36 --- /dev/null +++ b/src/backend/migrate.js @@ -0,0 +1,17 @@ +'use strict'; + +const db = require('./db'); +const logger = require('./logger').migrate; + +module.exports = { + latest: function () { + return db.migrate.currentVersion() + .then(version => { + logger.info('Current database version:', version); + return db.migrate.latest({ + tableName: 'migrations', + directory: 'src/backend/migrations' + }); + }); + } +}; diff --git a/src/backend/migrations/20180618015850_initial.js b/src/backend/migrations/20180618015850_initial.js new file mode 100644 index 00000000..f9777072 --- /dev/null +++ b/src/backend/migrations/20180618015850_initial.js @@ -0,0 +1,207 @@ +'use strict'; + +const migrate_name = 'initial-schema'; +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('auth', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('user_id').notNull().unsigned(); + table.string('type', 30).notNull(); + table.string('secret').notNull(); + table.json('meta').notNull().defaultTo('{}'); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + }) + .then(() => { + logger.info('[' + migrate_name + '] auth Table created'); + + return knex.schema.createTable('user', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.integer('is_disabled').notNull().unsigned().defaultTo(0); + table.string('email').notNull(); + table.string('name').notNull(); + table.string('nickname').notNull(); + table.string('avatar').notNull(); + table.json('roles').notNull(); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] user Table created'); + + return knex.schema.createTable('user_permission', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('user_id').notNull().unsigned(); + table.string('visibility').notNull(); + table.string('proxy_hosts').notNull(); + table.string('redirection_hosts').notNull(); + table.string('dead_hosts').notNull(); + table.string('streams').notNull(); + table.string('access_lists').notNull(); + table.string('certificates').notNull(); + table.unique('user_id'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] user_permission Table created'); + + return knex.schema.createTable('proxy_host', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.json('domain_names').notNull(); + table.string('forward_ip').notNull(); + table.integer('forward_port').notNull().unsigned(); + table.integer('access_list_id').notNull().unsigned().defaultTo(0); + table.integer('certificate_id').notNull().unsigned().defaultTo(0); + table.integer('ssl_forced').notNull().unsigned().defaultTo(0); + table.integer('caching_enabled').notNull().unsigned().defaultTo(0); + table.integer('block_exploits').notNull().unsigned().defaultTo(0); + table.text('advanced_config').notNull().defaultTo(''); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table created'); + + return knex.schema.createTable('redirection_host', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.json('domain_names').notNull(); + table.string('forward_domain_name').notNull(); + table.integer('preserve_path').notNull().unsigned().defaultTo(0); + table.integer('certificate_id').notNull().unsigned().defaultTo(0); + table.integer('ssl_forced').notNull().unsigned().defaultTo(0); + table.integer('block_exploits').notNull().unsigned().defaultTo(0); + table.text('advanced_config').notNull().defaultTo(''); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] redirection_host Table created'); + + return knex.schema.createTable('dead_host', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.json('domain_names').notNull(); + table.integer('certificate_id').notNull().unsigned().defaultTo(0); + table.integer('ssl_forced').notNull().unsigned().defaultTo(0); + table.text('advanced_config').notNull().defaultTo(''); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] dead_host Table created'); + + return knex.schema.createTable('stream', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.integer('incoming_port').notNull().unsigned(); + table.string('forward_ip').notNull(); + table.integer('forwarding_port').notNull().unsigned(); + table.integer('tcp_forwarding').notNull().unsigned().defaultTo(0); + table.integer('udp_forwarding').notNull().unsigned().defaultTo(0); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] stream Table created'); + + return knex.schema.createTable('access_list', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('name').notNull(); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] access_list Table created'); + + return knex.schema.createTable('certificate', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('provider').notNull(); + table.string('nice_name').notNull().defaultTo(''); + table.json('domain_names').notNull().defaultTo('[]'); + table.dateTime('expires_on').notNull(); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] certificate Table created'); + + return knex.schema.createTable('access_list_auth', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('access_list_id').notNull().unsigned(); + table.string('username').notNull(); + table.string('password').notNull(); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] access_list_auth Table created'); + + return knex.schema.createTable('audit_log', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('user_id').notNull().unsigned(); + table.string('object_type').notNull().defaultTo(''); + table.integer('object_id').notNull().unsigned().defaultTo(0); + table.string('action').notNull(); + table.json('meta').notNull().defaultTo('{}'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] audit_log Table created'); + }); + +}; + +/** + * 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); +}; diff --git a/src/backend/models/access_list.js b/src/backend/models/access_list.js new file mode 100644 index 00000000..5bb0e355 --- /dev/null +++ b/src/backend/models/access_list.js @@ -0,0 +1,78 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); +const AccessListAuth = require('./access_list_auth'); + +Model.knex(db); + +class AccessList extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'AccessList'; + } + + static get tableName () { + return 'access_list'; + } + + static get jsonAttributes () { + return ['meta']; + } + + static get relationMappings () { + const ProxyHost = require('./proxy_host'); + + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'access_list.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']); + } + }, + items: { + relation: Model.HasManyRelation, + modelClass: AccessListAuth, + join: { + from: 'access_list.id', + to: 'access_list_auth.access_list_id' + }, + modify: function (qb) { + qb.omit(['id', 'created_on', 'modified_on', 'access_list_id', 'meta']); + } + }, + proxy_hosts: { + relation: Model.HasManyRelation, + modelClass: ProxyHost, + join: { + from: 'access_list.id', + to: 'proxy_host.access_list_id' + }, + modify: function (qb) { + qb.where('proxy_host.is_deleted', 0); + qb.omit(['is_deleted', 'meta']); + } + } + }; + } +} + +module.exports = AccessList; diff --git a/src/backend/models/access_list_auth.js b/src/backend/models/access_list_auth.js new file mode 100644 index 00000000..cb44ce1b --- /dev/null +++ b/src/backend/models/access_list_auth.js @@ -0,0 +1,51 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; + +Model.knex(db); + +class AccessListAuth extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'AccessListAuth'; + } + + static get tableName () { + return 'access_list_auth'; + } + + static get jsonAttributes () { + return ['meta']; + } + + static get relationMappings () { + return { + access_list: { + relation: Model.HasOneRelation, + modelClass: require('./access_list'), + join: { + from: 'access_list_auth.access_list_id', + to: 'access_list.id' + }, + modify: function (qb) { + qb.where('access_list.is_deleted', 0); + qb.omit(['created_on', 'modified_on', 'is_deleted', 'access_list_id']); + } + } + }; + } +} + +module.exports = AccessListAuth; diff --git a/src/backend/models/audit-log.js b/src/backend/models/audit-log.js new file mode 100644 index 00000000..d9f312ee --- /dev/null +++ b/src/backend/models/audit-log.js @@ -0,0 +1,51 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); + +Model.knex(db); + +class AuditLog extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'AuditLog'; + } + + static get tableName () { + return 'audit_log'; + } + + static get jsonAttributes () { + return ['meta']; + } + + static get relationMappings () { + return { + user: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'audit_log.user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.omit(['id', 'created_on', 'modified_on', 'roles']); + } + } + }; + } +} + +module.exports = AuditLog; diff --git a/src/backend/models/auth.js b/src/backend/models/auth.js new file mode 100644 index 00000000..5024a0d8 --- /dev/null +++ b/src/backend/models/auth.js @@ -0,0 +1,82 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const bcrypt = require('bcrypt'); +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); + +Model.knex(db); + +function encryptPassword () { + /* jshint -W040 */ + let _this = this; + + if (_this.type === 'password' && _this.secret) { + return bcrypt.hash(_this.secret, 13) + .then(function (hash) { + _this.secret = hash; + }); + } + + return null; +} + +class Auth extends Model { + $beforeInsert (queryContext) { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + + return encryptPassword.apply(this, queryContext); + } + + $beforeUpdate (queryContext) { + this.modified_on = Model.raw('NOW()'); + return encryptPassword.apply(this, queryContext); + } + + /** + * Verify a plain password against the encrypted password + * + * @param {String} password + * @returns {Promise} + */ + verifyPassword (password) { + return bcrypt.compare(password, this.secret); + } + + static get name () { + return 'Auth'; + } + + static get tableName () { + return 'auth'; + } + + static get jsonAttributes () { + return ['meta']; + } + + static get relationMappings () { + return { + user: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'auth.user_id', + to: 'user.id' + }, + filter: { + is_deleted: 0 + }, + modify: function (qb) { + qb.omit(['is_deleted']); + } + } + }; + } +} + +module.exports = Auth; diff --git a/src/backend/models/certificate.js b/src/backend/models/certificate.js new file mode 100644 index 00000000..476f7b41 --- /dev/null +++ b/src/backend/models/certificate.js @@ -0,0 +1,56 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); + +Model.knex(db); + +class Certificate extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + + if (typeof this.expires_on === 'undefined') { + this.expires_on = Model.raw('NOW()'); + } + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'Certificate'; + } + + static get tableName () { + return 'certificate'; + } + + static get jsonAttributes () { + return ['domain_names', 'meta']; + } + + static get relationMappings () { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'certificate.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']); + } + } + }; + } +} + +module.exports = Certificate; diff --git a/src/backend/models/dead_host.js b/src/backend/models/dead_host.js new file mode 100644 index 00000000..3eb093a8 --- /dev/null +++ b/src/backend/models/dead_host.js @@ -0,0 +1,65 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); +const Certificate = require('./certificate'); + +Model.knex(db); + +class DeadHost extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'DeadHost'; + } + + static get tableName () { + return 'dead_host'; + } + + static get jsonAttributes () { + return ['domain_names', 'meta']; + } + + static get relationMappings () { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'dead_host.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']); + } + }, + certificate: { + relation: Model.HasOneRelation, + modelClass: Certificate, + join: { + from: 'dead_host.certificate_id', + to: 'certificate.id' + }, + modify: function (qb) { + qb.where('certificate.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']); + } + } + }; + } +} + +module.exports = DeadHost; diff --git a/src/backend/models/proxy_host.js b/src/backend/models/proxy_host.js new file mode 100644 index 00000000..6a3bffbe --- /dev/null +++ b/src/backend/models/proxy_host.js @@ -0,0 +1,82 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); +const AccessList = require('./access_list'); +const Certificate = require('./certificate'); + +Model.knex(db); + +class ProxyHost extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + this.domain_names.sort(); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + if (typeof this.domain_names !== 'undefined') { + this.domain_names.sort(); + } + } + + static get name () { + return 'ProxyHost'; + } + + static get tableName () { + return 'proxy_host'; + } + + static get jsonAttributes () { + return ['domain_names', 'meta']; + } + + static get relationMappings () { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'proxy_host.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']); + } + }, + access_list: { + relation: Model.HasOneRelation, + modelClass: AccessList, + join: { + from: 'proxy_host.access_list_id', + to: 'access_list.id' + }, + modify: function (qb) { + qb.where('access_list.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']); + } + }, + certificate: { + relation: Model.HasOneRelation, + modelClass: Certificate, + join: { + from: 'proxy_host.certificate_id', + to: 'certificate.id' + }, + modify: function (qb) { + qb.where('certificate.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']); + } + } + }; + } +} + +module.exports = ProxyHost; diff --git a/src/backend/models/redirection_host.js b/src/backend/models/redirection_host.js new file mode 100644 index 00000000..28f12a56 --- /dev/null +++ b/src/backend/models/redirection_host.js @@ -0,0 +1,65 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); +const Certificate = require('./certificate'); + +Model.knex(db); + +class RedirectionHost extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'RedirectionHost'; + } + + static get tableName () { + return 'redirection_host'; + } + + static get jsonAttributes () { + return ['domain_names', 'meta']; + } + + static get relationMappings () { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'redirection_host.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']); + } + }, + certificate: { + relation: Model.HasOneRelation, + modelClass: Certificate, + join: { + from: 'redirection_host.certificate_id', + to: 'certificate.id' + }, + modify: function (qb) { + qb.where('certificate.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']); + } + } + }; + } +} + +module.exports = RedirectionHost; diff --git a/src/backend/models/stream.js b/src/backend/models/stream.js new file mode 100644 index 00000000..7e9f76b9 --- /dev/null +++ b/src/backend/models/stream.js @@ -0,0 +1,52 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); + +Model.knex(db); + +class Stream extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'Stream'; + } + + static get tableName () { + return 'stream'; + } + + static get jsonAttributes () { + return ['meta']; + } + + static get relationMappings () { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'stream.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']); + } + } + }; + } +} + +module.exports = Stream; diff --git a/src/backend/models/token.js b/src/backend/models/token.js new file mode 100644 index 00000000..4a96fc4c --- /dev/null +++ b/src/backend/models/token.js @@ -0,0 +1,149 @@ +/** + NOTE: This is not a database table, this is a model of a Token object that can be created/loaded + and then has abilities after that. + */ + +'use strict'; + +const _ = require('lodash'); +const config = require('config'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const error = require('../lib/error'); +const ALGO = 'RS256'; + +module.exports = function () { + const public_key = config.get('jwt.pub'); + const private_key = config.get('jwt.key'); + + let token_data = {}; + + let self = { + //return { + /** + * @param {Object} payload + * @param {Object} [user_options] + * @param {Integer} [user_options.expires] + * @returns {Promise} + */ + create: (payload, user_options) => { + + user_options = user_options || {}; + + // sign with RSA SHA256 + let options = { + algorithm: ALGO + }; + + if (typeof user_options.expires !== 'undefined' && user_options.expires) { + options.expiresIn = user_options.expires; + } + + payload.jti = crypto.randomBytes(12) + .toString('base64') + .substr(-8); + + return new Promise((resolve, reject) => { + jwt.sign(payload, private_key, options, (err, token) => { + if (err) { + reject(err); + } else { + token_data = payload; + resolve({ + token: token, + payload: payload + }); + } + + }); + }); + + }, + + /** + * @param {String} token + * @returns {Promise} + */ + load: function (token) { + return new Promise((resolve, reject) => { + try { + if (!token || token === null || token === 'null') { + reject('Empty token'); + } else { + jwt.verify(token, public_key, {ignoreExpiration: false, algorithms: [ALGO]}, (err, result) => { + if (err) { + + if (err.name === 'TokenExpiredError') { + reject(new error.AuthError('Token has expired', err)); + } else { + reject(err); + } + + } else { + token_data = result; + + // Hack: some tokens out in the wild have a scope of 'all' instead of 'user'. + // For 30 days at least, we need to replace 'all' with user. + if ((typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'all') !== -1)) { + //console.log('Warning! Replacing "all" scope with "user"'); + + token_data.scope = ['user']; + } + + resolve(token_data); + } + }); + } + } catch (err) { + reject(err); + } + }); + + }, + + /** + * Does the token have the specified scope? + * + * @param {String} scope + * @returns {Boolean} + */ + hasScope: function (scope) { + return typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, scope) !== -1; + }, + + /** + * @param {String} key + * @return {*} + */ + get: function (key) { + if (typeof token_data[key] !== 'undefined') { + return token_data[key]; + } + + return null; + }, + + /** + * @param {String} key + * @param {*} value + */ + set: function (key, value) { + token_data[key] = value; + }, + + /** + * @param [default_value] + * @returns {Integer} + */ + getUserId: default_value => { + let attrs = self.get('attrs'); + if (attrs && typeof attrs.id !== 'undefined' && attrs.id) { + return attrs.id; + } + + return default_value || 0; + } + }; + + return self; +}; diff --git a/src/backend/models/user.js b/src/backend/models/user.js new file mode 100644 index 00000000..3a2ab100 --- /dev/null +++ b/src/backend/models/user.js @@ -0,0 +1,52 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; +const UserPermission = require('./user_permission'); + +Model.knex(db); + +class User extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'User'; + } + + static get tableName () { + return 'user'; + } + + static get jsonAttributes () { + return ['roles']; + } + + static get relationMappings () { + return { + permissions: { + relation: Model.HasOneRelation, + modelClass: UserPermission, + join: { + from: 'user.id', + to: 'user_permission.user_id' + }, + modify: function (qb) { + qb.omit(['id', 'created_on', 'modified_on', 'user_id']); + } + } + }; + } + +} + +module.exports = User; diff --git a/src/backend/models/user_permission.js b/src/backend/models/user_permission.js new file mode 100644 index 00000000..5848a9ea --- /dev/null +++ b/src/backend/models/user_permission.js @@ -0,0 +1,30 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; + +Model.knex(db); + +class UserPermission extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'UserPermission'; + } + + static get tableName () { + return 'user_permission'; + } +} + +module.exports = UserPermission; diff --git a/src/backend/routes/api/audit-log.js b/src/backend/routes/api/audit-log.js new file mode 100644 index 00000000..36a0357f --- /dev/null +++ b/src/backend/routes/api/audit-log.js @@ -0,0 +1,54 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../lib/validator'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const internalAuditLog = require('../../internal/audit-log'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/audit-log + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/audit-log + * + * Retrieve all logs + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalAuditLog.getAll(res.locals.access, data.expand, data.query); + }) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/main.js b/src/backend/routes/api/main.js new file mode 100644 index 00000000..cbc352ed --- /dev/null +++ b/src/backend/routes/api/main.js @@ -0,0 +1,51 @@ +'use strict'; + +const express = require('express'); +const pjson = require('../../../../package.json'); +const error = require('../../lib/error'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * Health Check + * GET /api + */ +router.get('/', (req, res/*, next*/) => { + let version = pjson.version.split('-').shift().split('.'); + + res.status(200).send({ + status: 'OK', + version: { + major: parseInt(version.shift(), 10), + minor: parseInt(version.shift(), 10), + revision: parseInt(version.shift(), 10) + } + }); +}); + +router.use('/tokens', require('./tokens')); +router.use('/users', require('./users')); +router.use('/audit-log', require('./audit-log')); +router.use('/reports', require('./reports')); +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')); +router.use('/nginx/streams', require('./nginx/streams')); +router.use('/nginx/access-lists', require('./nginx/access_lists')); +router.use('/nginx/certificates', require('./nginx/certificates')); + +/** + * API 404 for all other routes + * + * ALL /api/* + */ +router.all(/(.+)/, function (req, res, next) { + req.params.page = req.params['0']; + next(new error.ItemNotFoundError(req.params.page)); +}); + +module.exports = router; diff --git a/src/backend/routes/api/nginx/access_lists.js b/src/backend/routes/api/nginx/access_lists.js new file mode 100644 index 00000000..79bce0ef --- /dev/null +++ b/src/backend/routes/api/nginx/access_lists.js @@ -0,0 +1,150 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../../lib/validator'); +const jwtdecode = require('../../../lib/express/jwt-decode'); +const internalAccessList = require('../../../internal/access-list'); +const apiValidator = require('../../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/nginx/access-lists + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/access-lists + * + * Retrieve all access-lists + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalAccessList.getAll(res.locals.access, data.expand, data.query); + }) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }) + + /** + * POST /api/nginx/access-lists + * + * Create a new access-list + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/access-lists#/links/1/schema'}, req.body) + .then(payload => { + return internalAccessList.create(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific access-list + * + * /api/nginx/access-lists/123 + */ +router + .route('/:list_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/access-lists/123 + * + * Retrieve a specific access-list + */ + .get((req, res, next) => { + validator({ + required: ['list_id'], + additionalProperties: false, + properties: { + list_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + list_id: req.params.list_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => { + return internalAccessList.get(res.locals.access, { + id: parseInt(data.list_id, 10), + expand: data.expand + }); + }) + .then(row => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/nginx/access-lists/123 + * + * Update and existing access-list + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/access-lists#/links/2/schema'}, req.body) + .then(payload => { + payload.id = parseInt(req.params.list_id, 10); + return internalAccessList.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/nginx/access-lists/123 + * + * Delete and existing access-list + */ + .delete((req, res, next) => { + internalAccessList.delete(res.locals.access, {id: parseInt(req.params.list_id, 10)}) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/nginx/certificates.js b/src/backend/routes/api/nginx/certificates.js new file mode 100644 index 00000000..73241b85 --- /dev/null +++ b/src/backend/routes/api/nginx/certificates.js @@ -0,0 +1,217 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../../lib/validator'); +const jwtdecode = require('../../../lib/express/jwt-decode'); +const internalCertificate = require('../../../internal/certificate'); +const apiValidator = require('../../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/nginx/certificates + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/certificates + * + * Retrieve all certificates + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalCertificate.getAll(res.locals.access, data.expand, data.query); + }) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }) + + /** + * POST /api/nginx/certificates + * + * Create a new certificate + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/certificates#/links/1/schema'}, req.body) + .then(payload => { + return internalCertificate.create(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific certificate + * + * /api/nginx/certificates/123 + */ +router + .route('/:certificate_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/certificates/123 + * + * Retrieve a specific certificate + */ + .get((req, res, next) => { + validator({ + required: ['certificate_id'], + additionalProperties: false, + properties: { + certificate_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + certificate_id: req.params.certificate_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => { + return internalCertificate.get(res.locals.access, { + id: parseInt(data.certificate_id, 10), + expand: data.expand + }); + }) + .then(row => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/nginx/certificates/123 + * + * Update and existing certificate + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/certificates#/links/2/schema'}, req.body) + .then(payload => { + payload.id = parseInt(req.params.certificate_id, 10); + return internalCertificate.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/nginx/certificates/123 + * + * Update and existing certificate + */ + .delete((req, res, next) => { + internalCertificate.delete(res.locals.access, {id: parseInt(req.params.certificate_id, 10)}) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +/** + * Upload Certs + * + * /api/nginx/certificates/123/upload + */ +router + .route('/:certificate_id/upload') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * POST /api/nginx/certificates/123/upload + * + * Upload certificates + */ + .post((req, res, next) => { + if (!req.files) { + res.status(400) + .send({error: 'No files were uploaded'}); + } else { + internalCertificate.upload(res.locals.access, { + id: parseInt(req.params.certificate_id, 10), + files: req.files + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + } + }); + +/** + * Validate Certs before saving + * + * /api/nginx/certificates/validate + */ +router + .route('/validate') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * POST /api/nginx/certificates/validate + * + * Validate certificates + */ + .post((req, res, next) => { + if (!req.files) { + res.status(400) + .send({error: 'No files were uploaded'}); + } else { + internalCertificate.validate({ + files: req.files + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + } + }); + +module.exports = router; diff --git a/src/backend/routes/api/nginx/dead_hosts.js b/src/backend/routes/api/nginx/dead_hosts.js new file mode 100644 index 00000000..01015302 --- /dev/null +++ b/src/backend/routes/api/nginx/dead_hosts.js @@ -0,0 +1,150 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../../lib/validator'); +const jwtdecode = require('../../../lib/express/jwt-decode'); +const internalDeadHost = require('../../../internal/dead-host'); +const apiValidator = require('../../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/nginx/dead-hosts + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/dead-hosts + * + * Retrieve all dead-hosts + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalDeadHost.getAll(res.locals.access, data.expand, data.query); + }) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }) + + /** + * POST /api/nginx/dead-hosts + * + * Create a new dead-host + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/dead-hosts#/links/1/schema'}, req.body) + .then(payload => { + return internalDeadHost.create(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific dead-host + * + * /api/nginx/dead-hosts/123 + */ +router + .route('/:host_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/dead-hosts/123 + * + * Retrieve a specific dead-host + */ + .get((req, res, next) => { + validator({ + required: ['host_id'], + additionalProperties: false, + properties: { + host_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + host_id: req.params.host_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => { + return internalDeadHost.get(res.locals.access, { + id: parseInt(data.host_id, 10), + expand: data.expand + }); + }) + .then(row => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/nginx/dead-hosts/123 + * + * Update and existing dead-host + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/dead-hosts#/links/2/schema'}, req.body) + .then(payload => { + payload.id = parseInt(req.params.host_id, 10); + return internalDeadHost.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/nginx/dead-hosts/123 + * + * Update and existing dead-host + */ + .delete((req, res, next) => { + internalDeadHost.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)}) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/nginx/proxy_hosts.js b/src/backend/routes/api/nginx/proxy_hosts.js new file mode 100644 index 00000000..ad69f003 --- /dev/null +++ b/src/backend/routes/api/nginx/proxy_hosts.js @@ -0,0 +1,150 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../../lib/validator'); +const jwtdecode = require('../../../lib/express/jwt-decode'); +const internalProxyHost = require('../../../internal/proxy-host'); +const apiValidator = require('../../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/nginx/proxy-hosts + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/proxy-hosts + * + * Retrieve all proxy-hosts + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalProxyHost.getAll(res.locals.access, data.expand, data.query); + }) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }) + + /** + * POST /api/nginx/proxy-hosts + * + * Create a new proxy-host + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/proxy-hosts#/links/1/schema'}, req.body) + .then(payload => { + return internalProxyHost.create(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific proxy-host + * + * /api/nginx/proxy-hosts/123 + */ +router + .route('/:host_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/proxy-hosts/123 + * + * Retrieve a specific proxy-host + */ + .get((req, res, next) => { + validator({ + required: ['host_id'], + additionalProperties: false, + properties: { + host_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + host_id: req.params.host_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => { + return internalProxyHost.get(res.locals.access, { + id: parseInt(data.host_id, 10), + expand: data.expand + }); + }) + .then(row => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/nginx/proxy-hosts/123 + * + * Update and existing proxy-host + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/proxy-hosts#/links/2/schema'}, req.body) + .then(payload => { + payload.id = parseInt(req.params.host_id, 10); + return internalProxyHost.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/nginx/proxy-hosts/123 + * + * Update and existing proxy-host + */ + .delete((req, res, next) => { + internalProxyHost.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)}) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/nginx/redirection_hosts.js b/src/backend/routes/api/nginx/redirection_hosts.js new file mode 100644 index 00000000..acd5a67e --- /dev/null +++ b/src/backend/routes/api/nginx/redirection_hosts.js @@ -0,0 +1,150 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../../lib/validator'); +const jwtdecode = require('../../../lib/express/jwt-decode'); +const internalRedirectionHost = require('../../../internal/redirection-host'); +const apiValidator = require('../../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/nginx/redirection-hosts + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/redirection-hosts + * + * Retrieve all redirection-hosts + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalRedirectionHost.getAll(res.locals.access, data.expand, data.query); + }) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }) + + /** + * POST /api/nginx/redirection-hosts + * + * Create a new redirection-host + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/redirection-hosts#/links/1/schema'}, req.body) + .then(payload => { + return internalRedirectionHost.create(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific redirection-host + * + * /api/nginx/redirection-hosts/123 + */ +router + .route('/:host_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/redirection-hosts/123 + * + * Retrieve a specific redirection-host + */ + .get((req, res, next) => { + validator({ + required: ['host_id'], + additionalProperties: false, + properties: { + host_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + host_id: req.params.host_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => { + return internalRedirectionHost.get(res.locals.access, { + id: parseInt(data.host_id, 10), + expand: data.expand + }); + }) + .then(row => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/nginx/redirection-hosts/123 + * + * Update and existing redirection-host + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/redirection-hosts#/links/2/schema'}, req.body) + .then(payload => { + payload.id = parseInt(req.params.host_id, 10); + return internalRedirectionHost.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/nginx/redirection-hosts/123 + * + * Update and existing redirection-host + */ + .delete((req, res, next) => { + internalRedirectionHost.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)}) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/nginx/streams.js b/src/backend/routes/api/nginx/streams.js new file mode 100644 index 00000000..80f4c9fd --- /dev/null +++ b/src/backend/routes/api/nginx/streams.js @@ -0,0 +1,150 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../../lib/validator'); +const jwtdecode = require('../../../lib/express/jwt-decode'); +const internalStream = require('../../../internal/stream'); +const apiValidator = require('../../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/nginx/streams + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/streams + * + * Retrieve all streams + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalStream.getAll(res.locals.access, data.expand, data.query); + }) + .then(rows => { + res.status(200) + .send(rows); + }) + .catch(next); + }) + + /** + * POST /api/nginx/streams + * + * Create a new stream + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/streams#/links/1/schema'}, req.body) + .then(payload => { + return internalStream.create(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific stream + * + * /api/nginx/streams/123 + */ +router + .route('/:stream_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/nginx/streams/123 + * + * Retrieve a specific stream + */ + .get((req, res, next) => { + validator({ + required: ['stream_id'], + additionalProperties: false, + properties: { + stream_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + stream_id: req.params.stream_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => { + return internalStream.get(res.locals.access, { + id: parseInt(data.stream_id, 10), + expand: data.expand + }); + }) + .then(row => { + res.status(200) + .send(row); + }) + .catch(next); + }) + + /** + * PUT /api/nginx/streams/123 + * + * Update and existing stream + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/streams#/links/2/schema'}, req.body) + .then(payload => { + payload.id = parseInt(req.params.stream_id, 10); + return internalStream.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/nginx/streams/123 + * + * Update and existing stream + */ + .delete((req, res, next) => { + internalStream.delete(res.locals.access, {id: parseInt(req.params.stream_id, 10)}) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/reports.js b/src/backend/routes/api/reports.js new file mode 100644 index 00000000..d14d8bd3 --- /dev/null +++ b/src/backend/routes/api/reports.js @@ -0,0 +1,31 @@ +'use strict'; + +const express = require('express'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const internalReport = require('../../internal/report'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +router + .route('/hosts') + .options((req, res) => { + res.sendStatus(204); + }) + + /** + * GET /reports/hosts + */ + .get(jwtdecode(), (req, res, next) => { + internalReport.getHostsReport(res.locals.access) + .then(data => { + res.status(200) + .send(data); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/tokens.js b/src/backend/routes/api/tokens.js new file mode 100644 index 00000000..54207907 --- /dev/null +++ b/src/backend/routes/api/tokens.js @@ -0,0 +1,56 @@ +'use strict'; + +const express = require('express'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const internalToken = require('../../internal/token'); +const apiValidator = require('../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + + /** + * GET /tokens + * + * Get a new Token, given they already have a token they want to refresh + * We also piggy back on to this method, allowing admins to get tokens + * for services like Job board and Worker. + */ + .get(jwtdecode(), (req, res, next) => { + internalToken.getFreshToken(res.locals.access, { + expiry: (typeof req.query.expiry !== 'undefined' ? req.query.expiry : null), + scope: (typeof req.query.scope !== 'undefined' ? req.query.scope : null) + }) + .then(data => { + res.status(200) + .send(data); + }) + .catch(next); + }) + + /** + * POST /tokens + * + * Create a new Token + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/tokens#/links/0/schema'}, req.body) + .then(payload => { + return internalToken.getTokenFromEmail(payload); + }) + .then(data => { + res.status(200) + .send(data); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/users.js b/src/backend/routes/api/users.js new file mode 100644 index 00000000..b7ab5e62 --- /dev/null +++ b/src/backend/routes/api/users.js @@ -0,0 +1,241 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../lib/validator'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const userIdFromMe = require('../../lib/express/user-id-from-me'); +const internalUser = require('../../internal/user'); +const apiValidator = require('../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/users + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/users + * + * Retrieve all users + */ + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => { + return internalUser.getAll(res.locals.access, data.expand, data.query); + }) + .then(users => { + res.status(200) + .send(users); + }) + .catch(next); + }) + + /** + * POST /api/users + * + * Create a new User + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/1/schema'}, req.body) + .then(payload => { + return internalUser.create(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user + * + * /api/users/123 + */ +router + .route('/:user_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(userIdFromMe) + + /** + * GET /users/123 or /users/me + * + * Retrieve a specific user + */ + .get((req, res, next) => { + validator({ + required: ['user_id'], + additionalProperties: false, + properties: { + user_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + user_id: req.params.user_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => { + return internalUser.get(res.locals.access, { + id: data.user_id, + expand: data.expand, + omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id) + }); + }) + .then(user => { + res.status(200) + .send(user); + }) + .catch(next); + }) + + /** + * PUT /api/users/123 + * + * Update and existing user + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/2/schema'}, req.body) + .then(payload => { + payload.id = req.params.user_id; + return internalUser.update(res.locals.access, payload); + }) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/users/123 + * + * Update and existing user + */ + .delete((req, res, next) => { + internalUser.delete(res.locals.access, {id: req.params.user_id}) + .then(result => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user auth + * + * /api/users/123/auth + */ +router + .route('/:user_id/auth') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(userIdFromMe) + + /** + * PUT /api/users/123/auth + * + * Update password for a user + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/4/schema'}, req.body) + .then(payload => { + payload.id = req.params.user_id; + return internalUser.setPassword(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user permissions + * + * /api/users/123/permissions + */ +router + .route('/:user_id/permissions') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(userIdFromMe) + + /** + * PUT /api/users/123/permissions + * + * Set some or all permissions for a user + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/5/schema'}, req.body) + .then(payload => { + payload.id = req.params.user_id; + return internalUser.setPermissions(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user login as + * + * /api/users/123/login + */ +router + .route('/:user_id/login') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * POST /api/users/123/login + * + * Log in as a user + */ + .post((req, res, next) => { + internalUser.loginAs(res.locals.access, {id: parseInt(req.params.user_id, 10)}) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/main.js b/src/backend/routes/main.js new file mode 100644 index 00000000..4d3232b9 --- /dev/null +++ b/src/backend/routes/main.js @@ -0,0 +1,44 @@ +'use strict'; + +const express = require('express'); +const fs = require('fs'); +const PACKAGE = require('../../../package.json'); + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * GET /login + */ +router.get('/login', function (req, res, next) { + res.render('login', { + version: PACKAGE.version + }); +}); + +/** + * GET .* + */ +router.get(/(.*)/, function (req, res, next) { + req.params.page = req.params['0']; + if (req.params.page === '/') { + res.render('index', { + version: PACKAGE.version + }); + } else { + fs.readFile('dist' + req.params.page, 'utf8', function (err, data) { + if (err) { + res.render('index', { + version: PACKAGE.version + }); + } else { + res.contentType('text/html').end(data); + } + }); + } +}); + +module.exports = router; diff --git a/src/backend/schema/definitions.json b/src/backend/schema/definitions.json new file mode 100644 index 00000000..272b4c43 --- /dev/null +++ b/src/backend/schema/definitions.json @@ -0,0 +1,200 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "definitions", + "definitions": { + "id": { + "description": "Unique identifier", + "example": 123456, + "readOnly": true, + "type": "integer", + "minimum": 1 + }, + "token": { + "type": "string", + "minLength": 10 + }, + "expand": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "sort": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "field", + "dir" + ], + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "dir": { + "type": "string", + "pattern": "^(asc|desc)$" + } + } + } + }, + "query": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + ] + }, + "criteria": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object" + } + ] + }, + "fields": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "omit": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "created_on": { + "description": "Date and time of creation", + "format": "date-time", + "readOnly": true, + "type": "string" + }, + "modified_on": { + "description": "Date and time of last update", + "format": "date-time", + "readOnly": true, + "type": "string" + }, + "user_id": { + "description": "User ID", + "example": 1234, + "type": "integer", + "minimum": 1 + }, + "certificate_id": { + "description": "Certificate ID", + "example": 1234, + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^new$" + } + ] + }, + "access_list_id": { + "description": "Access List ID", + "example": 1234, + "type": "integer", + "minimum": 0 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "email": { + "description": "Email Address", + "example": "john@example.com", + "format": "email", + "type": "string", + "minLength": 8, + "maxLength": 100 + }, + "password": { + "description": "Password", + "type": "string", + "minLength": 8, + "maxLength": 255 + }, + "domain_name": { + "description": "Domain Name", + "example": "jc21.com", + "type": "string", + "pattern": "^(?:[^.*]+\\.?)+[^.]$" + }, + "domain_names": { + "description": "Domain Names separated by a comma", + "example": "*.jc21.com,blog.jc21.com", + "type": "array", + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(?:\\*\\.)?(?:[^.*]+\\.?)+[^.]$" + } + }, + "ssl_enabled": { + "description": "Is SSL Enabled", + "example": true, + "type": "boolean" + }, + "ssl_forced": { + "description": "Is SSL Forced", + "example": false, + "type": "boolean" + }, + "ssl_provider": { + "type": "string", + "pattern": "^(letsencrypt|other)$" + }, + "block_exploits": { + "description": "Should we block common exploits", + "example": true, + "type": "boolean" + }, + "caching_enabled": { + "description": "Should we cache assets", + "example": true, + "type": "boolean" + } + } +} diff --git a/src/backend/schema/endpoints/access-lists.json b/src/backend/schema/endpoints/access-lists.json new file mode 100644 index 00000000..da90a050 --- /dev/null +++ b/src/backend/schema/endpoints/access-lists.json @@ -0,0 +1,168 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/access-lists", + "title": "Access Lists", + "description": "Endpoints relating to Access Lists", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "name": { + "type": "string", + "description": "Name of the Access List" + }, + "meta": { + "type": "object" + } + }, + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "name": { + "$ref": "#/definitions/name" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Access Lists", + "href": "/nginx/access-lists", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new Access List", + "href": "/nginx/access-list", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "$ref": "#/definitions/name" + }, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "username": { + "type": "string", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 1 + } + } + } + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing Access List", + "href": "/nginx/access-list/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/definitions/name" + }, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "username": { + "type": "string", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 0 + } + } + } + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing Access List", + "href": "/nginx/access-list/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + } + ] +} diff --git a/src/backend/schema/endpoints/certificates.json b/src/backend/schema/endpoints/certificates.json new file mode 100644 index 00000000..d3294f86 --- /dev/null +++ b/src/backend/schema/endpoints/certificates.json @@ -0,0 +1,144 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/certificates", + "title": "Certificates", + "description": "Endpoints relating to Certificates", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "provider": { + "$ref": "../definitions.json#/definitions/ssl_provider" + }, + "nice_name": { + "type": "string", + "description": "Nice Name for the custom certificate" + }, + "domain_names": { + "$ref": "../definitions.json#/definitions/domain_names" + }, + "expires_on": { + "description": "Date and time of expiration", + "format": "date-time", + "readOnly": true, + "type": "string" + }, + "meta": { + "type": "object", + "additionalProperties": false, + "properties": { + "letsencrypt_email": { + "type": "string", + "format": "email" + }, + "letsencrypt_agree": { + "type": "boolean" + } + } + } + }, + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "provider": { + "$ref": "#/definitions/provider" + }, + "nice_name": { + "$ref": "#/definitions/nice_name" + }, + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "expires_on": { + "$ref": "#/definitions/expires_on" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Certificates", + "href": "/nginx/certificates", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new Certificate", + "href": "/nginx/certificates", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider" + ], + "properties": { + "provider": { + "$ref": "#/definitions/provider" + }, + "nice_name": { + "$ref": "#/definitions/nice_name" + }, + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing Certificate", + "href": "/nginx/certificates/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + } + ] +} diff --git a/src/backend/schema/endpoints/dead-hosts.json b/src/backend/schema/endpoints/dead-hosts.json new file mode 100644 index 00000000..34d38e7d --- /dev/null +++ b/src/backend/schema/endpoints/dead-hosts.json @@ -0,0 +1,170 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/dead-hosts", + "title": "404 Hosts", + "description": "Endpoints relating to 404 Hosts", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "domain_names": { + "$ref": "../definitions.json#/definitions/domain_names" + }, + "certificate_id": { + "$ref": "../definitions.json#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "../definitions.json#/definitions/ssl_forced" + }, + "advanced_config": { + "type": "string" + }, + "meta": { + "type": "object" + } + }, + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of 404 Hosts", + "href": "/nginx/dead-hosts", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new 404 Host", + "href": "/nginx/dead-hosts", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "domain_names" + ], + "properties": { + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing 404 Host", + "href": "/nginx/dead-hosts/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing 404 Host", + "href": "/nginx/dead-hosts/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + } + ] +} diff --git a/src/backend/schema/endpoints/proxy-hosts.json b/src/backend/schema/endpoints/proxy-hosts.json new file mode 100644 index 00000000..bde76cd7 --- /dev/null +++ b/src/backend/schema/endpoints/proxy-hosts.json @@ -0,0 +1,235 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/proxy-hosts", + "title": "Proxy Hosts", + "description": "Endpoints relating to Proxy Hosts", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "domain_names": { + "$ref": "../definitions.json#/definitions/domain_names" + }, + "forward_ip": { + "type": "string", + "format": "ipv4" + }, + "forward_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "certificate_id": { + "$ref": "../definitions.json#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "../definitions.json#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "../definitions.json#/definitions/block_exploits" + }, + "caching_enabled": { + "$ref": "../definitions.json#/definitions/caching_enabled" + }, + "access_list_id": { + "$ref": "../definitions.json#/definitions/access_list_id" + }, + "advanced_config": { + "type": "string" + }, + "meta": { + "type": "object" + } + }, + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "forward_ip": { + "$ref": "#/definitions/forward_ip" + }, + "forward_port": { + "$ref": "#/definitions/forward_port" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "#/definitions/block_exploits" + }, + "caching_enabled": { + "$ref": "#/definitions/caching_enabled" + }, + "access_list_id": { + "$ref": "#/definitions/access_list_id" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Proxy Hosts", + "href": "/nginx/proxy-hosts", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new Proxy Host", + "href": "/nginx/proxy-hosts", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "domain_names", + "forward_ip", + "forward_port" + ], + "properties": { + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "forward_ip": { + "$ref": "#/definitions/forward_ip" + }, + "forward_port": { + "$ref": "#/definitions/forward_port" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "#/definitions/block_exploits" + }, + "caching_enabled": { + "$ref": "#/definitions/caching_enabled" + }, + "access_list_id": { + "$ref": "#/definitions/access_list_id" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing Proxy Host", + "href": "/nginx/proxy-hosts/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "forward_ip": { + "$ref": "#/definitions/forward_ip" + }, + "forward_port": { + "$ref": "#/definitions/forward_port" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "#/definitions/block_exploits" + }, + "caching_enabled": { + "$ref": "#/definitions/caching_enabled" + }, + "access_list_id": { + "$ref": "#/definitions/access_list_id" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing Proxy Host", + "href": "/nginx/proxy-hosts/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + } + ] +} diff --git a/src/backend/schema/endpoints/redirection-hosts.json b/src/backend/schema/endpoints/redirection-hosts.json new file mode 100644 index 00000000..e843e609 --- /dev/null +++ b/src/backend/schema/endpoints/redirection-hosts.json @@ -0,0 +1,209 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/redirection-hosts", + "title": "Redirection Hosts", + "description": "Endpoints relating to Redirection Hosts", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "domain_names": { + "$ref": "../definitions.json#/definitions/domain_names" + }, + "forward_domain_name": { + "$ref": "../definitions.json#/definitions/domain_name" + }, + "preserve_path": { + "description": "Should the path be preserved", + "example": true, + "type": "boolean" + }, + "certificate_id": { + "$ref": "../definitions.json#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "../definitions.json#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "../definitions.json#/definitions/block_exploits" + }, + "advanced_config": { + "type": "string" + }, + "meta": { + "type": "object" + } + }, + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "forward_domain_name": { + "$ref": "#/definitions/forward_domain_name" + }, + "preserve_path": { + "$ref": "#/definitions/preserve_path" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "#/definitions/block_exploits" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Redirection Hosts", + "href": "/nginx/redirection-hosts", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new Redirection Host", + "href": "/nginx/redirection-hosts", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "domain_names", + "forward_domain_name" + ], + "properties": { + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "forward_domain_name": { + "$ref": "#/definitions/forward_domain_name" + }, + "preserve_path": { + "$ref": "#/definitions/preserve_path" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "#/definitions/block_exploits" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing Redirection Host", + "href": "/nginx/redirection-hosts/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "domain_names": { + "$ref": "#/definitions/domain_names" + }, + "forward_domain_name": { + "$ref": "#/definitions/forward_domain_name" + }, + "preserve_path": { + "$ref": "#/definitions/preserve_path" + }, + "certificate_id": { + "$ref": "#/definitions/certificate_id" + }, + "ssl_forced": { + "$ref": "#/definitions/ssl_forced" + }, + "block_exploits": { + "$ref": "#/definitions/block_exploits" + }, + "advanced_config": { + "$ref": "#/definitions/advanced_config" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing Redirection Host", + "href": "/nginx/redirection-hosts/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + } + ] +} diff --git a/src/backend/schema/endpoints/streams.json b/src/backend/schema/endpoints/streams.json new file mode 100644 index 00000000..e8535a31 --- /dev/null +++ b/src/backend/schema/endpoints/streams.json @@ -0,0 +1,189 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/streams", + "title": "Streams", + "description": "Endpoints relating to Streams", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "incoming_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "forward_ip": { + "type": "string", + "format": "ipv4" + }, + "forwarding_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "tcp_forwarding": { + "type": "boolean" + }, + "udp_forwarding": { + "type": "boolean" + }, + "meta": { + "type": "object" + } + }, + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "incoming_port": { + "$ref": "#/definitions/incoming_port" + }, + "forward_ip": { + "$ref": "#/definitions/forward_ip" + }, + "forwarding_port": { + "$ref": "#/definitions/forwarding_port" + }, + "tcp_forwarding": { + "$ref": "#/definitions/tcp_forwarding" + }, + "udp_forwarding": { + "$ref": "#/definitions/udp_forwarding" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Steams", + "href": "/nginx/streams", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new Stream", + "href": "/nginx/streams", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "incoming_port", + "forward_ip", + "forwarding_port" + ], + "properties": { + "incoming_port": { + "$ref": "#/definitions/incoming_port" + }, + "forward_ip": { + "$ref": "#/definitions/forward_ip" + }, + "forwarding_port": { + "$ref": "#/definitions/forwarding_port" + }, + "tcp_forwarding": { + "$ref": "#/definitions/tcp_forwarding" + }, + "udp_forwarding": { + "$ref": "#/definitions/udp_forwarding" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing Stream", + "href": "/nginx/streams/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "incoming_port": { + "$ref": "#/definitions/incoming_port" + }, + "forward_ip": { + "$ref": "#/definitions/forward_ip" + }, + "forwarding_port": { + "$ref": "#/definitions/forwarding_port" + }, + "tcp_forwarding": { + "$ref": "#/definitions/tcp_forwarding" + }, + "udp_forwarding": { + "$ref": "#/definitions/udp_forwarding" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing Stream", + "href": "/nginx/streams/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + } + ] +} diff --git a/src/backend/schema/endpoints/tokens.json b/src/backend/schema/endpoints/tokens.json new file mode 100644 index 00000000..920af63f --- /dev/null +++ b/src/backend/schema/endpoints/tokens.json @@ -0,0 +1,100 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/tokens", + "title": "Token", + "description": "Tokens are required to authenticate against the API", + "stability": "stable", + "type": "object", + "definitions": { + "identity": { + "description": "Email Address or other 3rd party providers identifier", + "example": "john@example.com", + "type": "string" + }, + "secret": { + "description": "A password or key", + "example": "correct horse battery staple", + "type": "string" + }, + "token": { + "description": "JWT", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", + "type": "string" + }, + "expires": { + "description": "Token expiry time", + "format": "date-time", + "type": "string" + }, + "scope": { + "description": "Scope of the Token, defaults to 'user'", + "example": "user", + "type": "string" + } + }, + "links": [ + { + "title": "Create", + "description": "Creates a new token.", + "href": "/tokens", + "access": "public", + "method": "POST", + "rel": "create", + "schema": { + "type": "object", + "required": [ + "identity", + "secret" + ], + "properties": { + "identity": { + "$ref": "#/definitions/identity" + }, + "secret": { + "$ref": "#/definitions/secret" + }, + "scope": { + "$ref": "#/definitions/scope" + } + } + }, + "targetSchema": { + "type": "object", + "properties": { + "token": { + "$ref": "#/definitions/token" + }, + "expires": { + "$ref": "#/definitions/expires" + } + } + } + }, + { + "title": "Refresh", + "description": "Returns a new token.", + "href": "/tokens", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": {}, + "targetSchema": { + "type": "object", + "properties": { + "token": { + "$ref": "#/definitions/token" + }, + "expires": { + "$ref": "#/definitions/expires" + }, + "scope": { + "$ref": "#/definitions/scope" + } + } + } + } + ] +} diff --git a/src/backend/schema/endpoints/users.json b/src/backend/schema/endpoints/users.json new file mode 100644 index 00000000..42f44eac --- /dev/null +++ b/src/backend/schema/endpoints/users.json @@ -0,0 +1,287 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/users", + "title": "Users", + "description": "Endpoints relating to Users", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "name": { + "description": "Name", + "example": "Jamie Curnow", + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "nickname": { + "description": "Nickname", + "example": "Jamie", + "type": "string", + "minLength": 2, + "maxLength": 50 + }, + "email": { + "$ref": "../definitions.json#/definitions/email" + }, + "avatar": { + "description": "Avatar", + "example": "http://somewhere.jpg", + "type": "string", + "minLength": 2, + "maxLength": 150, + "readOnly": true + }, + "roles": { + "description": "Roles", + "example": [ + "admin" + ], + "type": "array" + }, + "is_disabled": { + "description": "Is Disabled", + "example": false, + "type": "boolean" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Users", + "href": "/users", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new User", + "href": "/users", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "required": [ + "name", + "nickname", + "email" + ], + "properties": { + "name": { + "$ref": "#/definitions/name" + }, + "nickname": { + "$ref": "#/definitions/nickname" + }, + "email": { + "$ref": "#/definitions/email" + }, + "roles": { + "$ref": "#/definitions/roles" + }, + "is_disabled": { + "$ref": "#/definitions/is_disabled" + }, + "auth": { + "type": "object", + "description": "Auth Credentials", + "example": { + "type": "password", + "secret": "bigredhorsebanana" + } + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing User", + "href": "/users/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/name" + }, + "nickname": { + "$ref": "#/definitions/nickname" + }, + "email": { + "$ref": "#/definitions/email" + }, + "roles": { + "$ref": "#/definitions/roles" + }, + "is_disabled": { + "$ref": "#/definitions/is_disabled" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing User", + "href": "/users/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + }, + { + "title": "Set Password", + "description": "Sets a password for an existing User", + "href": "/users/{definitions.identity.example}/auth", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "required": [ + "type", + "secret" + ], + "properties": { + "type": { + "type": "string", + "pattern": "^password$" + }, + "current": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "secret": { + "type": "string", + "minLength": 8, + "maxLength": 64 + } + } + }, + "targetSchema": { + "type": "boolean" + } + }, + { + "title": "Set Permissions", + "description": "Sets Permissions for a User", + "href": "/users/{definitions.identity.example}/permissions", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "pattern": "^(all|user)$" + }, + "access_lists": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "dead_hosts": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "proxy_hosts": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "redirection_hosts": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "streams": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "certificates": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + } + } + }, + "targetSchema": { + "type": "boolean" + } + } + ], + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "name": { + "$ref": "#/definitions/name" + }, + "nickname": { + "$ref": "#/definitions/nickname" + }, + "email": { + "$ref": "#/definitions/email" + }, + "avatar": { + "$ref": "#/definitions/avatar" + }, + "roles": { + "$ref": "#/definitions/roles" + }, + "is_disabled": { + "$ref": "#/definitions/is_disabled" + } + } +} diff --git a/src/backend/schema/examples.json b/src/backend/schema/examples.json new file mode 100644 index 00000000..37bc6c4d --- /dev/null +++ b/src/backend/schema/examples.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "examples", + "type": "object", + "definitions": { + "name": { + "description": "Name", + "example": "John Smith", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "auth_header": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", + "X-API-Version": "next" + }, + "token": { + "type": "string", + "description": "JWT", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk" + } + } +} diff --git a/src/backend/schema/index.json b/src/backend/schema/index.json new file mode 100644 index 00000000..b61509bd --- /dev/null +++ b/src/backend/schema/index.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "root", + "title": "Nginx Proxy Manager REST API", + "description": "This is the Nginx Proxy Manager REST API", + "version": "2.0.0", + "links": [ + { + "href": "http://npm.example.com/api", + "rel": "self" + } + ], + "properties": { + "tokens": { + "$ref": "endpoints/tokens.json" + }, + "users": { + "$ref": "endpoints/users.json" + }, + "proxy-hosts": { + "$ref": "endpoints/proxy-hosts.json" + }, + "redirection-hosts": { + "$ref": "endpoints/redirection-hosts.json" + }, + "dead-hosts": { + "$ref": "endpoints/dead-hosts.json" + }, + "streams": { + "$ref": "endpoints/streams.json" + }, + "certificates": { + "$ref": "endpoints/certificates.json" + }, + "access-lists": { + "$ref": "endpoints/access-lists.json" + } + } +} diff --git a/src/backend/setup.js b/src/backend/setup.js new file mode 100644 index 00000000..5015cbc5 --- /dev/null +++ b/src/backend/setup.js @@ -0,0 +1,102 @@ +'use strict'; + +const fs = require('fs'); +const NodeRSA = require('node-rsa'); +const config = require('config'); +const logger = require('./logger').global; +const userModel = require('./models/user'); +const userPermissionModel = require('./models/user_permission'); +const authModel = require('./models/auth'); + +module.exports = function () { + return new Promise((resolve, reject) => { + // Now go and check if the jwt gpg keys have been created and if not, create them + if (!config.has('jwt') || !config.has('jwt.key') || !config.has('jwt.pub')) { + logger.info('Creating a new JWT key pair...'); + + // jwt keys are not configured properly + const filename = config.util.getEnv('NODE_CONFIG_DIR') + '/' + (config.util.getEnv('NODE_ENV') || 'default') + '.json'; + let config_data = {}; + + try { + config_data = require(filename); + } catch (err) { + // do nothing + } + + // Now create the keys and save them in the config. + let key = new NodeRSA({b: 2048}); + key.generateKeyPair(); + + config_data.jwt = { + key: key.exportKey('private').toString(), + pub: key.exportKey('public').toString() + }; + + // Write config + fs.writeFile(filename, JSON.stringify(config_data, null, 2), (err) => { + if (err) { + logger.error('Could not write JWT key pair to config file: ' + filename); + reject(err); + } else { + logger.info('Wrote JWT key pair to config file: ' + filename); + config.util.loadFileConfigs(); + resolve(); + } + }); + } else { + // JWT key pair exists + resolve(); + } + }) + .then(() => { + return userModel + .query() + .select(userModel.raw('COUNT(`id`) as `count`')) + .where('is_deleted', 0) + .first('count') + .then(row => { + if (!row.count) { + // Create a new user and set password + logger.info('Creating a new user: admin@example.com with password: changeme'); + + let data = { + is_deleted: 0, + email: 'admin@example.com', + name: 'Administrator', + nickname: 'Admin', + avatar: '', + roles: ['admin'] + }; + + return userModel + .query() + .insertAndFetch(data) + .then(user => { + return authModel + .query() + .insert({ + user_id: user.id, + type: 'password', + secret: 'changeme', + meta: {} + }) + .then(() => { + return userPermissionModel + .query() + .insert({ + user_id: user.id, + visibility: 'all', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + dead_hosts: 'manage', + streams: 'manage', + access_lists: 'manage', + certificates: 'manage' + }); + }); + }); + } + }); + }); +}; diff --git a/src/backend/templates/_assets.conf b/src/backend/templates/_assets.conf new file mode 100644 index 00000000..dcb183c5 --- /dev/null +++ b/src/backend/templates/_assets.conf @@ -0,0 +1,4 @@ +{% if caching_enabled == 1 or caching_enabled == true -%} + # Asset Caching + include conf.d/include/assets.conf; +{% endif %} \ No newline at end of file diff --git a/src/backend/templates/_certificates.conf b/src/backend/templates/_certificates.conf new file mode 100644 index 00000000..06ca7bb8 --- /dev/null +++ b/src/backend/templates/_certificates.conf @@ -0,0 +1,14 @@ +{% if certificate and certificate_id > 0 -%} +{% if certificate.provider == "letsencrypt" %} + # Let's Encrypt SSL + include conf.d/include/letsencrypt-acme-challenge.conf; + include conf.d/include/ssl-ciphers.conf; + ssl_certificate /etc/letsencrypt/live/npm-{{ certificate_id }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/npm-{{ certificate_id }}/privkey.pem; +{% else %} + # Custom SSL + ssl_certificate /data/custom_ssl/npm-{{ certificate_id }}/fullchain.pem; + ssl_certificate_key /data/custom_ssl/npm-{{ certificate_id }}/privkey.pem; +{% endif %} +{% endif %} + diff --git a/src/backend/templates/_exploits.conf b/src/backend/templates/_exploits.conf new file mode 100644 index 00000000..002970d5 --- /dev/null +++ b/src/backend/templates/_exploits.conf @@ -0,0 +1,4 @@ +{% if block_exploits == 1 or block_exploits == true %} + # Block Exploits + include conf.d/include/block-exploits.conf; +{% endif %} \ No newline at end of file diff --git a/src/backend/templates/_forced_ssl.conf b/src/backend/templates/_forced_ssl.conf new file mode 100644 index 00000000..7fade20c --- /dev/null +++ b/src/backend/templates/_forced_ssl.conf @@ -0,0 +1,6 @@ +{% if certificate and certificate_id > 0 -%} +{% if ssl_forced == 1 or ssl_forced == true %} + # Force SSL + include conf.d/include/force-ssl.conf; +{% endif %} +{% endif %} \ No newline at end of file diff --git a/src/backend/templates/_header_comment.conf b/src/backend/templates/_header_comment.conf new file mode 100644 index 00000000..8f996d34 --- /dev/null +++ b/src/backend/templates/_header_comment.conf @@ -0,0 +1,3 @@ +# ------------------------------------------------------------ +# {{ domain_names | join: ", " }} +# ------------------------------------------------------------ \ No newline at end of file diff --git a/src/backend/templates/_listen.conf b/src/backend/templates/_listen.conf new file mode 100644 index 00000000..067b2dd4 --- /dev/null +++ b/src/backend/templates/_listen.conf @@ -0,0 +1,5 @@ + listen 80; +{% if certificate -%} + listen 443 ssl; +{% endif %} + server_name {{ domain_names | join: " " }}; \ No newline at end of file diff --git a/src/backend/templates/dead_host.conf b/src/backend/templates/dead_host.conf new file mode 100644 index 00000000..8b807958 --- /dev/null +++ b/src/backend/templates/dead_host.conf @@ -0,0 +1,12 @@ +{% include "_header_comment.conf" %} + +server { +{% include "_listen.conf" %} +{% include "_certificates.conf" %} + + access_log /data/logs/dead_host-{{ id }}.log standard; + +{{ advanced_config }} + + return 404; +} diff --git a/src/backend/templates/letsencrypt-request.conf b/src/backend/templates/letsencrypt-request.conf new file mode 100644 index 00000000..2adcdb32 --- /dev/null +++ b/src/backend/templates/letsencrypt-request.conf @@ -0,0 +1,14 @@ +{% include "_header_comment.conf" %} + +server { + listen 80; + server_name {{ domain_names | join: " " }}; + + access_log /data/logs/letsencrypt-requests.log standard; + + include conf.d/include/letsencrypt-acme-challenge.conf; + + location / { + return 404; + } +} diff --git a/src/backend/templates/proxy_host.conf b/src/backend/templates/proxy_host.conf new file mode 100644 index 00000000..17fc87c7 --- /dev/null +++ b/src/backend/templates/proxy_host.conf @@ -0,0 +1,28 @@ +{% include "_header_comment.conf" %} + +server { + set $server {{ forward_ip }}; + set $port {{ forward_port }}; + +{% include "_listen.conf" %} +{% include "_certificates.conf" %} +{% include "_assets.conf" %} +{% include "_exploits.conf" %} + + access_log /data/logs/proxy_host-{{ id }}.log proxy; + +{{ advanced_config }} + + location / { + {%- if access_list_id > 0 -%} + # Access List + auth_basic "Authorization required"; + auth_basic_user_file /data/access/{{ access_list_id }}; + {%- endif %} + +{% include "_forced_ssl.conf" %} + + # Proxy! + include conf.d/include/proxy.conf; + } +} diff --git a/src/backend/templates/redirection_host.conf b/src/backend/templates/redirection_host.conf new file mode 100644 index 00000000..9d51a920 --- /dev/null +++ b/src/backend/templates/redirection_host.conf @@ -0,0 +1,24 @@ +{% include "_header_comment.conf" %} + +server { +{% include "_listen.conf" %} +{% include "_certificates.conf" %} +{% include "_assets.conf" %} +{% include "_exploits.conf" %} + + access_log /data/logs/redirection_host-{{ id }}.log standard; + +{{ advanced_config }} + + # TODO: Preserve Path Option + + location / { +{% include "_forced_ssl.conf" %} + + {% if preserve_path == 1 or preserve_path == true %} + return 301 $scheme://{{ forward_domain_name }}$request_uri; + {% else %} + return 301 $scheme://{{ forward_domain_name }}; + {% endif %} + } +} diff --git a/src/backend/templates/stream.conf b/src/backend/templates/stream.conf new file mode 100644 index 00000000..9bcd76de --- /dev/null +++ b/src/backend/templates/stream.conf @@ -0,0 +1,16 @@ +# ------------------------------------------------------------ +# {{ incoming_port }} TCP: {{ tcp_forwarding }} UDP: {{ udp_forwarding }} +# ------------------------------------------------------------ + +{% if tcp_forwarding == 1 or tcp_forwarding == true -%} +server { + listen {{ incoming_port }}; + proxy_pass {{ forward_ip }}:{{ forwarding_port }}; +} +{% endif %} +{% if udp_forwarding == 1 or udp_forwarding == true %} +server { + listen {{ incoming_port }} udp; + proxy_pass {{ forward_ip }}:{{ forwarding_port }}; +} +{% endif %} diff --git a/src/backend/views/index.ejs b/src/backend/views/index.ejs new file mode 100644 index 00000000..ce58fd44 --- /dev/null +++ b/src/backend/views/index.ejs @@ -0,0 +1,9 @@ +<% var title = 'Nginx Proxy Manager' %> +<%- include partials/header.ejs %> + +
+ +
+ + +<%- include partials/footer.ejs %> diff --git a/src/backend/views/login.ejs b/src/backend/views/login.ejs new file mode 100644 index 00000000..d3bec119 --- /dev/null +++ b/src/backend/views/login.ejs @@ -0,0 +1,9 @@ +<% var title = 'Login – Nginx Proxy Manager' %> +<%- include partials/header.ejs %> + +
+ +
+ + +<%- include partials/footer.ejs %> diff --git a/src/backend/views/partials/footer.ejs b/src/backend/views/partials/footer.ejs new file mode 100644 index 00000000..2ab5c0d1 --- /dev/null +++ b/src/backend/views/partials/footer.ejs @@ -0,0 +1,2 @@ + + diff --git a/src/backend/views/partials/header.ejs b/src/backend/views/partials/header.ejs new file mode 100644 index 00000000..cef92e15 --- /dev/null +++ b/src/backend/views/partials/header.ejs @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + <%- title %> + + + + + + + + + + + + + + + diff --git a/src/frontend/app-images/default-avatar.jpg b/src/frontend/app-images/default-avatar.jpg new file mode 100644 index 00000000..1a0e5076 Binary files /dev/null and b/src/frontend/app-images/default-avatar.jpg differ diff --git a/manager/src/frontend/images/favicon/android-chrome-192x192.png b/src/frontend/app-images/favicons/android-chrome-192x192.png similarity index 100% rename from manager/src/frontend/images/favicon/android-chrome-192x192.png rename to src/frontend/app-images/favicons/android-chrome-192x192.png diff --git a/manager/src/frontend/images/favicon/android-chrome-512x512.png b/src/frontend/app-images/favicons/android-chrome-512x512.png similarity index 100% rename from manager/src/frontend/images/favicon/android-chrome-512x512.png rename to src/frontend/app-images/favicons/android-chrome-512x512.png diff --git a/manager/src/frontend/images/favicon/apple-touch-icon.png b/src/frontend/app-images/favicons/apple-touch-icon.png similarity index 100% rename from manager/src/frontend/images/favicon/apple-touch-icon.png rename to src/frontend/app-images/favicons/apple-touch-icon.png diff --git a/manager/src/frontend/images/favicon/browserconfig.xml b/src/frontend/app-images/favicons/browserconfig.xml similarity index 100% rename from manager/src/frontend/images/favicon/browserconfig.xml rename to src/frontend/app-images/favicons/browserconfig.xml diff --git a/manager/src/frontend/images/favicon/favicon-16x16.png b/src/frontend/app-images/favicons/favicon-16x16.png similarity index 100% rename from manager/src/frontend/images/favicon/favicon-16x16.png rename to src/frontend/app-images/favicons/favicon-16x16.png diff --git a/manager/src/frontend/images/favicon/favicon-32x32.png b/src/frontend/app-images/favicons/favicon-32x32.png similarity index 100% rename from manager/src/frontend/images/favicon/favicon-32x32.png rename to src/frontend/app-images/favicons/favicon-32x32.png diff --git a/manager/src/frontend/images/favicon/favicon.ico b/src/frontend/app-images/favicons/favicon.ico similarity index 100% rename from manager/src/frontend/images/favicon/favicon.ico rename to src/frontend/app-images/favicons/favicon.ico diff --git a/manager/src/frontend/images/favicon/manifest.json b/src/frontend/app-images/favicons/manifest.json similarity index 100% rename from manager/src/frontend/images/favicon/manifest.json rename to src/frontend/app-images/favicons/manifest.json diff --git a/manager/src/frontend/images/favicon/mstile-150x150.png b/src/frontend/app-images/favicons/mstile-150x150.png similarity index 100% rename from manager/src/frontend/images/favicon/mstile-150x150.png rename to src/frontend/app-images/favicons/mstile-150x150.png diff --git a/manager/src/frontend/images/favicon/safari-pinned-tab.svg b/src/frontend/app-images/favicons/safari-pinned-tab.svg similarity index 100% rename from manager/src/frontend/images/favicon/safari-pinned-tab.svg rename to src/frontend/app-images/favicons/safari-pinned-tab.svg diff --git a/src/frontend/fonts b/src/frontend/fonts new file mode 120000 index 00000000..84b6a8e0 --- /dev/null +++ b/src/frontend/fonts @@ -0,0 +1 @@ +../../node_modules/tabler-ui/dist/assets/fonts \ No newline at end of file diff --git a/src/frontend/images b/src/frontend/images new file mode 120000 index 00000000..6f1cb6a6 --- /dev/null +++ b/src/frontend/images @@ -0,0 +1 @@ +../../node_modules/tabler-ui/dist/assets/images \ No newline at end of file diff --git a/src/frontend/js/app/api.js b/src/frontend/js/app/api.js new file mode 100644 index 00000000..76ec5c96 --- /dev/null +++ b/src/frontend/js/app/api.js @@ -0,0 +1,570 @@ +'use strict'; + +const $ = require('jquery'); +const _ = require('underscore'); +const Tokens = require('./tokens'); + +/** + * @param {String} message + * @param {*} debug + * @param {Integer} code + * @constructor + */ +const ApiError = function (message, debug, code) { + let temp = Error.call(this, message); + temp.name = this.name = 'ApiError'; + this.stack = temp.stack; + this.message = temp.message; + this.debug = debug; + this.code = code; +}; + +ApiError.prototype = Object.create(Error.prototype, { + constructor: { + value: ApiError, + writable: true, + configurable: true + } +}); + +/** + * + * @param {String} verb + * @param {String} path + * @param {Object} [data] + * @param {Object} [options] + * @returns {Promise} + */ +function fetch (verb, path, data, options) { + options = options || {}; + + return new Promise(function (resolve, reject) { + let api_url = '/api/'; + let url = api_url + path; + let token = Tokens.getTopToken(); + + if ((typeof options.contentType === 'undefined' || options.contentType.match(/json/im)) && typeof data === 'object') { + data = JSON.stringify(data); + } + + $.ajax({ + url: url, + data: typeof data === 'object' ? JSON.stringify(data) : data, + type: verb, + dataType: 'json', + contentType: options.contentType || 'application/json; charset=UTF-8', + processData: options.processData || true, + crossDomain: true, + timeout: options.timeout ? options.timeout : 15000, + xhrFields: { + withCredentials: true + }, + + beforeSend: function (xhr) { + xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); + }, + + success: function (data, textStatus, response) { + let total = response.getResponseHeader('X-Dataset-Total'); + if (total !== null) { + resolve({ + data: data, + pagination: { + total: parseInt(total, 10), + offset: parseInt(response.getResponseHeader('X-Dataset-Offset'), 10), + limit: parseInt(response.getResponseHeader('X-Dataset-Limit'), 10) + } + }); + } else { + resolve(response); + } + }, + + error: function (xhr, status, error_thrown) { + let code = 400; + + if (typeof xhr.responseJSON !== 'undefined' && typeof xhr.responseJSON.error !== 'undefined' && typeof xhr.responseJSON.error.message !== 'undefined') { + error_thrown = xhr.responseJSON.error.message; + code = xhr.responseJSON.error.code || 500; + } + + reject(new ApiError(error_thrown, xhr.responseText, code)); + } + }); + }); +} + +/** + * + * @param {Array} expand + * @returns {String} + */ +function makeExpansionString (expand) { + let items = []; + _.forEach(expand, function (exp) { + items.push(encodeURIComponent(exp)); + }); + + return items.join(','); +} + +/** + * @param {String} path + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ +function getAllObjects (path, expand, query) { + let params = []; + + if (typeof expand === 'object' && expand !== null && expand.length) { + params.push('expand=' + makeExpansionString(expand)); + } + + if (typeof query === 'string') { + params.push('query=' + query); + } + + return fetch('get', path + (params.length ? '?' + params.join('&') : '')); +} + +/** + * @param {String} path + * @param {FormData} form_data + * @returns {Promise} + */ +function upload (path, form_data) { + console.log('UPLOAD:', path, form_data); + return fetch('post', path, form_data, { + contentType: 'multipart/form-data', + processData: false + }); +} + +function FileUpload (path, fd) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + let token = Tokens.getTopToken(); + + xhr.open('POST', '/api/' + path); + xhr.overrideMimeType('text/plain'); + xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); + xhr.send(fd); + + xhr.onreadystatechange = function () { + if (this.readyState === XMLHttpRequest.DONE) { + if (xhr.status !== 200 && xhr.status !== 201) { + reject(new Error('Upload failed: ' + xhr.status)); + } else { + resolve(xhr.responseText); + } + } + }; + }); +} + +module.exports = { + status: function () { + return fetch('get', ''); + }, + + Tokens: { + + /** + * @param {String} identity + * @param {String} secret + * @param {Boolean} [wipe] Will wipe the stack before adding to it again if login was successful + * @returns {Promise} + */ + login: function (identity, secret, wipe) { + return fetch('post', 'tokens', {identity: identity, secret: secret}) + .then(response => { + if (response.token) { + if (wipe) { + Tokens.clearTokens(); + } + + // Set storage token + Tokens.addToken(response.token); + return response.token; + } else { + Tokens.clearTokens(); + throw(new Error('No token returned')); + } + }); + }, + + /** + * @returns {Promise} + */ + refresh: function () { + return fetch('get', 'tokens') + .then(response => { + if (response.token) { + Tokens.setCurrentToken(response.token); + return response.token; + } else { + Tokens.clearTokens(); + throw(new Error('No token returned')); + } + }); + } + }, + + Users: { + + /** + * @param {Integer|String} user_id + * @param {Array} [expand] + * @returns {Promise} + */ + getById: function (user_id, expand) { + return fetch('get', 'users/' + user_id + (typeof expand === 'object' && expand.length ? '?expand=' + makeExpansionString(expand) : '')); + }, + + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('users', expand, query); + }, + + /** + * @param {Object} data + * @returns {Promise} + */ + create: function (data) { + return fetch('post', 'users', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'users/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'users/' + id); + }, + + /** + * + * @param {Integer} id + * @param {Object} auth + * @returns {Promise} + */ + setPassword: function (id, auth) { + return fetch('put', 'users/' + id + '/auth', auth); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + loginAs: function (id) { + return fetch('post', 'users/' + id + '/login'); + }, + + /** + * + * @param {Integer} id + * @param {Object} perms + * @returns {Promise} + */ + setPermissions: function (id, perms) { + return fetch('put', 'users/' + id + '/permissions', perms); + } + }, + + Nginx: { + + ProxyHosts: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/proxy-hosts', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/proxy-hosts', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/proxy-hosts/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/proxy-hosts/' + id); + } + }, + + RedirectionHosts: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/redirection-hosts', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/redirection-hosts', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/redirection-hosts/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/redirection-hosts/' + id); + }, + + /** + * @param {Integer} id + * @param {FormData} form_data + * @params {Promise} + */ + setCerts: function (id, form_data) { + return FileUpload('nginx/redirection-hosts/' + id + '/certificates', form_data); + } + }, + + Streams: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/streams', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/streams', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/streams/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/streams/' + id); + } + }, + + DeadHosts: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/dead-hosts', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/dead-hosts', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/dead-hosts/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/dead-hosts/' + id); + }, + + /** + * @param {Integer} id + * @param {FormData} form_data + * @params {Promise} + */ + setCerts: function (id, form_data) { + return FileUpload('nginx/dead-hosts/' + id + '/certificates', form_data); + } + }, + + AccessLists: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/access-lists', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/access-lists', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/access-lists/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/access-lists/' + id); + } + }, + + Certificates: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/certificates', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/certificates', data); + }, + + /** + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/certificates/' + id, data); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/certificates/' + id); + }, + + /** + * @param {Integer} id + * @param {FormData} form_data + * @params {Promise} + */ + upload: function (id, form_data) { + return FileUpload('nginx/certificates/' + id + '/upload', form_data); + }, + + /** + * @param {FormData} form_data + * @params {Promise} + */ + validate: function (form_data) { + return FileUpload('nginx/certificates/validate', form_data); + } + } + }, + + AuditLog: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('audit-log', expand, query); + } + }, + + Reports: { + + /** + * @returns {Promise} + */ + getHostStats: function () { + return fetch('get', 'reports/hosts'); + } + } +}; diff --git a/src/frontend/js/app/audit-log/list/item.ejs b/src/frontend/js/app/audit-log/list/item.ejs new file mode 100644 index 00000000..84743c8d --- /dev/null +++ b/src/frontend/js/app/audit-log/list/item.ejs @@ -0,0 +1,80 @@ + +
+ +
+ + +
+ <% if (user.is_deleted) { + %> + <%- user.name %> + <% + } else { + %> + <%- user.name %> + <% + } + %> +
+ + +
+ <% + var items = []; + switch (object_type) { + case 'proxy-host': + %> <% + items = meta.domain_names; + break; + case 'redirection-host': + %> <% + items = meta.domain_names; + break; + case 'stream': + %> <% + items.push(meta.incoming_port); + break; + case 'dead-host': + %> <% + items = meta.domain_names; + break; + case 'access-list': + %> <% + items.push(meta.name); + break; + case 'user': + %> <% + items.push(meta.name); + break; + case 'certificate': + %> <% + if (meta.provider === 'letsencrypt') { + items = meta.domain_names; + } else { + items.push(meta.nice_name); + } + break; + } + %> <%- i18n('audit-log', action, {name: i18n('audit-log', object_type)}) %> + — + <% + if (items && items.length) { + items.map(function(item) { + %> + <%- item %> + <% + }); + } else { + %> + #<%- object_id %> + <% + } + %> +
+
+ <%- formatDbDate(created_on, 'Do MMMM YYYY, h:mm a') %> +
+ + + <%- i18n('audit-log', 'view-meta') %> + diff --git a/src/frontend/js/app/audit-log/list/item.js b/src/frontend/js/app/audit-log/list/item.js new file mode 100644 index 00000000..f154931f --- /dev/null +++ b/src/frontend/js/app/audit-log/list/item.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const Controller = require('../../controller'); +const template = require('./item.ejs'); + +module.exports = Mn.View.extend({ + template: template, + tagName: 'tr', + + ui: { + meta: 'a.meta' + }, + + events: { + 'click @ui.meta': function (e) { + e.preventDefault(); + Controller.showAuditMeta(this.model); + } + }, + + templateContext: { + more: function() { + switch (this.object_type) { + case 'redirection-host': + case 'stream': + case 'proxy-host': + return this.meta.domain_names.join(', '); + } + + return '#' + (this.object_id || '?'); + } + } +}); diff --git a/src/frontend/js/app/audit-log/list/main.ejs b/src/frontend/js/app/audit-log/list/main.ejs new file mode 100644 index 00000000..ec3cf2a2 --- /dev/null +++ b/src/frontend/js/app/audit-log/list/main.ejs @@ -0,0 +1,9 @@ + +   + User + Event +   + + + + diff --git a/src/frontend/js/app/audit-log/list/main.js b/src/frontend/js/app/audit-log/list/main.js new file mode 100644 index 00000000..bbe75beb --- /dev/null +++ b/src/frontend/js/app/audit-log/list/main.js @@ -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 + })); + } +}); diff --git a/src/frontend/js/app/audit-log/main.ejs b/src/frontend/js/app/audit-log/main.ejs new file mode 100644 index 00000000..acaa8b49 --- /dev/null +++ b/src/frontend/js/app/audit-log/main.ejs @@ -0,0 +1,15 @@ +
+
+
+

<%- i18n('audit-log', 'title') %>

+
+
+
+
+
+ +
+
+ +
+
diff --git a/src/frontend/js/app/audit-log/main.js b/src/frontend/js/app/audit-log/main.js new file mode 100644 index 00000000..b9c2f15d --- /dev/null +++ b/src/frontend/js/app/audit-log/main.js @@ -0,0 +1,55 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../main'); +const AuditLogModel = require('../../models/audit-log'); +const ListView = require('./list/main'); +const template = require('./main.ejs'); +const ErrorView = require('../error/main'); +const EmptyView = require('../empty/main'); + +module.exports = Mn.View.extend({ + id: 'audit-log', + template: template, + + ui: { + list_region: '.list-region', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + onRender: function () { + let view = this; + + App.Api.AuditLog.getAll(['user']) + .then(response => { + if (!view.isDestroyed() && response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new AuditLogModel.Collection(response) + })); + } else { + view.showChildView('list_region', new EmptyView({ + title: App.i18n('audit-log', 'empty'), + subtitle: App.i18n('audit-log', 'empty-subtitle') + })); + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showAuditLog(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/app/audit-log/meta.ejs b/src/frontend/js/app/audit-log/meta.ejs new file mode 100644 index 00000000..98a2d973 --- /dev/null +++ b/src/frontend/js/app/audit-log/meta.ejs @@ -0,0 +1,27 @@ + diff --git a/src/frontend/js/app/audit-log/meta.js b/src/frontend/js/app/audit-log/meta.js new file mode 100644 index 00000000..9ec962bd --- /dev/null +++ b/src/frontend/js/app/audit-log/meta.js @@ -0,0 +1,9 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./meta.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog wide' +}); diff --git a/src/frontend/js/app/cache.js b/src/frontend/js/app/cache.js new file mode 100644 index 00000000..9e6534f1 --- /dev/null +++ b/src/frontend/js/app/cache.js @@ -0,0 +1,12 @@ +'use strict'; + +const UserModel = require('../models/user'); + +let cache = { + User: new UserModel.Model(), + locale: 'en', + version: null +}; + +module.exports = cache; + diff --git a/src/frontend/js/app/controller.js b/src/frontend/js/app/controller.js new file mode 100644 index 00000000..3f894748 --- /dev/null +++ b/src/frontend/js/app/controller.js @@ -0,0 +1,393 @@ +'use strict'; + +const Backbone = require('backbone'); +const Cache = require('./cache'); +const Tokens = require('./tokens'); + +module.exports = { + + /** + * @param {String} route + * @param {Object} [options] + * @returns {Boolean} + */ + navigate: function (route, options) { + options = options || {}; + Backbone.history.navigate(route.toString(), options); + return true; + }, + + /** + * Login + */ + showLogin: function () { + window.location = '/login'; + }, + + /** + * Users + */ + showUsers: function () { + let controller = this; + if (Cache.User.isAdmin()) { + require(['./main', './users/main'], (App, View) => { + controller.navigate('/users'); + App.UI.showAppContent(new View()); + }); + } else { + this.showDashboard(); + } + }, + + /** + * User Form + * + * @param [model] + */ + showUserForm: function (model) { + if (Cache.User.isAdmin()) { + require(['./main', './user/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * User Permissions Form + * + * @param model + */ + showUserPermissions: function (model) { + if (Cache.User.isAdmin()) { + require(['./main', './user/permissions'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * User Password Form + * + * @param model + */ + showUserPasswordForm: function (model) { + if (Cache.User.isAdmin() || model.get('id') === Cache.User.get('id')) { + require(['./main', './user/password'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * User Delete Confirm + * + * @param model + */ + showUserDeleteConfirm: function (model) { + if (Cache.User.isAdmin() && model.get('id') !== Cache.User.get('id')) { + require(['./main', './user/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Dashboard + */ + showDashboard: function () { + let controller = this; + + require(['./main', './dashboard/main'], (App, View) => { + controller.navigate('/'); + App.UI.showAppContent(new View()); + }); + }, + + /** + * Nginx Proxy Hosts + */ + showNginxProxy: function () { + if (Cache.User.isAdmin() || Cache.User.canView('proxy_hosts')) { + let controller = this; + + require(['./main', './nginx/proxy/main'], (App, View) => { + controller.navigate('/nginx/proxy'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * Nginx Proxy Host Form + * + * @param [model] + */ + showNginxProxyForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) { + require(['./main', './nginx/proxy/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Proxy Host Delete Confirm + * + * @param model + */ + showNginxProxyDeleteConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) { + require(['./main', './nginx/proxy/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Nginx Redirection Hosts + */ + showNginxRedirection: function () { + if (Cache.User.isAdmin() || Cache.User.canView('redirection_hosts')) { + let controller = this; + + require(['./main', './nginx/redirection/main'], (App, View) => { + controller.navigate('/nginx/redirection'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * Nginx Redirection Host Form + * + * @param [model] + */ + showNginxRedirectionForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) { + require(['./main', './nginx/redirection/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Proxy Redirection Delete Confirm + * + * @param model + */ + showNginxRedirectionDeleteConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) { + require(['./main', './nginx/redirection/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Nginx Stream Hosts + */ + showNginxStream: function () { + if (Cache.User.isAdmin() || Cache.User.canView('streams')) { + let controller = this; + + require(['./main', './nginx/stream/main'], (App, View) => { + controller.navigate('/nginx/stream'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * Stream Form + * + * @param [model] + */ + showNginxStreamForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('streams')) { + require(['./main', './nginx/stream/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Stream Delete Confirm + * + * @param model + */ + showNginxStreamDeleteConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('streams')) { + require(['./main', './nginx/stream/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Nginx Dead Hosts + */ + showNginxDead: function () { + if (Cache.User.isAdmin() || Cache.User.canView('dead_hosts')) { + let controller = this; + + require(['./main', './nginx/dead/main'], (App, View) => { + controller.navigate('/nginx/404'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * Dead Host Form + * + * @param [model] + */ + showNginxDeadForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) { + require(['./main', './nginx/dead/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Dead Host Delete Confirm + * + * @param model + */ + showNginxDeadDeleteConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) { + require(['./main', './nginx/dead/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Help Dialog + * + * @param {String} title + * @param {String} content + */ + showHelp: function (title, content) { + require(['./main', './help/main'], function (App, View) { + App.UI.showModalDialog(new View({title: title, content: content})); + }); + }, + + /** + * Nginx Access + */ + showNginxAccess: function () { + if (Cache.User.isAdmin() || Cache.User.canView('access_lists')) { + let controller = this; + + require(['./main', './nginx/access/main'], (App, View) => { + controller.navigate('/nginx/access'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * Nginx Access List Form + * + * @param [model] + */ + showNginxAccessListForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) { + require(['./main', './nginx/access/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Access List Delete Confirm + * + * @param model + */ + showNginxAccessListDeleteConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) { + require(['./main', './nginx/access/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Nginx Certificates + */ + showNginxCertificates: function () { + if (Cache.User.isAdmin() || Cache.User.canView('certificates')) { + let controller = this; + + require(['./main', './nginx/certificates/main'], (App, View) => { + controller.navigate('/nginx/certificates'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * Nginx Certificate Form + * + * @param [model] + */ + showNginxCertificateForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) { + require(['./main', './nginx/certificates/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Certificate Delete Confirm + * + * @param model + */ + showNginxCertificateDeleteConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) { + require(['./main', './nginx/certificates/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Audit Log + */ + showAuditLog: function () { + let controller = this; + if (Cache.User.isAdmin()) { + require(['./main', './audit-log/main'], (App, View) => { + controller.navigate('/audit-log'); + App.UI.showAppContent(new View()); + }); + } else { + this.showDashboard(); + } + }, + + /** + * Audit Log Metadata + * + * @param model + */ + showAuditMeta: function (model) { + if (Cache.User.isAdmin()) { + require(['./main', './audit-log/meta'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Logout + */ + logout: function () { + Tokens.dropTopToken(); + this.showLogin(); + } +}; diff --git a/src/frontend/js/app/dashboard/main.ejs b/src/frontend/js/app/dashboard/main.ejs new file mode 100644 index 00000000..c00aa6d0 --- /dev/null +++ b/src/frontend/js/app/dashboard/main.ejs @@ -0,0 +1,67 @@ + + +<% if (columns) { %> +
+ <% if (canShow('proxy_hosts')) { %> + + <% } %> + + <% if (canShow('redirection_hosts')) { %> + + <% } %> + + <% if (canShow('streams')) { %> + + <% } %> + + <% if (canShow('dead_hosts')) { %> + + <% } %> +
+<% } %> diff --git a/src/frontend/js/app/dashboard/main.js b/src/frontend/js/app/dashboard/main.js new file mode 100644 index 00000000..9d768183 --- /dev/null +++ b/src/frontend/js/app/dashboard/main.js @@ -0,0 +1,94 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const Cache = require('../cache'); +const Controller = require('../controller'); +const Api = require('../api'); +const Helpers = require('../../lib/helpers'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + template: template, + id: 'dashboard', + columns: 0, + + stats: {}, + + ui: { + links: 'a' + }, + + events: { + 'click @ui.links': function (e) { + e.preventDefault(); + Controller.navigate($(e.currentTarget).attr('href'), true); + } + }, + + templateContext: function () { + let view = this; + + return { + getUserName: function () { + return Cache.User.get('nickname') || Cache.User.get('name'); + }, + + getHostStat: function (type) { + if (view.stats && typeof view.stats.hosts !== 'undefined' && typeof view.stats.hosts[type] !== 'undefined') { + return Helpers.niceNumber(view.stats.hosts[type]); + } + + return '-'; + }, + + canShow: function (perm) { + return Cache.User.isAdmin() || Cache.User.canView(perm); + }, + + columns: view.columns + }; + }, + + onRender: function () { + let view = this; + + if (typeof view.stats.hosts === 'undefined') { + Api.Reports.getHostStats() + .then(response => { + if (!view.isDestroyed()) { + view.stats.hosts = response; + view.render(); + } + }) + .catch(err => { + console.log(err); + }); + } + }, + + /** + * @param {Object} [model] + */ + preRender: function (model) { + this.columns = 0; + + // calculate the available columns based on permissions for the objects + // and store as a variable + //let view = this; + let perms = ['proxy_hosts', 'redirection_hosts', 'streams', 'dead_hosts']; + + perms.map(perm => { + this.columns += Cache.User.isAdmin() || Cache.User.canView(perm) ? 1 : 0; + }); + + // Prevent double rendering on initial calls + if (typeof model !== 'undefined') { + this.render(); + } + }, + + initialize: function () { + this.preRender(); + this.listenTo(Cache.User, 'change', this.preRender); + } +}); diff --git a/src/frontend/js/app/empty/main.ejs b/src/frontend/js/app/empty/main.ejs new file mode 100644 index 00000000..11633dfc --- /dev/null +++ b/src/frontend/js/app/empty/main.ejs @@ -0,0 +1,11 @@ +<% if (title) { %> +

<%- title %>

+<% } + +if (subtitle) { %> +

<%- subtitle %>

+<% } + +if (link) { %> + <%- link %> +<% } %> diff --git a/src/frontend/js/app/empty/main.js b/src/frontend/js/app/empty/main.js new file mode 100644 index 00000000..e6f54e45 --- /dev/null +++ b/src/frontend/js/app/empty/main.js @@ -0,0 +1,35 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + className: 'text-center m-7', + template: template, + + options: { + btn_color: 'teal' + }, + + ui: { + action: 'a' + }, + + events: { + 'click @ui.action': function (e) { + e.preventDefault(); + this.getOption('action')(); + } + }, + + templateContext: function () { + return { + title: this.getOption('title'), + subtitle: this.getOption('subtitle'), + link: this.getOption('link'), + action: typeof this.getOption('action') === 'function', + btn_color: this.getOption('btn_color') + }; + } + +}); diff --git a/src/frontend/js/app/error/main.ejs b/src/frontend/js/app/error/main.ejs new file mode 100644 index 00000000..f7fd709b --- /dev/null +++ b/src/frontend/js/app/error/main.ejs @@ -0,0 +1,7 @@ + +<%= code ? '' + code + ' — ' : '' %> +<%- message %> + +<% if (retry) { %> +

<%- i18n('str', 'try-again') %> +<% } %> diff --git a/src/frontend/js/app/error/main.js b/src/frontend/js/app/error/main.js new file mode 100644 index 00000000..431fb17f --- /dev/null +++ b/src/frontend/js/app/error/main.js @@ -0,0 +1,29 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'alert alert-icon alert-warning m-5', + + ui: { + retry: 'a.retry' + }, + + events: { + 'click @ui.retry': function (e) { + e.preventDefault(); + this.getOption('retry')(); + } + }, + + templateContext: function () { + return { + message: this.getOption('message'), + code: this.getOption('code'), + retry: typeof this.getOption('retry') === 'function' + }; + } + +}); diff --git a/src/frontend/js/app/help/main.ejs b/src/frontend/js/app/help/main.ejs new file mode 100644 index 00000000..6fb79e66 --- /dev/null +++ b/src/frontend/js/app/help/main.ejs @@ -0,0 +1,12 @@ + diff --git a/src/frontend/js/app/help/main.js b/src/frontend/js/app/help/main.js new file mode 100644 index 00000000..c5787775 --- /dev/null +++ b/src/frontend/js/app/help/main.js @@ -0,0 +1,18 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog wide', + + templateContext: function () { + let content = this.getOption('content').split("\n"); + + return { + title: this.getOption('title'), + content: '

' + content.join('

') + '

' + }; + } +}); diff --git a/src/frontend/js/app/i18n.js b/src/frontend/js/app/i18n.js new file mode 100644 index 00000000..451f1968 --- /dev/null +++ b/src/frontend/js/app/i18n.js @@ -0,0 +1,25 @@ +'use strict'; + +const Cache = ('./cache'); +const messages = require('../i18n/messages.json'); + +/** + * @param {String} namespace + * @param {String} key + * @param {Object} [data] + */ +module.exports = function (namespace, key, data) { + let locale = Cache.locale; + // check that the locale exists + if (typeof messages[locale] === 'undefined') { + locale = 'en'; + } + + if (typeof messages[locale][namespace] !== 'undefined' && typeof messages[locale][namespace][key] !== 'undefined') { + return messages[locale][namespace][key](data); + } else if (locale !== 'en' && typeof messages['en'][namespace] !== 'undefined' && typeof messages['en'][namespace][key] !== 'undefined') { + return messages['en'][namespace][key](data); + } + + return '(MISSING: ' + namespace + '/' + key + ')'; +}; diff --git a/src/frontend/js/app/main.js b/src/frontend/js/app/main.js new file mode 100644 index 00000000..09236420 --- /dev/null +++ b/src/frontend/js/app/main.js @@ -0,0 +1,157 @@ +'use strict'; + +const _ = require('underscore'); +const Backbone = require('backbone'); +const Mn = require('../lib/marionette'); +const Cache = require('./cache'); +const Controller = require('./controller'); +const Router = require('./router'); +const Api = require('./api'); +const Tokens = require('./tokens'); +const UI = require('./ui/main'); +const i18n = require('./i18n'); + +const App = Mn.Application.extend({ + + Cache: Cache, + Api: Api, + UI: null, + i18n: i18n, + Controller: Controller, + + region: { + el: '#app', + replaceElement: true + }, + + onStart: function (app, options) { + console.log(i18n('main', 'welcome')); + + // Check if token is coming through + if (this.getParam('token')) { + Tokens.addToken(this.getParam('token')); + } + + // Check if we are still logged in by refreshing the token + Api.status() + .then(result => { + Cache.version = [result.version.major, result.version.minor, result.version.revision].join('.'); + }) + .then(Api.Tokens.refresh) + .then(this.bootstrap) + .then(() => { + console.info(i18n('main', 'logged-in', Cache.User.attributes)); + this.bootstrapTimer(); + this.refreshTokenTimer(); + + this.UI = new UI(); + this.UI.on('render', () => { + new Router(options); + Backbone.history.start({pushState: true}); + + // Ask the admin use to change their details + if (Cache.User.get('email') === 'admin@example.com') { + Controller.showUserForm(Cache.User); + } + }); + + this.getRegion().show(this.UI); + }) + .catch(err => { + console.warn('Not logged in:', err.message); + Controller.showLogin(); + }); + }, + + History: { + replace: function (data) { + window.history.replaceState(_.extend(window.history.state || {}, data), document.title); + }, + + get: function (attr) { + return window.history.state ? window.history.state[attr] : undefined; + } + }, + + getParam: function (name) { + name = name.replace(/[\[\]]/g, '\\$&'); + let url = window.location.href; + let regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'); + let results = regex.exec(url); + + if (!results) { + return null; + } + + if (!results[2]) { + return ''; + } + + return decodeURIComponent(results[2].replace(/\+/g, ' ')); + }, + + /** + * Get user and other base info to start prime the cache and the application + * + * @returns {Promise} + */ + bootstrap: function () { + return Api.Users.getById('me', ['permissions']) + .then(response => { + Cache.User.set(response); + Tokens.setCurrentName(response.nickname || response.name); + }); + }, + + /** + * Bootstraps the user from time to time + */ + bootstrapTimer: function () { + setTimeout(() => { + Api.status() + .then(result => { + let version = [result.version.major, result.version.minor, result.version.revision].join('.'); + if (version !== Cache.version) { + document.location.reload(); + } + }) + .then(this.bootstrap) + .then(() => { + this.bootstrapTimer(); + }) + .catch(err => { + if (err.message !== 'timeout' && err.code && err.code !== 400) { + console.log(err); + console.error(err.message); + console.info('Not logged in?'); + Controller.showLogin(); + } else { + this.bootstrapTimer(); + } + }); + }, 30 * 1000); // 30 seconds + }, + + refreshTokenTimer: function () { + setTimeout(() => { + return Api.Tokens.refresh() + .then(this.bootstrap) + .then(() => { + this.refreshTokenTimer(); + }) + .catch(err => { + if (err.message !== 'timeout' && err.code && err.code !== 400) { + console.log(err); + console.error(err.message); + console.info('Not logged in?'); + Controller.showLogin(); + } else { + this.refreshTokenTimer(); + } + }); + }, 10 * 60 * 1000); + } +}); + +const app = new App(); +module.exports = app; diff --git a/src/frontend/js/app/nginx/access/delete.ejs b/src/frontend/js/app/nginx/access/delete.ejs new file mode 100644 index 00000000..3833549a --- /dev/null +++ b/src/frontend/js/app/nginx/access/delete.ejs @@ -0,0 +1,23 @@ + diff --git a/src/frontend/js/app/nginx/access/delete.js b/src/frontend/js/app/nginx/access/delete.js new file mode 100644 index 00000000..e4660cb1 --- /dev/null +++ b/src/frontend/js/app/nginx/access/delete.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Nginx.AccessLists.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxAccess(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/nginx/access/form.ejs b/src/frontend/js/app/nginx/access/form.ejs new file mode 100644 index 00000000..a85e3968 --- /dev/null +++ b/src/frontend/js/app/nginx/access/form.ejs @@ -0,0 +1,34 @@ + diff --git a/manager/src/frontend/js/app/access_list/form.js b/src/frontend/js/app/nginx/access/form.js similarity index 55% rename from manager/src/frontend/js/app/access_list/form.js rename to src/frontend/js/app/nginx/access/form.js index 6c0d227c..89c1020b 100644 --- a/manager/src/frontend/js/app/access_list/form.js +++ b/src/frontend/js/app/nginx/access/form.js @@ -1,13 +1,10 @@ 'use strict'; const Mn = require('backbone.marionette'); -const _ = require('lodash'); +const App = require('../../main'); +const AccessListModel = require('../../../models/access-list'); const template = require('./form.ejs'); -const Controller = require('../controller'); -const Api = require('../api'); -const App = require('../main'); -const ItemView = require('./item'); -const AccessItemModel = require('../../models/access_item'); +const ItemView = require('./form/item'); require('jquery-serializejson'); @@ -16,13 +13,15 @@ const ItemsView = Mn.CollectionView.extend({ }); module.exports = Mn.View.extend({ - template: template, - id: 'access-list-form', + template: template, + className: 'modal-dialog', ui: { items_region: '.items', form: 'form', - buttons: 'form button' + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' }, regions: { @@ -30,13 +29,19 @@ module.exports = Mn.View.extend({ }, events: { - 'submit @ui.form': function (e) { + 'click @ui.save': function (e) { e.preventDefault(); + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; let form_data = this.ui.form.serializeJSON(); let items_data = []; - _.map(form_data.username, (val, idx) => { + form_data.username.map(function (val, idx) { if (val.trim().length) { items_data.push({ username: val.trim(), @@ -55,21 +60,28 @@ module.exports = Mn.View.extend({ items: items_data }; - this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); - let method = Api.Access.create; + let method = App.Api.Nginx.AccessLists.create; + let is_new = true; - if (this.model.get('_id')) { + if (this.model.get('id')) { // edit - method = Api.Access.update; - data._id = this.model.get('_id'); + is_new = false; + method = App.Api.Nginx.AccessLists.update; + data.id = this.model.get('id'); } + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); method(data) - .then((/*result*/) => { - App.UI.closeModal(); - Controller.showAccess(); + .then(result => { + view.model.set(result); + + App.UI.closeModal(function () { + if (is_new) { + App.Controller.showNginxAccess(); + } + }); }) - .catch((err) => { + .catch(err => { alert(err.message); this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); }); @@ -88,7 +100,13 @@ module.exports = Mn.View.extend({ } this.showChildView('items_region', new ItemsView({ - collection: new AccessItemModel.Collection(items) + collection: new Backbone.Collection(items) })); + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new AccessListModel.Model(); + } } }); diff --git a/src/frontend/js/app/nginx/access/form/item.ejs b/src/frontend/js/app/nginx/access/form/item.ejs new file mode 100644 index 00000000..c2435ecb --- /dev/null +++ b/src/frontend/js/app/nginx/access/form/item.ejs @@ -0,0 +1,10 @@ +
+
+ +
+
+
+
+ +
+
diff --git a/src/frontend/js/app/nginx/access/form/item.js b/src/frontend/js/app/nginx/access/form/item.js new file mode 100644 index 00000000..07c86b96 --- /dev/null +++ b/src/frontend/js/app/nginx/access/form/item.js @@ -0,0 +1,9 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./item.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'row' +}); diff --git a/src/frontend/js/app/nginx/access/list/item.ejs b/src/frontend/js/app/nginx/access/list/item.ejs new file mode 100644 index 00000000..613f6208 --- /dev/null +++ b/src/frontend/js/app/nginx/access/list/item.ejs @@ -0,0 +1,31 @@ + +
+ +
+ + +
+ <%- name %> +
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + + <%- i18n('access-lists', 'item-count', {count: items.length || 0}) %> + + + <%- i18n('access-lists', 'proxy-host-count', {count: proxy_host_count}) %> + +<% if (canManage) { %> + + + +<% } %> diff --git a/src/frontend/js/app/nginx/access/list/item.js b/src/frontend/js/app/nginx/access/list/item.js new file mode 100644 index 00000000..d6498d52 --- /dev/null +++ b/src/frontend/js/app/nginx/access/list/item.js @@ -0,0 +1,35 @@ +'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', + delete: 'a.delete' + }, + + events: { + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showNginxAccessListForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxAccessListDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('access_lists') + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/nginx/access/list/main.ejs b/src/frontend/js/app/nginx/access/list/main.ejs new file mode 100644 index 00000000..435b767d --- /dev/null +++ b/src/frontend/js/app/nginx/access/list/main.ejs @@ -0,0 +1,12 @@ + +   + <%- i18n('str', 'name') %> + <%- i18n('users', 'title') %> + <%- i18n('proxy-hosts', 'title') %> + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/src/frontend/js/app/nginx/access/list/main.js b/src/frontend/js/app/nginx/access/list/main.js new file mode 100644 index 00000000..d5b7aa75 --- /dev/null +++ b/src/frontend/js/app/nginx/access/list/main.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +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 + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('access_lists') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/src/frontend/js/app/nginx/access/main.ejs b/src/frontend/js/app/nginx/access/main.ejs new file mode 100644 index 00000000..c245ff4a --- /dev/null +++ b/src/frontend/js/app/nginx/access/main.ejs @@ -0,0 +1,20 @@ +
+
+
+

<%- i18n('access-lists', 'title') %>

+
+ + <% if (showAddButton) { %> + <%- i18n('access-lists', 'add') %> + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/src/frontend/js/app/nginx/access/main.js b/src/frontend/js/app/nginx/access/main.js new file mode 100644 index 00000000..21e54f0f --- /dev/null +++ b/src/frontend/js/app/nginx/access/main.js @@ -0,0 +1,83 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const AccessListModel = require('../../../models/access-list'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-access', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showNginxAccessListForm(); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp(App.i18n('access-lists', 'help-title'), App.i18n('access-lists', 'help-content')); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('access_lists') + }, + + onRender: function () { + let view = this; + + App.Api.Nginx.AccessLists.getAll(['owner', 'items']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new AccessListModel.Collection(response) + })); + } else { + let manage = App.Cache.User.canManage('access_lists'); + + view.showChildView('list_region', new EmptyView({ + title: App.i18n('access-lists', 'empty'), + subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), + link: manage ? App.i18n('access-lists', 'add') : null, + btn_color: 'teal', + permission: 'access_lists', + action: function () { + App.Controller.showNginxAccessListForm(); + } + })); + } + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxAccess(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/app/nginx/certificates-list-item.ejs b/src/frontend/js/app/nginx/certificates-list-item.ejs new file mode 100644 index 00000000..aa4b53ad --- /dev/null +++ b/src/frontend/js/app/nginx/certificates-list-item.ejs @@ -0,0 +1,18 @@ +
+ <% if (id === 'new') { %> +
+ <%- i18n('all-hosts', 'new-cert') %> +
+ <%- i18n('all-hosts', 'with-le') %> + <% } else if (id > 0) { %> +
+ <%- provider === 'other' ? nice_name : domain_names.join(', ') %> +
+ <%- i18n('ssl', provider) %> – Expires: <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %> + <% } else { %> +
+ <%- i18n('all-hosts', 'none') %> +
+ <%- i18n('all-hosts', 'no-ssl') %> + <% } %> +
diff --git a/src/frontend/js/app/nginx/certificates/delete.ejs b/src/frontend/js/app/nginx/certificates/delete.ejs new file mode 100644 index 00000000..b4e06866 --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/delete.ejs @@ -0,0 +1,19 @@ + diff --git a/src/frontend/js/app/nginx/certificates/delete.js b/src/frontend/js/app/nginx/certificates/delete.js new file mode 100644 index 00000000..2499bad1 --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/delete.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Nginx.Certificates.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxCertificates(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/nginx/certificates/form.ejs b/src/frontend/js/app/nginx/certificates/form.ejs new file mode 100644 index 00000000..32edb6bf --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/form.ejs @@ -0,0 +1,76 @@ + diff --git a/src/frontend/js/app/nginx/certificates/form.js b/src/frontend/js/app/nginx/certificates/form.js new file mode 100644 index 00000000..9a7fa54f --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/form.js @@ -0,0 +1,158 @@ +'use strict'; + +const _ = require('underscore'); +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const CertificateModel = require('../../../models/certificate'); +const template = require('./form.ejs'); + +require('jquery-serializejson'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + max_file_size: 5120, + + ui: { + form: 'form', + domain_names: 'input[name="domain_names"]', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + other_certificate: '#other_certificate', + other_certificate_key: '#other_certificate_key', + other_intermediate_certificate: '#other_intermediate_certificate' + }, + + events: { + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + data.provider = this.model.get('provider'); + + // Manipulate + if (typeof data.meta !== 'undefined' && typeof data.meta.letsencrypt_agree !== 'undefined') { + data.meta.letsencrypt_agree = !!data.meta.letsencrypt_agree; + } + + if (typeof data.domain_names === 'string' && data.domain_names) { + data.domain_names = data.domain_names.split(','); + } + + let ssl_files = []; + + // check files are attached + if (this.model.get('provider') === 'other' && !this.model.hasSslFiles()) { + if (!this.ui.other_certificate[0].files.length || !this.ui.other_certificate[0].files[0].size) { + alert('Certificate file is not attached'); + return; + } else { + if (this.ui.other_certificate[0].files[0].size > this.max_file_size) { + alert('Certificate file is too large (> 5kb)'); + return; + } + ssl_files.push({name: 'certificate', file: this.ui.other_certificate[0].files[0]}); + } + + if (!this.ui.other_certificate_key[0].files.length || !this.ui.other_certificate_key[0].files[0].size) { + alert('Certificate key file is not attached'); + return; + } else { + if (this.ui.other_certificate_key[0].files[0].size > this.max_file_size) { + alert('Certificate key file is too large (> 5kb)'); + return; + } + ssl_files.push({name: 'certificate_key', file: this.ui.other_certificate_key[0].files[0]}); + } + + if (this.ui.other_intermediate_certificate[0].files.length && this.ui.other_intermediate_certificate[0].files[0].size) { + if (this.ui.other_intermediate_certificate[0].files[0].size > this.max_file_size) { + alert('Intermediate Certificate file is too large (> 5kb)'); + return; + } + ssl_files.push({name: 'intermediate_certificate', file: this.ui.other_intermediate_certificate[0].files[0]}); + } + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + + // compile file data + let form_data = new FormData(); + if (view.model.get('provider') && ssl_files.length) { + ssl_files.map(function (file) { + form_data.append(file.name, file.file); + }); + } + + new Promise(resolve => { + if (view.model.get('provider') === 'other') { + resolve(App.Api.Nginx.Certificates.validate(form_data)); + } else { + resolve(); + } + }) + .then(() => { + return App.Api.Nginx.Certificates.create(data); + }) + .then(result => { + view.model.set(result); + + // Now upload the certs if we need to + if (view.model.get('provider') === 'other') { + return App.Api.Nginx.Certificates.upload(view.model.get('id'), form_data) + .then(result => { + view.model.set('meta', _.assign({}, view.model.get('meta'), result)); + }); + } + }) + .then(() => { + App.UI.closeModal(function () { + App.Controller.showNginxCertificates(); + }); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + templateContext: { + getLetsencryptEmail: function () { + return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : App.Cache.User.get('email'); + }, + + getLetsencryptAgree: function () { + return typeof this.meta.letsencrypt_agree !== 'undefined' ? this.meta.letsencrypt_agree : false; + } + }, + + onRender: function () { + this.ui.domain_names.selectize({ + delimiter: ',', + persist: false, + maxOptions: 15, + create: function (input) { + return { + value: input, + text: input + }; + }, + createFilter: /^(?:[^.*]+\.?)+[^.]$/ + }); + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new CertificateModel.Model({provider: 'letsencrypt'}); + } + } +}); diff --git a/src/frontend/js/app/nginx/certificates/list/item.ejs b/src/frontend/js/app/nginx/certificates/list/item.ejs new file mode 100644 index 00000000..ad22fb51 --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/list/item.ejs @@ -0,0 +1,38 @@ + +
+ +
+ + +
+ <% if (provider === 'letsencrypt') { %> + <% domain_names.map(function(host) { + %> + <%- host %> + <% + }); + %> + <% } else { %> + <%- nice_name %> + <% } %> +
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + + <%- i18n('ssl', provider) %> + + + <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %> + +<% if (canManage) { %> + + + +<% } %> \ No newline at end of file diff --git a/src/frontend/js/app/nginx/certificates/list/item.js b/src/frontend/js/app/nginx/certificates/list/item.js new file mode 100644 index 00000000..d2897472 --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/list/item.js @@ -0,0 +1,33 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const moment = require('moment'); +const App = require('../../../main'); +const template = require('./item.ejs'); + +module.exports = Mn.View.extend({ + template: template, + tagName: 'tr', + + ui: { + delete: 'a.delete' + }, + + events: { + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxCertificateDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('certificates'), + isExpired: function () { + return moment(this.expires_on).isBefore(moment()); + } + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/nginx/certificates/list/main.ejs b/src/frontend/js/app/nginx/certificates/list/main.ejs new file mode 100644 index 00000000..aa49a27f --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/list/main.ejs @@ -0,0 +1,12 @@ + +   + <%- i18n('str', 'name') %> + <%- i18n('all-hosts', 'cert-provider') %> + <%- i18n('str', 'expires') %> + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/src/frontend/js/app/nginx/certificates/list/main.js b/src/frontend/js/app/nginx/certificates/list/main.js new file mode 100644 index 00000000..6472604c --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/list/main.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +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 + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('certificates') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/src/frontend/js/app/nginx/certificates/main.ejs b/src/frontend/js/app/nginx/certificates/main.ejs new file mode 100644 index 00000000..cc3624d5 --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/main.ejs @@ -0,0 +1,28 @@ +
+
+
+

<%- i18n('certificates', 'title') %>

+
+ + <% if (showAddButton) { %> + + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/src/frontend/js/app/nginx/certificates/main.js b/src/frontend/js/app/nginx/certificates/main.js new file mode 100644 index 00000000..03f795ad --- /dev/null +++ b/src/frontend/js/app/nginx/certificates/main.js @@ -0,0 +1,84 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const CertificateModel = require('../../../models/certificate'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-certificates', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + let model = new CertificateModel.Model({provider: $(e.currentTarget).data('cert')}); + App.Controller.showNginxCertificateForm(model); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp(App.i18n('certificates', 'help-title'), App.i18n('certificates', 'help-content')); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('certificates') + }, + + onRender: function () { + let view = this; + + App.Api.Nginx.Certificates.getAll(['owner']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new CertificateModel.Collection(response) + })); + } else { + let manage = App.Cache.User.canManage('certificates'); + + view.showChildView('list_region', new EmptyView({ + title: App.i18n('certificates', 'empty'), + subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), + link: manage ? App.i18n('certificates', 'add') : null, + btn_color: 'pink', + permission: 'certificates', + action: function () { + App.Controller.showNginxCertificateForm(); + } + })); + } + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxCertificates(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/app/nginx/dead/delete.ejs b/src/frontend/js/app/nginx/dead/delete.ejs new file mode 100644 index 00000000..cf720e86 --- /dev/null +++ b/src/frontend/js/app/nginx/dead/delete.ejs @@ -0,0 +1,23 @@ + diff --git a/src/frontend/js/app/nginx/dead/delete.js b/src/frontend/js/app/nginx/dead/delete.js new file mode 100644 index 00000000..81356f32 --- /dev/null +++ b/src/frontend/js/app/nginx/dead/delete.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Nginx.DeadHosts.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxDead(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/nginx/dead/form.ejs b/src/frontend/js/app/nginx/dead/form.ejs new file mode 100644 index 00000000..e014ec9b --- /dev/null +++ b/src/frontend/js/app/nginx/dead/form.ejs @@ -0,0 +1,86 @@ + diff --git a/src/frontend/js/app/nginx/dead/form.js b/src/frontend/js/app/nginx/dead/form.js new file mode 100644 index 00000000..821fd691 --- /dev/null +++ b/src/frontend/js/app/nginx/dead/form.js @@ -0,0 +1,168 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const DeadHostModel = require('../../../models/dead-host'); +const template = require('./form.ejs'); +const certListItemTemplate = require('../certificates-list-item.ejs'); +const Helpers = require('../../../lib/helpers'); + +require('jquery-serializejson'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + domain_names: 'input[name="domain_names"]', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + certificate_select: 'select[name="certificate_id"]', + ssl_forced: 'input[name="ssl_forced"]', + letsencrypt: '.letsencrypt' + }, + + events: { + 'change @ui.certificate_select': function () { + let id = this.ui.certificate_select.val(); + if (id === 'new') { + this.ui.letsencrypt.show().find('input').prop('disabled', false); + } else { + this.ui.letsencrypt.hide().find('input').prop('disabled', true); + } + + let enabled = id === 'new' || parseInt(id, 10) > 0; + this.ui.ssl_forced.prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5); + }, + + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + + // Manipulate + if (typeof data.ssl_forced !== 'undefined' && data.ssl_forced === '1') { + data.ssl_forced = true; + } + + if (typeof data.domain_names === 'string' && data.domain_names) { + data.domain_names = data.domain_names.split(','); + } + + // Check for any domain names containing wildcards, which are not allowed with letsencrypt + if (data.certificate_id === 'new') { + let domain_err = false; + data.domain_names.map(function (name) { + if (name.match(/\*/im)) { + domain_err = true; + } + }); + + if (domain_err) { + alert('Cannot request Let\'s Encrypt Certificate for wildcard domains'); + return; + } + + data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1'; + } else { + data.certificate_id = parseInt(data.certificate_id, 0); + } + + let method = App.Api.Nginx.DeadHosts.create; + let is_new = true; + + if (this.model.get('id')) { + // edit + is_new = false; + method = App.Api.Nginx.DeadHosts.update; + data.id = this.model.get('id'); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + method(data) + .then(result => { + view.model.set(result); + + App.UI.closeModal(function () { + if (is_new) { + App.Controller.showNginxDead(); + } + }); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + templateContext: { + getLetsencryptEmail: function () { + return App.Cache.User.get('email'); + } + }, + + onRender: function () { + let view = this; + + // Domain names + this.ui.domain_names.selectize({ + delimiter: ',', + persist: false, + maxOptions: 15, + create: function (input) { + return { + value: input, + text: input + }; + }, + createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/ + }); + + // Certificates + this.ui.letsencrypt.hide(); + this.ui.certificate_select.selectize({ + valueField: 'id', + labelField: 'nice_name', + searchField: ['nice_name', 'domain_names'], + create: false, + preload: true, + allowEmptyOption: true, + render: { + option: function (item) { + item.i18n = App.i18n; + item.formatDbDate = Helpers.formatDbDate; + return certListItemTemplate(item); + } + }, + load: function (query, callback) { + App.Api.Nginx.Certificates.getAll() + .then(rows => { + callback(rows); + }) + .catch(err => { + console.error(err); + callback(); + }); + }, + onLoad: function () { + view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id')); + } + }); + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new DeadHostModel.Model(); + } + } +}); diff --git a/src/frontend/js/app/nginx/dead/list/item.ejs b/src/frontend/js/app/nginx/dead/list/item.ejs new file mode 100644 index 00000000..47e1484f --- /dev/null +++ b/src/frontend/js/app/nginx/dead/list/item.ejs @@ -0,0 +1,45 @@ + +
+ +
+ + +
+ <% domain_names.map(function(host) { + %> + <%- host %> + <% + }); + %> +
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + +
<%- certificate ? i18n('ssl', certificate.provider) : i18n('ssl', 'none') %>
+ + + <% + var o = isOnline(); + if (o === true) { %> + <%- i18n('str', 'online') %> + <% } else if (o === false) { %> + <%- i18n('str', 'offline') %> + <% } else { %> + <%- i18n('str', 'unknown') %> + <% } %> + +<% if (canManage) { %> + + + +<% } %> \ No newline at end of file diff --git a/src/frontend/js/app/nginx/dead/list/item.js b/src/frontend/js/app/nginx/dead/list/item.js new file mode 100644 index 00000000..1fe9bc23 --- /dev/null +++ b/src/frontend/js/app/nginx/dead/list/item.js @@ -0,0 +1,43 @@ +'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', + delete: 'a.delete' + }, + + events: { + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showNginxDeadForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxDeadDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('dead_hosts'), + + isOnline: function () { + return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online; + }, + + getOfflineError: function () { + return this.meta.nginx_err || ''; + } + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/nginx/dead/list/main.ejs b/src/frontend/js/app/nginx/dead/list/main.ejs new file mode 100644 index 00000000..e018a74b --- /dev/null +++ b/src/frontend/js/app/nginx/dead/list/main.ejs @@ -0,0 +1,12 @@ + +   + <%- i18n('str', 'source') %> + <%- i18n('str', 'ssl') %> + <%- i18n('str', 'status') %> + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/src/frontend/js/app/nginx/dead/list/main.js b/src/frontend/js/app/nginx/dead/list/main.js new file mode 100644 index 00000000..c1833739 --- /dev/null +++ b/src/frontend/js/app/nginx/dead/list/main.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +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 + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('dead_hosts') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/src/frontend/js/app/nginx/dead/main.ejs b/src/frontend/js/app/nginx/dead/main.ejs new file mode 100644 index 00000000..508280ae --- /dev/null +++ b/src/frontend/js/app/nginx/dead/main.ejs @@ -0,0 +1,20 @@ +
+
+
+

<%- i18n('dead-hosts', 'title') %>

+
+ + <% if (showAddButton) { %> + <%- i18n('dead-hosts', 'add') %> + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/src/frontend/js/app/nginx/dead/main.js b/src/frontend/js/app/nginx/dead/main.js new file mode 100644 index 00000000..4741a61c --- /dev/null +++ b/src/frontend/js/app/nginx/dead/main.js @@ -0,0 +1,83 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const DeadHostModel = require('../../../models/dead-host'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-dead', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showNginxDeadForm(); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp(App.i18n('dead-hosts', 'help-title'), App.i18n('dead-hosts', 'help-content')); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('dead_hosts') + }, + + onRender: function () { + let view = this; + + App.Api.Nginx.DeadHosts.getAll(['owner', 'certificate']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new DeadHostModel.Collection(response) + })); + } else { + let manage = App.Cache.User.canManage('dead_hosts'); + + view.showChildView('list_region', new EmptyView({ + title: App.i18n('dead-hosts', 'empty'), + subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), + link: manage ? App.i18n('dead-hosts', 'add') : null, + btn_color: 'danger', + permission: 'dead_hosts', + action: function () { + App.Controller.showNginxDeadForm(); + } + })); + } + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxDead(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/app/nginx/proxy/access-list-item.ejs b/src/frontend/js/app/nginx/proxy/access-list-item.ejs new file mode 100644 index 00000000..9232d38b --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/access-list-item.ejs @@ -0,0 +1,13 @@ +
+ <% if (id > 0) { %> +
+ <%- name %> +
+ <%- i18n('access-lists', 'item-count', {count: items.length || 0}) %> – Created: <%- formatDbDate(created_on, 'Do MMMM YYYY, h:mm a') %> + <% } else { %> +
+ <%- i18n('access-lists', 'public') %> +
+ <%- i18n('access-lists', 'public-sub') %> + <% } %> +
diff --git a/src/frontend/js/app/nginx/proxy/delete.ejs b/src/frontend/js/app/nginx/proxy/delete.ejs new file mode 100644 index 00000000..2fe099fa --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/delete.ejs @@ -0,0 +1,23 @@ + diff --git a/src/frontend/js/app/nginx/proxy/delete.js b/src/frontend/js/app/nginx/proxy/delete.js new file mode 100644 index 00000000..3f739f8f --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/delete.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Nginx.ProxyHosts.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxProxy(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/nginx/proxy/form.ejs b/src/frontend/js/app/nginx/proxy/form.ejs new file mode 100644 index 00000000..0d0f45d2 --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/form.ejs @@ -0,0 +1,123 @@ + diff --git a/src/frontend/js/app/nginx/proxy/form.js b/src/frontend/js/app/nginx/proxy/form.js new file mode 100644 index 00000000..a4ad212c --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/form.js @@ -0,0 +1,213 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const ProxyHostModel = require('../../../models/proxy-host'); +const template = require('./form.ejs'); +const certListItemTemplate = require('../certificates-list-item.ejs'); +const accessListItemTemplate = require('./access-list-item.ejs'); +const Helpers = require('../../../lib/helpers'); + +require('jquery-serializejson'); +require('jquery-mask-plugin'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + domain_names: 'input[name="domain_names"]', + forward_ip: 'input[name="forward_ip"]', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + certificate_select: 'select[name="certificate_id"]', + access_list_select: 'select[name="access_list_id"]', + ssl_forced: 'input[name="ssl_forced"]', + letsencrypt: '.letsencrypt' + }, + + events: { + 'change @ui.certificate_select': function () { + let id = this.ui.certificate_select.val(); + if (id === 'new') { + this.ui.letsencrypt.show().find('input').prop('disabled', false); + } else { + this.ui.letsencrypt.hide().find('input').prop('disabled', true); + } + + let enabled = id === 'new' || parseInt(id, 10) > 0; + this.ui.ssl_forced.prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5); + }, + + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + + // Manipulate + data.forward_port = parseInt(data.forward_port, 10); + data.block_exploits = !!data.block_exploits; + data.caching_enabled = !!data.caching_enabled; + + if (typeof data.ssl_forced !== 'undefined' && data.ssl_forced === '1') { + data.ssl_forced = true; + } + + if (typeof data.domain_names === 'string' && data.domain_names) { + data.domain_names = data.domain_names.split(','); + } + + // Check for any domain names containing wildcards, which are not allowed with letsencrypt + if (data.certificate_id === 'new') { + let domain_err = false; + data.domain_names.map(function (name) { + if (name.match(/\*/im)) { + domain_err = true; + } + }); + + if (domain_err) { + alert('Cannot request Let\'s Encrypt Certificate for wildcard domains'); + return; + } + + data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1'; + } else { + data.certificate_id = parseInt(data.certificate_id, 0); + } + + let method = App.Api.Nginx.ProxyHosts.create; + let is_new = true; + + if (this.model.get('id')) { + // edit + is_new = false; + method = App.Api.Nginx.ProxyHosts.update; + data.id = this.model.get('id'); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + method(data) + .then(result => { + view.model.set(result); + + App.UI.closeModal(function () { + if (is_new) { + App.Controller.showNginxProxy(); + } + }); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + templateContext: { + getLetsencryptEmail: function () { + return App.Cache.User.get('email'); + } + }, + + onRender: function () { + let view = this; + + // IP Address + this.ui.forward_ip.mask('099.099.099.099', { + clearIfNotMatch: true, + placeholder: '000.000.000.000' + }); + + // Domain names + this.ui.domain_names.selectize({ + delimiter: ',', + persist: false, + maxOptions: 15, + create: function (input) { + return { + value: input, + text: input + }; + }, + createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/ + }); + + // Access Lists + this.ui.letsencrypt.hide(); + this.ui.access_list_select.selectize({ + valueField: 'id', + labelField: 'name', + searchField: ['name'], + create: false, + preload: true, + allowEmptyOption: true, + render: { + option: function (item) { + item.i18n = App.i18n; + item.formatDbDate = Helpers.formatDbDate; + return accessListItemTemplate(item); + } + }, + load: function (query, callback) { + App.Api.Nginx.AccessLists.getAll(['items']) + .then(rows => { + callback(rows); + }) + .catch(err => { + console.error(err); + callback(); + }); + }, + onLoad: function () { + view.ui.access_list_select[0].selectize.setValue(view.model.get('access_list_id')); + } + }); + + // Certificates + this.ui.letsencrypt.hide(); + this.ui.certificate_select.selectize({ + valueField: 'id', + labelField: 'nice_name', + searchField: ['nice_name', 'domain_names'], + create: false, + preload: true, + allowEmptyOption: true, + render: { + option: function (item) { + item.i18n = App.i18n; + item.formatDbDate = Helpers.formatDbDate; + return certListItemTemplate(item); + } + }, + load: function (query, callback) { + App.Api.Nginx.Certificates.getAll() + .then(rows => { + callback(rows); + }) + .catch(err => { + console.error(err); + callback(); + }); + }, + onLoad: function () { + view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id')); + } + }); + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new ProxyHostModel.Model(); + } + } +}); diff --git a/src/frontend/js/app/nginx/proxy/list/item.ejs b/src/frontend/js/app/nginx/proxy/list/item.ejs new file mode 100644 index 00000000..f68f93ec --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/list/item.ejs @@ -0,0 +1,51 @@ + +
+ +
+ + +
+ <% domain_names.map(function(host) { + %> + <%- host %> + <% + }); + %> +
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + +
<%- forward_ip %>:<%- forward_port %>
+ + +
<%- certificate && certificate_id ? i18n('ssl', certificate.provider) : i18n('ssl', 'none') %>
+ + +
<%- access_list_id ? access_list.name : i18n('str', 'public') %>
+ + + <% + var o = isOnline(); + if (o === true) { %> + <%- i18n('str', 'online') %> + <% } else if (o === false) { %> + <%- i18n('str', 'offline') %> + <% } else { %> + <%- i18n('str', 'unknown') %> + <% } %> + +<% if (canManage) { %> + + + +<% } %> \ No newline at end of file diff --git a/src/frontend/js/app/nginx/proxy/list/item.js b/src/frontend/js/app/nginx/proxy/list/item.js new file mode 100644 index 00000000..a9c07451 --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/list/item.js @@ -0,0 +1,43 @@ +'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', + delete: 'a.delete' + }, + + events: { + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showNginxProxyForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxProxyDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('proxy_hosts'), + + isOnline: function () { + return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online; + }, + + getOfflineError: function () { + return this.meta.nginx_err || ''; + } + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/nginx/proxy/list/main.ejs b/src/frontend/js/app/nginx/proxy/list/main.ejs new file mode 100644 index 00000000..6de5b9c6 --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/list/main.ejs @@ -0,0 +1,14 @@ + +   + <%- i18n('str', 'source') %> + <%- i18n('str', 'destination') %> + <%- i18n('str', 'ssl') %> + <%- i18n('str', 'access') %> + <%- i18n('str', 'status') %> + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/src/frontend/js/app/nginx/proxy/list/main.js b/src/frontend/js/app/nginx/proxy/list/main.js new file mode 100644 index 00000000..64896c1f --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/list/main.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +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 + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('proxy_hosts') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/src/frontend/js/app/nginx/proxy/main.ejs b/src/frontend/js/app/nginx/proxy/main.ejs new file mode 100644 index 00000000..a5114de6 --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/main.ejs @@ -0,0 +1,20 @@ +
+
+
+

<%- i18n('proxy-hosts', 'title') %>

+
+ + <% if (showAddButton) { %> + <%- i18n('proxy-hosts', 'add') %> + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/src/frontend/js/app/nginx/proxy/main.js b/src/frontend/js/app/nginx/proxy/main.js new file mode 100644 index 00000000..10139ac0 --- /dev/null +++ b/src/frontend/js/app/nginx/proxy/main.js @@ -0,0 +1,83 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const ProxyHostModel = require('../../../models/proxy-host'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-proxy', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showNginxProxyForm(); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp(App.i18n('proxy-hosts', 'help-title'), App.i18n('proxy-hosts', 'help-content')); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('proxy_hosts') + }, + + onRender: function () { + let view = this; + + App.Api.Nginx.ProxyHosts.getAll(['owner', 'access_list', 'certificate']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new ProxyHostModel.Collection(response) + })); + } else { + let manage = App.Cache.User.canManage('proxy_hosts'); + + view.showChildView('list_region', new EmptyView({ + title: App.i18n('proxy-hosts', 'empty'), + subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), + link: manage ? App.i18n('proxy-hosts', 'add') : null, + btn_color: 'success', + permission: 'proxy_hosts', + action: function () { + App.Controller.showNginxProxyForm(); + } + })); + } + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxProxy(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/app/nginx/redirection/delete.ejs b/src/frontend/js/app/nginx/redirection/delete.ejs new file mode 100644 index 00000000..8353d1b7 --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/delete.ejs @@ -0,0 +1,23 @@ + diff --git a/src/frontend/js/app/nginx/redirection/delete.js b/src/frontend/js/app/nginx/redirection/delete.js new file mode 100644 index 00000000..430f9c48 --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/delete.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Nginx.RedirectionHosts.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxRedirection(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/nginx/redirection/form.ejs b/src/frontend/js/app/nginx/redirection/form.ejs new file mode 100644 index 00000000..1dceb53b --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/form.ejs @@ -0,0 +1,110 @@ + diff --git a/src/frontend/js/app/nginx/redirection/form.js b/src/frontend/js/app/nginx/redirection/form.js new file mode 100644 index 00000000..9c1ec314 --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/form.js @@ -0,0 +1,171 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const RedirectionHostModel = require('../../../models/redirection-host'); +const template = require('./form.ejs'); +const certListItemTemplate = require('../certificates-list-item.ejs'); +const Helpers = require('../../../lib/helpers'); + +require('jquery-serializejson'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + domain_names: 'input[name="domain_names"]', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + certificate_select: 'select[name="certificate_id"]', + ssl_forced: 'input[name="ssl_forced"]', + letsencrypt: '.letsencrypt' + }, + + events: { + 'change @ui.certificate_select': function () { + let id = this.ui.certificate_select.val(); + if (id === 'new') { + this.ui.letsencrypt.show().find('input').prop('disabled', false); + } else { + this.ui.letsencrypt.hide().find('input').prop('disabled', true); + } + + let enabled = id === 'new' || parseInt(id, 10) > 0; + this.ui.ssl_forced.prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5); + }, + + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + + // Manipulate + data.block_exploits = !!data.block_exploits; + data.preserve_path = !!data.preserve_path; + + if (typeof data.ssl_forced !== 'undefined' && data.ssl_forced === '1') { + data.ssl_forced = true; + } + + if (typeof data.domain_names === 'string' && data.domain_names) { + data.domain_names = data.domain_names.split(','); + } + + // Check for any domain names containing wildcards, which are not allowed with letsencrypt + if (data.certificate_id === 'new') { + let domain_err = false; + data.domain_names.map(function (name) { + if (name.match(/\*/im)) { + domain_err = true; + } + }); + + if (domain_err) { + alert('Cannot request Let\'s Encrypt Certificate for wildcard domains'); + return; + } + + data.meta.letsencrypt_agree = data.meta.letsencrypt_agree === '1'; + } else { + data.certificate_id = parseInt(data.certificate_id, 0); + } + + let method = App.Api.Nginx.RedirectionHosts.create; + let is_new = true; + + if (this.model.get('id')) { + // edit + is_new = false; + method = App.Api.Nginx.RedirectionHosts.update; + data.id = this.model.get('id'); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + method(data) + .then(result => { + view.model.set(result); + + App.UI.closeModal(function () { + if (is_new) { + App.Controller.showNginxRedirection(); + } + }); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + templateContext: { + getLetsencryptEmail: function () { + return App.Cache.User.get('email'); + } + }, + + onRender: function () { + let view = this; + + // Domain names + this.ui.domain_names.selectize({ + delimiter: ',', + persist: false, + maxOptions: 15, + create: function (input) { + return { + value: input, + text: input + }; + }, + createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/ + }); + + // Certificates + this.ui.letsencrypt.hide(); + this.ui.certificate_select.selectize({ + valueField: 'id', + labelField: 'nice_name', + searchField: ['nice_name', 'domain_names'], + create: false, + preload: true, + allowEmptyOption: true, + render: { + option: function (item) { + item.i18n = App.i18n; + item.formatDbDate = Helpers.formatDbDate; + return certListItemTemplate(item); + } + }, + load: function (query, callback) { + App.Api.Nginx.Certificates.getAll() + .then(rows => { + callback(rows); + }) + .catch(err => { + console.error(err); + callback(); + }); + }, + onLoad: function () { + view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id')); + } + }); + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new RedirectionHostModel.Model(); + } + } +}); diff --git a/src/frontend/js/app/nginx/redirection/list/item.ejs b/src/frontend/js/app/nginx/redirection/list/item.ejs new file mode 100644 index 00000000..0fb2f3e6 --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/list/item.ejs @@ -0,0 +1,48 @@ + +
+ +
+ + +
+ <% domain_names.map(function(host) { + %> + <%- host %> + <% + }); + %> +
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + +
<%- forward_domain_name %>
+ + +
<%- certificate ? i18n('ssl', certificate.provider) : i18n('ssl', 'none') %>
+ + + <% + var o = isOnline(); + if (o === true) { %> + <%- i18n('str', 'online') %> + <% } else if (o === false) { %> + <%- i18n('str', 'offline') %> + <% } else { %> + <%- i18n('str', 'unknown') %> + <% } %> + +<% if (canManage) { %> + + + +<% } %> diff --git a/src/frontend/js/app/nginx/redirection/list/item.js b/src/frontend/js/app/nginx/redirection/list/item.js new file mode 100644 index 00000000..6bbb3c0f --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/list/item.js @@ -0,0 +1,43 @@ +'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', + delete: 'a.delete' + }, + + events: { + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showNginxRedirectionForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxRedirectionDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('redirection_hosts'), + + isOnline: function () { + return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online; + }, + + getOfflineError: function () { + return this.meta.nginx_err || ''; + } + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/nginx/redirection/list/main.ejs b/src/frontend/js/app/nginx/redirection/list/main.ejs new file mode 100644 index 00000000..15af827a --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/list/main.ejs @@ -0,0 +1,13 @@ + +   + <%- i18n('str', 'source') %> + <%- i18n('str', 'destination') %> + <%- i18n('str', 'ssl') %> + <%- i18n('str', 'status') %> + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/src/frontend/js/app/nginx/redirection/list/main.js b/src/frontend/js/app/nginx/redirection/list/main.js new file mode 100644 index 00000000..e51e03e6 --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/list/main.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +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 + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('redirection_hosts') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/src/frontend/js/app/nginx/redirection/main.ejs b/src/frontend/js/app/nginx/redirection/main.ejs new file mode 100644 index 00000000..4345a7e8 --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/main.ejs @@ -0,0 +1,20 @@ +
+
+
+

Redirection Hosts

+
+ + <% if (showAddButton) { %> + Add Redirection Host + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/src/frontend/js/app/nginx/redirection/main.js b/src/frontend/js/app/nginx/redirection/main.js new file mode 100644 index 00000000..1269fc54 --- /dev/null +++ b/src/frontend/js/app/nginx/redirection/main.js @@ -0,0 +1,83 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const RedirectionHostModel = require('../../../models/redirection-host'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-redirection', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showNginxRedirectionForm(); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp(App.i18n('redirection-hosts', 'help-title'), App.i18n('redirection-hosts', 'help-content')); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('proxy_hosts') + }, + + onRender: function () { + let view = this; + + App.Api.Nginx.RedirectionHosts.getAll(['owner', 'certificate']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new RedirectionHostModel.Collection(response) + })); + } else { + let manage = App.Cache.User.canManage('redirection_hosts'); + + view.showChildView('list_region', new EmptyView({ + title: App.i18n('redirection-hosts', 'empty'), + subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), + link: manage ? App.i18n('redirection-hosts', 'add') : null, + btn_color: 'yellow', + permission: 'redirection_hosts', + action: function () { + App.Controller.showNginxRedirectionForm(); + } + })); + } + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxRedirection(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/app/nginx/stream/delete.ejs b/src/frontend/js/app/nginx/stream/delete.ejs new file mode 100644 index 00000000..d7ba3a21 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/delete.ejs @@ -0,0 +1,19 @@ + diff --git a/src/frontend/js/app/nginx/stream/delete.js b/src/frontend/js/app/nginx/stream/delete.js new file mode 100644 index 00000000..8e0c57b8 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/delete.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Nginx.Streams.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxStream(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/nginx/stream/form.ejs b/src/frontend/js/app/nginx/stream/form.ejs new file mode 100644 index 00000000..b0a72e48 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/form.ejs @@ -0,0 +1,55 @@ + diff --git a/src/frontend/js/app/nginx/stream/form.js b/src/frontend/js/app/nginx/stream/form.js new file mode 100644 index 00000000..260a8ce1 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/form.js @@ -0,0 +1,94 @@ +'use strict'; + +const _ = require('underscore'); +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const StreamModel = require('../../../models/stream'); +const template = require('./form.ejs'); + +require('jquery-serializejson'); +require('jquery-mask-plugin'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + forward_ip: 'input[name="forward_ip"]', + type_error: '.forward-type-error', + buttons: '.modal-footer button', + switches: '.custom-switch-input', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + 'change @ui.switches': function () { + this.ui.type_error.hide(); + }, + + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + + if (!data.tcp_forwarding && !data.udp_forwarding) { + this.ui.type_error.show(); + return; + } + + // Manipulate + data.incoming_port = parseInt(data.incoming_port, 10); + data.forwarding_port = parseInt(data.forwarding_port, 10); + data.tcp_forwarding = !!data.tcp_forwarding; + data.udp_forwarding = !!data.udp_forwarding; + + let method = App.Api.Nginx.Streams.create; + let is_new = true; + + if (this.model.get('id')) { + // edit + is_new = false; + method = App.Api.Nginx.Streams.update; + data.id = this.model.get('id'); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + method(data) + .then(result => { + view.model.set(result); + + App.UI.closeModal(function () { + if (is_new) { + App.Controller.showNginxStream(); + } + }); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + onRender: function () { + this.ui.forward_ip.mask('099.099.099.099', { + clearIfNotMatch: true, + placeholder: '000.000.000.000' + }); + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new StreamModel.Model(); + } + } +}); diff --git a/src/frontend/js/app/nginx/stream/list/item.ejs b/src/frontend/js/app/nginx/stream/list/item.ejs new file mode 100644 index 00000000..f39fb358 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/list/item.ejs @@ -0,0 +1,49 @@ + +
+ +
+ + +
+ <%- incoming_port %> +
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + +
<%- forward_ip %>:<%- forwarding_port %>
+ + +
+ <% if (tcp_forwarding) { %> + <%- i18n('streams', 'tcp') %> + <% } + if (udp_forwarding) { %> + <%- i18n('streams', 'udp') %> + <% } %> +
+ + + <% + var o = isOnline(); + if (o === true) { %> + <%- i18n('str', 'online') %> + <% } else if (o === false) { %> + <%- i18n('str', 'offline') %> + <% } else { %> + <%- i18n('str', 'unknown') %> + <% } %> + +<% if (canManage) { %> + + + +<% } %> \ No newline at end of file diff --git a/src/frontend/js/app/nginx/stream/list/item.js b/src/frontend/js/app/nginx/stream/list/item.js new file mode 100644 index 00000000..30735c81 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/list/item.js @@ -0,0 +1,43 @@ +'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', + delete: 'a.delete' + }, + + events: { + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showNginxStreamForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxStreamDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('streams'), + + isOnline: function () { + return typeof this.meta.nginx_online === 'undefined' ? null : this.meta.nginx_online; + }, + + getOfflineError: function () { + return this.meta.nginx_err || ''; + } + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/nginx/stream/list/main.ejs b/src/frontend/js/app/nginx/stream/list/main.ejs new file mode 100644 index 00000000..5304f614 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/list/main.ejs @@ -0,0 +1,13 @@ + +   + <%- i18n('streams', 'incoming-port') %> + <%- i18n('str', 'destination') %> + <%- i18n('streams', 'protocol') %> + <%- i18n('str', 'status') %> + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/src/frontend/js/app/nginx/stream/list/main.js b/src/frontend/js/app/nginx/stream/list/main.js new file mode 100644 index 00000000..905eaa9d --- /dev/null +++ b/src/frontend/js/app/nginx/stream/list/main.js @@ -0,0 +1,34 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +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 + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('streams') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/src/frontend/js/app/nginx/stream/main.ejs b/src/frontend/js/app/nginx/stream/main.ejs new file mode 100644 index 00000000..c01414ce --- /dev/null +++ b/src/frontend/js/app/nginx/stream/main.ejs @@ -0,0 +1,20 @@ +
+
+
+

<%- i18n('streams', 'title') %>

+
+ + <% if (showAddButton) { %> + <%- i18n('streams', 'add') %> + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/src/frontend/js/app/nginx/stream/main.js b/src/frontend/js/app/nginx/stream/main.js new file mode 100644 index 00000000..0dc99c02 --- /dev/null +++ b/src/frontend/js/app/nginx/stream/main.js @@ -0,0 +1,83 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const StreamModel = require('../../../models/stream'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-stream', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showNginxStreamForm(); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp(App.i18n('streams', 'help-title'), App.i18n('streams', 'help-content')); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('streams') + }, + + onRender: function () { + let view = this; + + App.Api.Nginx.Streams.getAll(['owner']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new StreamModel.Collection(response) + })); + } else { + let manage = App.Cache.User.canManage('streams'); + + view.showChildView('list_region', new EmptyView({ + title: App.i18n('streams', 'empty'), + subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}), + link: manage ? App.i18n('streams', 'add') : null, + btn_color: 'blue', + permission: 'streams', + action: function () { + App.Controller.showNginxStreamForm(); + } + })); + } + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxStream(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/app/router.js b/src/frontend/js/app/router.js new file mode 100644 index 00000000..f6b686f2 --- /dev/null +++ b/src/frontend/js/app/router.js @@ -0,0 +1,20 @@ +'use strict'; + +const AppRouter = require('marionette.approuter'); +const Controller = require('./controller'); + +module.exports = AppRouter.default.extend({ + controller: Controller, + appRoutes: { + users: 'showUsers', + logout: 'logout', + 'nginx/proxy': 'showNginxProxy', + 'nginx/redirection': 'showNginxRedirection', + 'nginx/404': 'showNginxDead', + 'nginx/stream': 'showNginxStream', + 'nginx/access': 'showNginxAccess', + 'nginx/certificates': 'showNginxCertificates', + 'audit-log': 'showAuditLog', + '*default': 'showDashboard' + } +}); diff --git a/src/frontend/js/app/tokens.js b/src/frontend/js/app/tokens.js new file mode 100644 index 00000000..fb85e9f6 --- /dev/null +++ b/src/frontend/js/app/tokens.js @@ -0,0 +1,128 @@ +'use strict'; + +const STORAGE_NAME = 'nginx-proxy-manager-tokens'; + +/** + * @returns {Array} + */ +const getStorageTokens = function () { + let json = window.localStorage.getItem(STORAGE_NAME); + if (json) { + try { + return JSON.parse(json); + } catch (err) { + return []; + } + } + + return []; +}; + +/** + * @param {Array} tokens + */ +const setStorageTokens = function (tokens) { + window.localStorage.setItem(STORAGE_NAME, JSON.stringify(tokens)); +}; + +const Tokens = { + + /** + * @returns {Integer} + */ + getTokenCount: () => { + return getStorageTokens().length; + }, + + /** + * @returns {Object} t,n + */ + getTopToken: () => { + let tokens = getStorageTokens(); + if (tokens && tokens.length) { + return tokens[0]; + } + + return null; + }, + + /** + * @returns {String} + */ + getNextTokenName: () => { + let tokens = getStorageTokens(); + if (tokens && tokens.length > 1 && typeof tokens[1] !== 'undefined' && typeof tokens[1].n !== 'undefined') { + return tokens[1].n; + } + + return null; + }, + + /** + * + * @param {String} token + * @param {String} [name] + * @returns {Integer} + */ + addToken: (token, name) => { + // Get top token and if it's the same, ignore this call + let top = Tokens.getTopToken(); + if (!top || top.t !== token) { + let tokens = getStorageTokens(); + tokens.unshift({t: token, n: name || null}); + setStorageTokens(tokens); + } + + return Tokens.getTokenCount(); + }, + + /** + * @param {String} token + * @returns {Boolean} + */ + setCurrentToken: token => { + let tokens = getStorageTokens(); + if (tokens.length) { + tokens[0].t = token; + setStorageTokens(tokens); + return true; + } + + return false; + }, + + /** + * @param {String} name + * @returns {Boolean} + */ + setCurrentName: name => { + let tokens = getStorageTokens(); + if (tokens.length) { + tokens[0].n = name; + setStorageTokens(tokens); + return true; + } + + return false; + }, + + /** + * @returns {Integer} + */ + dropTopToken: () => { + let tokens = getStorageTokens(); + tokens.shift(); + setStorageTokens(tokens); + return tokens.length; + }, + + /** + * + */ + clearTokens: () => { + window.localStorage.removeItem(STORAGE_NAME); + } + +}; + +module.exports = Tokens; diff --git a/src/frontend/js/app/ui/footer/main.ejs b/src/frontend/js/app/ui/footer/main.ejs new file mode 100644 index 00000000..562e71c2 --- /dev/null +++ b/src/frontend/js/app/ui/footer/main.ejs @@ -0,0 +1,16 @@ +
+ +
+ <%- i18n('main', 'version', {version: getVersion()}) %> + <%= i18n('footer', 'copy', {url: 'https://jc21.com?utm_source=nginx-proxy-manager'}) %> + <%= i18n('footer', 'theme', {url: 'https://tabler.github.io/?utm_source=nginx-proxy-manager'}) %> +
+
diff --git a/src/frontend/js/app/ui/footer/main.js b/src/frontend/js/app/ui/footer/main.js new file mode 100644 index 00000000..5d00cad1 --- /dev/null +++ b/src/frontend/js/app/ui/footer/main.js @@ -0,0 +1,16 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./main.ejs'); +const Cache = require('../../cache'); + +module.exports = Mn.View.extend({ + className: 'container', + template: template, + + templateContext: { + getVersion: function () { + return Cache.version || '0.0.0'; + } + } +}); diff --git a/src/frontend/js/app/ui/header/main.ejs b/src/frontend/js/app/ui/header/main.ejs new file mode 100644 index 00000000..92eae4cd --- /dev/null +++ b/src/frontend/js/app/ui/header/main.ejs @@ -0,0 +1,31 @@ + diff --git a/src/frontend/js/app/ui/header/main.js b/src/frontend/js/app/ui/header/main.js new file mode 100644 index 00000000..a914ae66 --- /dev/null +++ b/src/frontend/js/app/ui/header/main.js @@ -0,0 +1,69 @@ +'use strict'; + +const $ = require('jquery'); +const Mn = require('backbone.marionette'); +const i18n = require('../../i18n'); +const Cache = require('../../cache'); +const Controller = require('../../controller'); +const Tokens = require('../../tokens'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'header', + className: 'header', + template: template, + + ui: { + link: 'a', + details: 'a.edit-details', + password: 'a.change-password' + }, + + events: { + 'click @ui.details': function (e) { + e.preventDefault(); + Controller.showUserForm(Cache.User); + }, + + 'click @ui.password': function (e) { + e.preventDefault(); + Controller.showUserPasswordForm(Cache.User); + }, + + 'click @ui.link': function (e) { + e.preventDefault(); + let href = $(e.currentTarget).attr('href'); + + switch (href) { + case '/': + Controller.showDashboard(); + break; + case '/logout': + Controller.logout(); + break; + } + } + }, + + templateContext: { + getUserField: function (field, default_val) { + return Cache.User.get(field) || default_val; + }, + + getRole: function () { + return i18n('roles', Cache.User.isAdmin() ? 'admin' : 'user'); + }, + + getLogoutText: function () { + if (Tokens.getTokenCount() > 1) { + return i18n('main', 'sign-in-as', {name: Tokens.getNextTokenName()}); + } + + return i18n('str', 'sign-out'); + } + }, + + initialize: function () { + this.listenTo(Cache.User, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/ui/main.ejs b/src/frontend/js/app/ui/main.ejs new file mode 100644 index 00000000..7c97cf7d --- /dev/null +++ b/src/frontend/js/app/ui/main.ejs @@ -0,0 +1,19 @@ +
+ + +
+
+ +
+
+
+ +
+ +
+ + diff --git a/src/frontend/js/app/ui/main.js b/src/frontend/js/app/ui/main.js new file mode 100644 index 00000000..4783358d --- /dev/null +++ b/src/frontend/js/app/ui/main.js @@ -0,0 +1,100 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./main.ejs'); +const HeaderView = require('./header/main'); +const MenuView = require('./menu/main'); +const FooterView = require('./footer/main'); +const Cache = require('../cache'); + +module.exports = Mn.View.extend({ + id: 'app', + className: 'page', + template: template, + modal_setup: false, + + modal: null, + + ui: { + modal: '#modal-dialog' + }, + + regions: { + header_region: { + el: '#header', + replaceElement: true + }, + menu_region: { + el: '#menu', + replaceElement: true + }, + footer_region: '.footer', + app_content_region: '#app-content', + modal_region: '#modal-dialog' + }, + + /** + * @param {Object} view + */ + showAppContent: function (view) { + this.showChildView('app_content_region', view); + }, + + /** + * @param {Object} view + * @param {Function} [show_callback] + * @param {Function} [shown_callback] + */ + showModalDialog: function (view, show_callback, shown_callback) { + this.showChildView('modal_region', view); + let modal = this.getRegion('modal_region').$el.modal('show'); + + modal.on('hidden.bs.modal', function (/*e*/) { + if (show_callback) { + modal.off('show.bs.modal', show_callback); + } + + if (shown_callback) { + modal.off('shown.bs.modal', shown_callback); + } + + modal.off('hidden.bs.modal'); + view.destroy(); + }); + + if (show_callback) { + modal.on('show.bs.modal', show_callback); + } + + if (shown_callback) { + modal.on('shown.bs.modal', shown_callback); + } + }, + + /** + * + * @param {Function} [hidden_callback] + */ + closeModal: function (hidden_callback) { + let modal = this.getRegion('modal_region').$el.modal('hide'); + + if (hidden_callback) { + modal.on('hidden.bs.modal', hidden_callback); + } + }, + + onRender: function () { + this.showChildView('header_region', new HeaderView({ + model: Cache.User + })); + + this.showChildView('menu_region', new MenuView()); + this.showChildView('footer_region', new FooterView()); + }, + + reset: function () { + this.getRegion('header_region').reset(); + this.getRegion('footer_region').reset(); + this.getRegion('modal_region').reset(); + } +}); diff --git a/src/frontend/js/app/ui/menu/main.ejs b/src/frontend/js/app/ui/menu/main.ejs new file mode 100644 index 00000000..3363640c --- /dev/null +++ b/src/frontend/js/app/ui/menu/main.ejs @@ -0,0 +1,49 @@ +
+
+
+ +
+
+
diff --git a/src/frontend/js/app/ui/menu/main.js b/src/frontend/js/app/ui/menu/main.js new file mode 100644 index 00000000..43186541 --- /dev/null +++ b/src/frontend/js/app/ui/menu/main.js @@ -0,0 +1,41 @@ +'use strict'; + +const $ = require('jquery'); +const Mn = require('backbone.marionette'); +const Controller = require('../../controller'); +const Cache = require('../../cache'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'menu', + className: 'header collapse d-lg-flex p-0', + template: template, + + ui: { + links: 'a' + }, + + events: { + 'click @ui.links': function (e) { + let href = $(e.currentTarget).attr('href'); + if (href !== '#') { + e.preventDefault(); + Controller.navigate(href, true); + } + } + }, + + templateContext: { + isAdmin: function () { + return Cache.User.isAdmin(); + }, + + canShow: function (perm) { + return Cache.User.isAdmin() || Cache.User.canView(perm); + } + }, + + initialize: function () { + this.listenTo(Cache.User, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/user/delete.ejs b/src/frontend/js/app/user/delete.ejs new file mode 100644 index 00000000..484e2786 --- /dev/null +++ b/src/frontend/js/app/user/delete.ejs @@ -0,0 +1,19 @@ + diff --git a/src/frontend/js/app/user/delete.js b/src/frontend/js/app/user/delete.js new file mode 100644 index 00000000..87aa7d5e --- /dev/null +++ b/src/frontend/js/app/user/delete.js @@ -0,0 +1,36 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./delete.ejs'); +const App = require('../main'); + +require('jquery-serializejson'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + App.Api.Users.delete(this.model.get('id')) + .then(() => { + App.Controller.showUsers(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/user/form.ejs b/src/frontend/js/app/user/form.ejs new file mode 100644 index 00000000..7169dbf8 --- /dev/null +++ b/src/frontend/js/app/user/form.ejs @@ -0,0 +1,58 @@ + diff --git a/src/frontend/js/app/user/form.js b/src/frontend/js/app/user/form.js new file mode 100644 index 00000000..6fe88f09 --- /dev/null +++ b/src/frontend/js/app/user/form.js @@ -0,0 +1,110 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../main'); +const UserModel = require('../../models/user'); +const template = require('./form.ejs'); + +require('jquery-serializejson'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + error: '.secret-error' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + this.ui.error.hide(); + let view = this; + let data = this.ui.form.serializeJSON(); + + let show_password = this.model.get('email') === 'admin@example.com'; + + // admin@example.com is not allowed + if (data.email === 'admin@example.com') { + this.ui.error.text(App.i18n('users', 'default_error')).show(); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + return; + } + + // Manipulate + data.roles = []; + if ((this.model.get('id') === App.Cache.User.get('id') && this.model.isAdmin()) || (typeof data.is_admin !== 'undefined' && data.is_admin)) { + data.roles.push('admin'); + delete data.is_admin; + } + + data.is_disabled = typeof data.is_disabled !== 'undefined' ? !!data.is_disabled : false; + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + let method = App.Api.Users.create; + + if (this.model.get('id')) { + // edit + method = App.Api.Users.update; + data.id = this.model.get('id'); + } + + method(data) + .then(result => { + if (result.id === App.Cache.User.get('id')) { + App.Cache.User.set(result); + } + + if (view.model.get('id') !== App.Cache.User.get('id')) { + App.Controller.showUsers(); + } + + view.model.set(result); + App.UI.closeModal(function () { + if (method === App.Api.Users.create) { + // Show permissions dialog immediately + App.Controller.showUserPermissions(view.model); + } else if (show_password) { + App.Controller.showUserPasswordForm(view.model); + } + }); + }) + .catch(err => { + this.ui.error.text(err.message).show(); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + templateContext: function () { + let view = this; + + return { + isSelf: function () { + return view.model.get('id') === App.Cache.User.get('id'); + }, + + isAdmin: function () { + return App.Cache.User.isAdmin(); + }, + + isAdminUser: function () { + return view.model.isAdmin(); + }, + + isDisabled: function () { + return view.model.isDisabled(); + } + }; + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new UserModel.Model(); + } + } +}); diff --git a/src/frontend/js/app/user/password.ejs b/src/frontend/js/app/user/password.ejs new file mode 100644 index 00000000..7dd497d1 --- /dev/null +++ b/src/frontend/js/app/user/password.ejs @@ -0,0 +1,30 @@ + diff --git a/src/frontend/js/app/user/password.js b/src/frontend/js/app/user/password.js new file mode 100644 index 00000000..7530ddbf --- /dev/null +++ b/src/frontend/js/app/user/password.js @@ -0,0 +1,60 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../main'); +const template = require('./password.ejs'); + +require('jquery-serializejson'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + error: '.secret-error' + }, + + events: { + 'click @ui.save': function (e) { + e.preventDefault(); + this.ui.error.hide(); + let form = this.ui.form.serializeJSON(); + + if (form.new_password1 !== form.new_password2) { + this.ui.error.text('Passwords do not match!').show(); + return; + } + + let data = { + type: 'password', + current: form.current_password, + secret: form.new_password1 + }; + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + App.Api.Users.setPassword(this.model.get('id'), data) + .then(() => { + App.UI.closeModal(); + App.Controller.showUsers(); + }) + .catch(err => { + this.ui.error.text(err.message).show(); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + isSelf: function () { + return App.Cache.User.get('id') === this.model.get('id'); + }, + + templateContext: function () { + return { + isSelf: this.isSelf.bind(this) + }; + } +}); diff --git a/src/frontend/js/app/user/permissions.ejs b/src/frontend/js/app/user/permissions.ejs new file mode 100644 index 00000000..b6161796 --- /dev/null +++ b/src/frontend/js/app/user/permissions.ejs @@ -0,0 +1,68 @@ + diff --git a/src/frontend/js/app/user/permissions.js b/src/frontend/js/app/user/permissions.js new file mode 100644 index 00000000..3621273a --- /dev/null +++ b/src/frontend/js/app/user/permissions.js @@ -0,0 +1,97 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../main'); +const UserModel = require('../../models/user'); +const template = require('./permissions.ejs'); + +require('jquery-serializejson'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + error: '.secret-error' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + let view = this; + let data = this.ui.form.serializeJSON(); + + // Manipulate + if (view.model.isAdmin()) { + // Force some attributes for admin + data = _.assign({}, data, { + access_lists: 'manage', + dead_hosts: 'manage', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + streams: 'manage', + certificates: 'manage' + }); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + + App.Api.Users.setPermissions(view.model.get('id'), data) + .then(() => { + if (view.model.get('id') === App.Cache.User.get('id')) { + App.Cache.User.set({permissions: data}); + } + + view.model.set({permissions: data}); + App.UI.closeModal(); + }) + .catch(err => { + this.ui.error.text(err.message).show(); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + templateContext: function () { + let perms = this.model.get('permissions'); + let is_admin = this.model.isAdmin(); + + return { + getPerm: function (key) { + if (perms !== null && typeof perms[key] !== 'undefined') { + return perms[key]; + } + + return null; + }, + + getPermProps: function (key, item, forced_admin) { + if (forced_admin && is_admin) { + return 'checked disabled'; + } else if (is_admin) { + return 'disabled'; + } else if (perms !== null && typeof perms[key] !== 'undefined' && perms[key] === item) { + return 'checked'; + } + + return ''; + }, + + isAdmin: function () { + return is_admin; + } + }; + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new UserModel.Model(); + } + } +}); diff --git a/src/frontend/js/app/users/list/item.ejs b/src/frontend/js/app/users/list/item.ejs new file mode 100644 index 00000000..e8699b59 --- /dev/null +++ b/src/frontend/js/app/users/list/item.ejs @@ -0,0 +1,44 @@ + +
+ +
+ + +
<%- name %>
+
+ <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %> +
+ + +
<%- email %>
+ + +
+ <% + var r = []; + roles.map(function(role) { + if (role) { + r.push(i18n('roles', role)); + } + }); + %> + <%- r.join(', ') %> +
+ + + + diff --git a/src/frontend/js/app/users/list/item.js b/src/frontend/js/app/users/list/item.js new file mode 100644 index 00000000..4b5b2c82 --- /dev/null +++ b/src/frontend/js/app/users/list/item.js @@ -0,0 +1,70 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const Tokens = require('../../tokens'); +const template = require('./item.ejs'); + +module.exports = Mn.View.extend({ + template: template, + tagName: 'tr', + + ui: { + edit: 'a.edit-user', + permissions: 'a.edit-permissions', + password: 'a.set-password', + login: 'a.login', + delete: 'a.delete-user' + }, + + events: { + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showUserForm(this.model); + }, + + 'click @ui.permissions': function (e) { + e.preventDefault(); + App.Controller.showUserPermissions(this.model); + }, + + 'click @ui.password': function (e) { + e.preventDefault(); + App.Controller.showUserPasswordForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showUserDeleteConfirm(this.model); + }, + + 'click @ui.login': function (e) { + e.preventDefault(); + + if (App.Cache.User.get('id') !== this.model.get('id')) { + this.ui.login.prop('disabled', true).addClass('btn-disabled'); + + App.Api.Users.loginAs(this.model.get('id')) + .then(res => { + Tokens.addToken(res.token, res.user.nickname || res.user.name); + window.location = '/'; + window.location.reload(); + }) + .catch(err => { + alert(err.message); + this.ui.login.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } + }, + + templateContext: { + isSelf: function () { + return App.Cache.User.get('id') === this.id; + } + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/src/frontend/js/app/users/list/main.ejs b/src/frontend/js/app/users/list/main.ejs new file mode 100644 index 00000000..c85c9cb1 --- /dev/null +++ b/src/frontend/js/app/users/list/main.ejs @@ -0,0 +1,10 @@ + +   + <%- i18n('str', 'name') %> + <%- i18n('str', 'email') %> + <%- i18n('str', 'roles') %> +   + + + + diff --git a/src/frontend/js/app/users/list/main.js b/src/frontend/js/app/users/list/main.js new file mode 100644 index 00000000..bbe75beb --- /dev/null +++ b/src/frontend/js/app/users/list/main.js @@ -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 + })); + } +}); diff --git a/src/frontend/js/app/users/main.ejs b/src/frontend/js/app/users/main.ejs new file mode 100644 index 00000000..8f0d3aaf --- /dev/null +++ b/src/frontend/js/app/users/main.ejs @@ -0,0 +1,18 @@ +
+
+
+

<%- i18n('users', 'title') %>

+ +
+
+
+
+
+ +
+
+ +
+
diff --git a/src/frontend/js/app/users/main.js b/src/frontend/js/app/users/main.js new file mode 100644 index 00000000..db4a389f --- /dev/null +++ b/src/frontend/js/app/users/main.js @@ -0,0 +1,57 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const App = require('../main'); +const UserModel = require('../../models/user'); +const ListView = require('./list/main'); +const ErrorView = require('../error/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'users', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + dimmer: '.dimmer' + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showUserForm(new UserModel.Model()); + } + }, + + onRender: function () { + let view = this; + + App.Api.Users.getAll(['permissions']) + .then(response => { + if (!view.isDestroyed() && response && response.length) { + view.showChildView('list_region', new ListView({ + collection: new UserModel.Collection(response) + })); + } + }) + .catch(err => { + view.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showUsers(); + } + })); + + console.error(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/src/frontend/js/i18n/messages.json b/src/frontend/js/i18n/messages.json new file mode 100644 index 00000000..f4100551 --- /dev/null +++ b/src/frontend/js/i18n/messages.json @@ -0,0 +1,217 @@ +{ + "en": { + "str": { + "email-address": "Email address", + "username": "Username", + "password": "Password", + "sign-in": "Sign in", + "sign-out": "Sign out", + "try-again": "Try again", + "name": "Name", + "email": "Email", + "roles": "Roles", + "created-on": "Created: {date}", + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "sure": "Yes I'm Sure", + "disabled": "Disabled", + "choose-file": "Choose file", + "source": "Source", + "destination": "Destination", + "ssl": "SSL", + "access": "Access", + "public": "Public", + "edit": "Edit", + "delete": "Delete", + "logs": "Logs", + "status": "Status", + "online": "Online", + "offline": "Offline", + "unknown": "Unknown", + "expires": "Expires" + }, + "login": { + "title": "Login to your account" + }, + "main": { + "app": "Nginx Proxy Manager", + "version": "v{version}", + "welcome": "Welcome to Nginx Proxy Manager", + "logged-in": "You are logged in as {name}", + "unknown-error": "Error loading stuff. Please reload the app.", + "unknown-user": "Unknown User", + "sign-in-as": "Sign back in as {name}" + }, + "roles": { + "title": "Roles", + "admin": "Administrator", + "user": "Apache Helicopter" + }, + "menu": { + "dashboard": "Dashboard", + "hosts": "Hosts" + }, + "footer": { + "fork-me": "Fork me on Github", + "copy": "© 2018 jc21.com.", + "theme": "Theme by Tabler" + }, + "dashboard": { + "title": "Hi {name}" + }, + "all-hosts": { + "empty-subtitle": "{manage, select, true{Why don't you create one?} other{And you don't have permission to create one.}}", + "details": "Details", + "enable-ssl": "Enable SSL", + "force-ssl": "Force SSL", + "domain-names": "Domain Names", + "cert-provider": "Certificate Provider", + "block-exploits": "Block Common Exploits", + "caching-enabled": "Cache Assets", + "ssl-certificate": "SSL Certificate", + "none": "None", + "new-cert": "Request a new SSL Certificate", + "with-le": "with Let's Encrypt", + "no-ssl": "This host will not use HTTPS", + "advanced": "Advanced", + "advanced-warning": "Enter your custom Nginx configuration here at your own risk!", + "advanced-config": "Custom Nginx Configuration" + }, + "ssl": { + "letsencrypt": "Let's Encrypt", + "other": "Custom", + "none": "HTTP only", + "letsencrypt-email": "Email Address for Let's Encrypt", + "letsencrypt-agree": "I Agree to the Let's Encrypt Terms of Service", + "delete-ssl": "The SSL certificates attached will NOT be removed, they will need to be removed manually.", + "hosts-warning": "These domains must be already configured to point to this installation" + }, + "proxy-hosts": { + "title": "Proxy Hosts", + "empty": "There are no Proxy Hosts", + "add": "Add Proxy Host", + "form-title": "{id, select, undefined{New} other{Edit}} Proxy Host", + "forward-ip": "Forward IP", + "forward-port": "Forward Port", + "delete": "Delete Proxy Host", + "delete-confirm": "Are you sure you want to delete the Proxy host for: {domains}?", + "help-title": "What is a Proxy Host?", + "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" + }, + "redirection-hosts": { + "title": "Redirection Hosts", + "empty": "There are no Redirection Hosts", + "add": "Add Redirection Host", + "form-title": "{id, select, undefined{New} other{Edit}} Redirection Host", + "forward-domain": "Forward Domain", + "preserve-path": "Preserve Path", + "delete": "Delete Proxy Host", + "delete-confirm": "Are you sure you want to delete the Redirection host for: {domains}?", + "help-title": "What is a Redirection Host?", + "help-content": "A Redirection Host will redirect requests from the incoming domain and push the viewer to another domain.\nThe most common reason to use this type of host is when your website changes domains but you still have search engine or referrer links pointing to the old domain." + }, + "dead-hosts": { + "title": "404 Hosts", + "empty": "There are no 404 Hosts", + "add": "Add 404 Host", + "form-title": "{id, select, undefined{New} other{Edit}} 404 Host", + "delete": "Delete 404 Host", + "delete-confirm": "Are you sure you want to delete this 404 Host?", + "help-title": "What is a 404 Host?", + "help-content": "A 404 Host is simply a host setup that shows a 404 page.\nThis can be useful when your domain is listed in search engines and you want to provide a nicer error page or specifically to tell the search indexers that the domain pages no longer exist.\nAnother benefit of having this host is to track the logs for hits to it and view the referrers." + }, + "streams": { + "title": "Streams", + "empty": "There are no Streams", + "add": "Add Stream", + "form-title": "{id, select, undefined{New} other{Edit}} Stream", + "incoming-port": "Incoming Port", + "forward-ip": "Forward IP", + "forwarding-port": "Forward Port", + "tcp-forwarding": "TCP Forwarding", + "udp-forwarding": "UDP Forwarding", + "forward-type-error": "At least one type of protocol must be enabled", + "protocol": "Protocol", + "tcp": "TCP", + "udp": "UDP", + "delete": "Delete Stream", + "delete-confirm": "Are you sure you want to delete this Stream?", + "help-title": "What is a Stream?", + "help-content": "A relatively new feature for Nginx, a Stream will serve to forward TCP/UDP traffic directly to another computer on the network.\nIf you're running game servers, FTP or SSH servers this can come in handy." + }, + "certificates": { + "title": "SSL Certificates", + "empty": "There are no SSL Certificates", + "add": "Add SSL Certificate", + "form-title": "Add {provider, select, letsencrypt{Let's Encrypt} other{Custom}} Certificate", + "delete": "Delete SSL Certificate", + "delete-confirm": "Are you sure you want to delete this SSL Certificate? Any hosts using it will need to be updated later.", + "help-title": "SSL Certificates", + "help-content": "TODO", + "other-certificate": "Certificate", + "other-certificate-key": "Certificate Key", + "other-intermediate-certificate": "Intermediate Certificate" + }, + "access-lists": { + "title": "Access Lists", + "empty": "There are no Access Lists", + "add": "Add Access List", + "form-title": "{id, select, undefined{New} other{Edit}} Access List", + "delete": "Delete Access List", + "delete-confirm": "Are you sure you want to delete this access list?", + "public": "Publicly Accessible", + "public-sub": "No Access Restrictions", + "help-title": "What is an Access List?", + "help-content": "Access Lists provide authentication for the Proxy Hosts via Basic HTTP Authentication.\nYou can configure multiple usernames and passwords for a single Access List and then apply that to a Proxy Host.\nThis is most useful for forwarded web services that do not have authentication mechanisms built in.", + "item-count": "{count} {count, select, 1{User} other{Users}}", + "proxy-host-count": "{count} {count, select, 1{Proxy Host} other{Proxy Hosts}}", + "delete-has-hosts": "This Access List is associated with {count} Proxy Hosts. They will become publicly available upon deletion." + }, + "users": { + "title": "Users", + "default_error": "Default email address must be changed", + "add": "Add User", + "nickname": "Nickname", + "full-name": "Full Name", + "edit-details": "Edit Details", + "change-password": "Change Password", + "edit-permissions": "Edit Permissions", + "sign-in-as": "Sign in as User", + "form-title": "{id, select, undefined{New} other{Edit}} User", + "delete": "Delete {name, select, undefined{User} other{{name}}}", + "delete-confirm": "Are you sure you want to delete {name}?", + "password-title": "Change Password{self, select, false{ for {name}} other{}}", + "current-password": "Current Password", + "new-password": "New Password", + "confirm-password": "Confirm Password", + "permissions-title": "Permissions for {name}", + "admin-perms": "This user is an Administrator and some items cannot be altered", + "perms-visibility": "Item Visibility", + "perms-visibility-user": "Created Items Only", + "perms-visibility-all": "All Items", + "perm-manage": "Manage", + "perm-view": "View Only", + "perm-hidden": "Hidden" + }, + "audit-log": { + "title": "Audit Log", + "empty": "There are no logs.", + "empty-subtitle": "As soon as you or another user changes something, history of those events will show up here.", + "proxy-host": "Proxy Host", + "redirection-host": "Redirection Host", + "dead-host": "404 Host", + "stream": "Stream", + "user": "User", + "certificate": "Certificate", + "access-list": "Access List", + "created": "Created {name}", + "updated": "Updated {name}", + "deleted": "Deleted {name}", + "meta-title": "Details for Event", + "view-meta": "View Details", + "date": "Date" + } + } +} diff --git a/src/frontend/js/index.js b/src/frontend/js/index.js new file mode 100644 index 00000000..bfaa0175 --- /dev/null +++ b/src/frontend/js/index.js @@ -0,0 +1,112 @@ +// This has to exist here so that Webpack picks it up +import '../scss/styles.scss'; + +window.tabler = { + colors: { + 'blue': '#467fcf', + 'blue-darkest': '#0e1929', + 'blue-darker': '#1c3353', + 'blue-dark': '#3866a6', + 'blue-light': '#7ea5dd', + 'blue-lighter': '#c8d9f1', + 'blue-lightest': '#edf2fa', + 'azure': '#45aaf2', + 'azure-darkest': '#0e2230', + 'azure-darker': '#1c4461', + 'azure-dark': '#3788c2', + 'azure-light': '#7dc4f6', + 'azure-lighter': '#c7e6fb', + 'azure-lightest': '#ecf7fe', + 'indigo': '#6574cd', + 'indigo-darkest': '#141729', + 'indigo-darker': '#282e52', + 'indigo-dark': '#515da4', + 'indigo-light': '#939edc', + 'indigo-lighter': '#d1d5f0', + 'indigo-lightest': '#f0f1fa', + 'purple': '#a55eea', + 'purple-darkest': '#21132f', + 'purple-darker': '#42265e', + 'purple-dark': '#844bbb', + 'purple-light': '#c08ef0', + 'purple-lighter': '#e4cff9', + 'purple-lightest': '#f6effd', + 'pink': '#f66d9b', + 'pink-darkest': '#31161f', + 'pink-darker': '#622c3e', + 'pink-dark': '#c5577c', + 'pink-light': '#f999b9', + 'pink-lighter': '#fcd3e1', + 'pink-lightest': '#fef0f5', + 'red': '#e74c3c', + 'red-darkest': '#2e0f0c', + 'red-darker': '#5c1e18', + 'red-dark': '#b93d30', + 'red-light': '#ee8277', + 'red-lighter': '#f8c9c5', + 'red-lightest': '#fdedec', + 'orange': '#fd9644', + 'orange-darkest': '#331e0e', + 'orange-darker': '#653c1b', + 'orange-dark': '#ca7836', + 'orange-light': '#feb67c', + 'orange-lighter': '#fee0c7', + 'orange-lightest': '#fff5ec', + 'yellow': '#f1c40f', + 'yellow-darkest': '#302703', + 'yellow-darker': '#604e06', + 'yellow-dark': '#c19d0c', + 'yellow-light': '#f5d657', + 'yellow-lighter': '#fbedb7', + 'yellow-lightest': '#fef9e7', + 'lime': '#7bd235', + 'lime-darkest': '#192a0b', + 'lime-darker': '#315415', + 'lime-dark': '#62a82a', + 'lime-light': '#a3e072', + 'lime-lighter': '#d7f2c2', + 'lime-lightest': '#f2fbeb', + 'green': '#5eba00', + 'green-darkest': '#132500', + 'green-darker': '#264a00', + 'green-dark': '#4b9500', + 'green-light': '#8ecf4d', + 'green-lighter': '#cfeab3', + 'green-lightest': '#eff8e6', + 'teal': '#2bcbba', + 'teal-darkest': '#092925', + 'teal-darker': '#11514a', + 'teal-dark': '#22a295', + 'teal-light': '#6bdbcf', + 'teal-lighter': '#bfefea', + 'teal-lightest': '#eafaf8', + 'cyan': '#17a2b8', + 'cyan-darkest': '#052025', + 'cyan-darker': '#09414a', + 'cyan-dark': '#128293', + 'cyan-light': '#5dbecd', + 'cyan-lighter': '#b9e3ea', + 'cyan-lightest': '#e8f6f8', + 'gray': '#868e96', + 'gray-darkest': '#1b1c1e', + 'gray-darker': '#36393c', + 'gray-light': '#aab0b6', + 'gray-lighter': '#dbdde0', + 'gray-lightest': '#f3f4f5', + 'gray-dark': '#343a40', + 'gray-dark-darkest': '#0a0c0d', + 'gray-dark-darker': '#15171a', + 'gray-dark-dark': '#2a2e33', + 'gray-dark-light': '#717579', + 'gray-dark-lighter': '#c2c4c6', + 'gray-dark-lightest': '#ebebec' + } +}; + +require('tabler-core'); + +const App = require('./app/main'); + +$(document).ready(() => { + App.start(); +}); diff --git a/src/frontend/js/lib/helpers.js b/src/frontend/js/lib/helpers.js new file mode 100644 index 00000000..578fe3ce --- /dev/null +++ b/src/frontend/js/lib/helpers.js @@ -0,0 +1,28 @@ +'use strict'; + +const numeral = require('numeral'); +const moment = require('moment'); + +module.exports = { + + /** + * @param {Integer} number + * @returns {String} + */ + niceNumber: function (number) { + return numeral(number).format('0,0'); + }, + + /** + * @param {String|Number} date + * @param {String} format + * @returns {String} + */ + formatDbDate: function (date, format) { + if (typeof date === 'number') { + return moment.unix(date).format(format); + } + + return moment(date).format(format); + } +}; diff --git a/src/frontend/js/lib/marionette.js b/src/frontend/js/lib/marionette.js new file mode 100644 index 00000000..c2989fde --- /dev/null +++ b/src/frontend/js/lib/marionette.js @@ -0,0 +1,17 @@ +'use strict'; + +const _ = require('underscore'); +const Mn = require('backbone.marionette'); +const i18n = require('../app/i18n'); +const Helpers = require('./helpers'); +const TemplateCache = require('marionette.templatecache'); + +Mn.setRenderer(function (template, data, view) { + data = _.clone(data); + data.i18n = i18n; + data.formatDbDate = Helpers.formatDbDate; + + return TemplateCache.default.render.call(this, template, data, view); +}); + +module.exports = Mn; diff --git a/src/frontend/js/login.js b/src/frontend/js/login.js new file mode 100644 index 00000000..0094e2a2 --- /dev/null +++ b/src/frontend/js/login.js @@ -0,0 +1,5 @@ +const App = require('./login/main'); + +$(document).ready(() => { + App.start(); +}); diff --git a/src/frontend/js/login/main.js b/src/frontend/js/login/main.js new file mode 100644 index 00000000..80d28660 --- /dev/null +++ b/src/frontend/js/login/main.js @@ -0,0 +1,17 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const LoginView = require('./ui/login'); + +const App = Mn.Application.extend({ + + region: '#login', + UI: null, + + onStart: function (/*app, options*/) { + this.getRegion().show(new LoginView()); + } +}); + +const app = new App(); +module.exports = app; diff --git a/src/frontend/js/login/ui/login.ejs b/src/frontend/js/login/ui/login.ejs new file mode 100644 index 00000000..ae8b1675 --- /dev/null +++ b/src/frontend/js/login/ui/login.ejs @@ -0,0 +1,26 @@ +
+
+ +
+
diff --git a/src/frontend/js/login/ui/login.js b/src/frontend/js/login/ui/login.js new file mode 100644 index 00000000..da1bc858 --- /dev/null +++ b/src/frontend/js/login/ui/login.js @@ -0,0 +1,44 @@ +'use strict'; + +const $ = require('jquery'); +const Mn = require('backbone.marionette'); +const template = require('./login.ejs'); +const Api = require('../../app/api'); +const i18n = require('../../app/i18n'); + +module.exports = Mn.View.extend({ + template: template, + className: 'page-single', + + ui: { + form: 'form', + identity: 'input[name="identity"]', + secret: 'input[name="secret"]', + error: '.secret-error', + button: 'button' + }, + + events: { + 'submit @ui.form': function (e) { + e.preventDefault(); + this.ui.button.addClass('btn-loading').prop('disabled', true); + this.ui.error.hide(); + + Api.Tokens.login(this.ui.identity.val(), this.ui.secret.val(), true) + .then(() => { + window.location = '/'; + }) + .catch(err => { + this.ui.error.text(err.message).show(); + this.ui.button.removeClass('btn-loading').prop('disabled', false); + }); + } + }, + + templateContext: { + i18n: i18n, + getVersion: function () { + return $('#login').data('version'); + } + } +}); diff --git a/src/frontend/js/models/access-list.js b/src/frontend/js/models/access-list.js new file mode 100644 index 00000000..60cd57dd --- /dev/null +++ b/src/frontend/js/models/access-list.js @@ -0,0 +1,26 @@ +'use strict'; + +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + created_on: null, + modified_on: null, + name: '', + items: [], + // The following are expansions: + owner: null + }; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +}; diff --git a/manager/src/frontend/js/models/access.js b/src/frontend/js/models/audit-log.js similarity index 75% rename from manager/src/frontend/js/models/access.js rename to src/frontend/js/models/audit-log.js index 2ab7bdb2..2918ff40 100644 --- a/manager/src/frontend/js/models/access.js +++ b/src/frontend/js/models/audit-log.js @@ -3,13 +3,11 @@ const Backbone = require('backbone'); const model = Backbone.Model.extend({ - idAttribute: '_id', + idAttribute: 'id', defaults: function () { return { - name: '', - items: [], - hosts: [] + name: '' }; } }); diff --git a/src/frontend/js/models/certificate.js b/src/frontend/js/models/certificate.js new file mode 100644 index 00000000..60df86c7 --- /dev/null +++ b/src/frontend/js/models/certificate.js @@ -0,0 +1,40 @@ +'use strict'; + +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + created_on: null, + modified_on: null, + provider: '', + nice_name: '', + domain_names: [], + expires_on: null, + meta: {}, + // The following are expansions: + owner: null, + proxy_hosts: [], + redirection_hosts: [], + dead_hosts: [] + }; + }, + + /** + * @returns {Boolean} + */ + hasSslFiles: function () { + let meta = this.get('meta'); + return typeof meta['certificate'] !== 'undefined' && meta['certificate'] && typeof meta['certificate_key'] !== 'undefined' && meta['certificate_key']; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +}; diff --git a/src/frontend/js/models/dead-host.js b/src/frontend/js/models/dead-host.js new file mode 100644 index 00000000..f6fdf47d --- /dev/null +++ b/src/frontend/js/models/dead-host.js @@ -0,0 +1,30 @@ +'use strict'; + +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + created_on: null, + modified_on: null, + domain_names: [], + certificate_id: 0, + ssl_forced: false, + meta: {}, + advanced_config: '', + // The following are expansions: + owner: null, + certificate: null + }; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +}; diff --git a/src/frontend/js/models/proxy-host.js b/src/frontend/js/models/proxy-host.js new file mode 100644 index 00000000..82ea623f --- /dev/null +++ b/src/frontend/js/models/proxy-host.js @@ -0,0 +1,36 @@ +'use strict'; + +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + created_on: null, + modified_on: null, + domain_names: [], + forward_ip: '', + forward_port: null, + access_list_id: 0, + certificate_id: 0, + ssl_forced: false, + caching_enabled: false, + block_exploits: false, + advanced_config: '', + meta: {}, + // The following are expansions: + owner: null, + access_list: null, + certificate: null + }; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +}; diff --git a/src/frontend/js/models/redirection-host.js b/src/frontend/js/models/redirection-host.js new file mode 100644 index 00000000..653eb945 --- /dev/null +++ b/src/frontend/js/models/redirection-host.js @@ -0,0 +1,33 @@ +'use strict'; + +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + created_on: null, + modified_on: null, + domain_names: [], + forward_domain_name: '', + preserve_path: true, + certificate_id: 0, + ssl_forced: false, + block_exploits: false, + advanced_config: '', + meta: {}, + // The following are expansions: + owner: null, + certificate: null + }; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +}; diff --git a/src/frontend/js/models/stream.js b/src/frontend/js/models/stream.js new file mode 100644 index 00000000..d7ec890f --- /dev/null +++ b/src/frontend/js/models/stream.js @@ -0,0 +1,30 @@ +'use strict'; + +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + created_on: null, + modified_on: null, + incoming_port: null, + forward_ip: null, + forwarding_port: null, + tcp_forwarding: true, + udp_forwarding: false, + meta: {}, + // The following are expansions: + owner: null + }; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +}; diff --git a/src/frontend/js/models/user.js b/src/frontend/js/models/user.js new file mode 100644 index 00000000..2fe809ef --- /dev/null +++ b/src/frontend/js/models/user.js @@ -0,0 +1,56 @@ +'use strict'; + +const _ = require('underscore'); +const Backbone = require('backbone'); + +const model = Backbone.Model.extend({ + idAttribute: 'id', + + defaults: function () { + return { + id: undefined, + name: '', + nickname: '', + email: '', + is_disabled: false, + roles: [], + permissions: null + }; + }, + + /** + * @returns {Boolean} + */ + isAdmin: function () { + return _.indexOf(this.get('roles'), 'admin') !== -1; + }, + + /** + * Checks if the perm has either `view` or `manage` value + * + * @param {String} item + * @returns {Boolean} + */ + canView: function (item) { + let permissions = this.get('permissions'); + return permissions !== null && typeof permissions[item] !== 'undefined' && ['view', 'manage'].indexOf(permissions[item]) !== -1; + }, + + /** + * Checks if the perm has `manage` value + * + * @param {String} item + * @returns {Boolean} + */ + canManage: function (item) { + let permissions = this.get('permissions'); + return permissions !== null && typeof permissions[item] !== 'undefined' && permissions[item] === 'manage'; + } +}); + +module.exports = { + Model: model, + Collection: Backbone.Collection.extend({ + model: model + }) +}; diff --git a/src/frontend/scss/custom.scss b/src/frontend/scss/custom.scss new file mode 100644 index 00000000..157d020f --- /dev/null +++ b/src/frontend/scss/custom.scss @@ -0,0 +1,29 @@ +$primary-color: #2bcbba; + +.loader { + color: $primary-color; +} + +a { + color: $primary-color; +} + +a:hover { + color: darken($primary-color, 10%); +} + +.dropdown-item.active, .dropdown-item:active { + background-color: $primary-color; +} + +.custom-switch-input:checked ~ .custom-switch-indicator { + background: $primary-color; +} + +.min-100 { + min-height: 100px; +} + +.card-options .dropdown-menu a:not(.btn) { + margin-left: 0; +} diff --git a/src/frontend/scss/selectize.scss b/src/frontend/scss/selectize.scss new file mode 100644 index 00000000..b6b3ac21 --- /dev/null +++ b/src/frontend/scss/selectize.scss @@ -0,0 +1,192 @@ +.selectize-dropdown-header { + position: relative; + padding: 5px 8px; + background: #f8f8f8; + border-bottom: 1px solid #d0d0d0; + -webkit-border-radius: 3px 3px 0 0; + -moz-border-radius: 3px 3px 0 0; + border-radius: 3px 3px 0 0; +} + +.selectize-dropdown-header-close { + position: absolute; + top: 50%; + right: 8px; + margin-top: -12px; + font-size: 20px !important; + line-height: 20px; + color: #303030; + opacity: 0.4; +} + +.selectize-dropdown-header-close:hover { + color: #000000; +} + +.selectize-dropdown.plugin-optgroup_columns .optgroup { + float: left; + border-top: 0 none; + border-right: 1px solid #f2f2f2; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child { + border-right: 0 none; +} + +.selectize-dropdown.plugin-optgroup_columns .optgroup:before { + display: none; +} + +.selectize-dropdown.plugin-optgroup_columns .optgroup-header { + border-top: 0 none; +} + +.selectize-control.plugin-remove_button [data-value] { + position: relative; + padding-right: 24px !important; +} + +.selectize-control.plugin-remove_button [data-value] .remove { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: inline-block; + width: 17px; + padding: 2px 0 0 0; + font-size: 12px; + font-weight: bold; + color: inherit; + text-align: center; + text-decoration: none; + vertical-align: middle; + border-left: 1px solid #0073bb; + -webkit-border-radius: 0 2px 2px 0; + -moz-border-radius: 0 2px 2px 0; + border-radius: 0 2px 2px 0; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.selectize-control.plugin-remove_button [data-value] .remove:hover { + background: rgba(0, 0, 0, 0.05); +} + +.selectize-control.plugin-remove_button [data-value].active .remove { + border-left-color: #00578d; +} + +.selectize-control.plugin-remove_button .disabled [data-value] .remove:hover { + background: none; +} + +.selectize-control.plugin-remove_button .disabled [data-value] .remove { + border-left-color: #aaaaaa; +} + +.selectize-control { + position: relative; +} + +.selectize-dropdown { + font-family: inherit; + font-size: 13px; + -webkit-font-smoothing: inherit; + line-height: 18px; + color: #303030; +} + +.selectize-control.single { + display: inline-block; + cursor: text; + background: #ffffff; +} + +.selectize-dropdown { + position: absolute; + z-index: 10; + margin: -1px 0 0 0; + background: #ffffff; + border: 1px solid #d0d0d0; + border-top: 0 none; + -webkit-border-radius: 0 0 3px 3px; + -moz-border-radius: 0 0 3px 3px; + border-radius: 0 0 3px 3px; + -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.selectize-dropdown [data-selectable] { + overflow: hidden; + cursor: pointer; +} + +.selectize-dropdown [data-selectable] .highlight { + background: rgba(125, 168, 208, 0.2); + -webkit-border-radius: 1px; + -moz-border-radius: 1px; + border-radius: 1px; +} + +.selectize-dropdown [data-selectable], +.selectize-dropdown .optgroup-header { + padding: 5px 8px; +} + +.selectize-dropdown .optgroup:first-child .optgroup-header { + border-top: 0 none; +} + +.selectize-dropdown .optgroup-header { + color: #303030; + cursor: default; + background: #ffffff; +} + +.selectize-dropdown .active { + color: #495c68; + background-color: #f5fafd; +} + +.selectize-dropdown .active.create { + color: #495c68; +} + +.selectize-dropdown .create { + color: rgba(48, 48, 48, 0.5); +} + +.selectize-dropdown-content { + max-height: 200px; + overflow-x: hidden; + overflow-y: auto; + + .title { + font-weight: bold; + } + + .description { + padding-left: 16px; + } +} + +.selectize-dropdown .optgroup-header { + padding-top: 7px; + font-size: 0.85em; + font-weight: bold; +} + +.selectize-dropdown .optgroup { + border-top: 1px solid #f0f0f0; +} + +.selectize-dropdown .optgroup:first-child { + border-top: 0 none; +} diff --git a/src/frontend/scss/styles.scss b/src/frontend/scss/styles.scss new file mode 100644 index 00000000..16b329c7 --- /dev/null +++ b/src/frontend/scss/styles.scss @@ -0,0 +1,16 @@ +@import "~tabler-ui/dist/assets/css/dashboard"; +@import "tabler-extra"; +@import "selectize"; +@import "custom"; + +/* Before any JS content is loaded */ +#app > .loader, #login > .loader, .container > .loader { + position: absolute; + left: 49%; + top: 40%; + display: block; +} + +.no-js-warning { + margin-top: 100px; +} diff --git a/src/frontend/scss/tabler-extra.scss b/src/frontend/scss/tabler-extra.scss new file mode 100644 index 00000000..69fe01f6 --- /dev/null +++ b/src/frontend/scss/tabler-extra.scss @@ -0,0 +1,125 @@ +$teal: #2bcbba; +$yellow: #f1c40f; +$blue: #467fcf; +$pink: #f66d9b; + +/* For Card bodies where I don't want padding */ +.card-body.no-padding { + padding: 0; +} + +/* Teal Outline Buttons */ +.btn-outline-teal { + color: $teal; + background-color: transparent; + background-image: none; + border-color: $teal; +} + +.btn-outline-teal:hover { + color: #fff; + background-color: $teal; + border-color: $teal; +} + +.btn-outline-teal:not(:disabled):not(.disabled):active, .btn-outline-teal:not(:disabled):not(.disabled).active, .show > .btn-outline-teal.dropdown-toggle { + color: #fff; + background-color: $teal; + border-color: $teal; +} + +/* Yellow Outline Buttons */ +.btn-outline-yellow { + color: $yellow; + background-color: transparent; + background-image: none; + border-color: $yellow; +} + +.btn-outline-yellow:hover { + color: #fff; + background-color: $yellow; + border-color: $yellow; +} + +.btn-outline-yellow:not(:disabled):not(.disabled):active, .btn-outline-yellow:not(:disabled):not(.disabled).active, .show > .btn-outline-yellow.dropdown-toggle { + color: #fff; + background-color: $yellow; + border-color: $yellow; +} + +/* Blue Outline Buttons */ +.btn-outline-blue { + color: $blue; + background-color: transparent; + background-image: none; + border-color: $blue; +} + +.btn-outline-blue:hover { + color: #fff; + background-color: $blue; + border-color: $blue; +} + +.btn-outline-blue:not(:disabled):not(.disabled):active, .btn-outline-blue:not(:disabled):not(.disabled).active, .show > .btn-outline-blue.dropdown-toggle { + color: #fff; + background-color: $blue; + border-color: $blue; +} + +/* Pink Outline Buttons */ +.btn-outline-pink { + color: $pink; + background-color: transparent; + background-image: none; + border-color: $pink; +} + +.btn-outline-pink:hover { + color: #fff; + background-color: $pink; + border-color: $pink; +} + +.btn-outline-pink:not(:disabled):not(.disabled):active, .btn-outline-pink:not(:disabled):not(.disabled).active, .show > .btn-outline-pink.dropdown-toggle { + color: #fff; + background-color: $pink; + border-color: $pink; +} + +/* dimmer */ + +.dimmer .loader { + margin-top: 50px; +} + +/* modal tabs */ + +.modal-body.has-tabs { + padding: 0; + + .nav-tabs { + margin: 0; + } + + .tab-content { + padding: 1rem; + } +} + +/* modal wide */ + +@media (min-width: 576px) { + .modal-dialog.wide { + max-width: 700px; + margin: 1.75rem auto; + } +} + + +/* Form mod */ + +textarea.form-control.text-monospace { + font-size: 12px; +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 00000000..30f658ca --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,148 @@ +const path = require('path'); +const webpack = require('webpack'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const Visualizer = require('webpack-visualizer-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +module.exports = { + entry: { + main: './src/frontend/js/index.js', + login: './src/frontend/js/login.js' + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'js/[name].js', + publicPath: '/' + }, + resolve: { + alias: { + 'tabler-core': 'tabler-ui/dist/assets/js/core', + 'bootstrap': 'tabler-ui/dist/assets/js/vendors/bootstrap.bundle.min', + 'sparkline': 'tabler-ui/dist/assets/js/vendors/jquery.sparkline.min', + 'selectize': 'tabler-ui/dist/assets/js/vendors/selectize.min', + 'tablesorter': 'tabler-ui/dist/assets/js/vendors/jquery.tablesorter.min', + 'vector-map': 'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-2.0.3.min', + 'vector-map-de': 'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-de-merc', + 'vector-map-world': 'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-world-mill', + 'circle-progress': 'tabler-ui/dist/assets/js/vendors/circle-progress.min', + 'c3': 'tabler-ui/dist/assets/js/vendors/chart.bundle.min' + } + }, + module: { + rules: [ + // Shims for tabler-ui + { + test: /assets\/js\/core/, + loader: 'imports-loader?bootstrap' + }, + { + test: /jquery-jvectormap-de-merc/, + loader: 'imports-loader?vector-map' + }, + { + test: /jquery-jvectormap-world-mill/, + loader: 'imports-loader?vector-map' + }, + + // other: + { + type: 'javascript/auto', // <= Set the module.type explicitly + test: /\bmessages\.json$/, + loader: 'messageformat-loader', + options: { + biDiSupport: false, + disablePluralKeyChecks: false, + formatters: null, + intlSupport: false, + locale: ['en'/*, 'es'*/], + strictNumberSign: false + } + }, + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader' + } + }, + { + test: /\.ejs$/, + loader: 'ejs-loader' + }, + { + test: /\.scss$/, + use: [ + MiniCssExtractPlugin.loader, + 'css-loader', + 'sass-loader' + ] + }, + { + test: /.*tabler.*\.(jpe?g|gif|png|svg|eot|woff|ttf)$/, + use: [ + { + loader: 'file-loader', + options: { + outputPath: 'assets/tabler-ui/' + } + } + ] + } + ] + }, + plugins: [ + new webpack.ProvidePlugin({ + $: 'jquery', + jQuery: 'jquery', + _: 'underscore' + }), + new MiniCssExtractPlugin({ + filename: 'css/[name].css', + chunkFilename: 'css/[id].css' + }), + new Visualizer({ + filename: '../webpack_stats.html' + }), + new CopyWebpackPlugin([{ + from: 'src/frontend/app-images', + to: 'images', + toType: 'dir', + context: '/app' + }]), + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, // Must be greater than or equal to one + minChunkSize: 999999999 + }) + ], + /* + optimization: { + splitChunks: { + chunks (chunk) { + // exclude `my-excluded-chunk` + return false; + }, + minSize: 999999999, + minChunks: 1, + name: true, + cacheGroups: { + vendors: { + test: /[\\/]node_modules[\\/]/, + priority: -10 + }, + default: { + minChunks: 2, + priority: -20, + reuseExistingChunk: true + } + } + } + }, + */ + devServer: { + contentBase: path.join(__dirname, 'dist'), + compress: true, + port: 8080, + disableHostCheck: true, + host: '0.0.0.0' + } +};