[
  {
    "path": ".babelrc",
    "content": "{\n  \"presets\": [\n    [\"env\", {\n      \"targets\": {\n        \"browsers\": [\"Chrome >= 65\"]\n      },\n      \"debug\": false,\n      \"modules\": false,\n      \"useBuiltIns\": \"usage\"\n    }]\n  ]\n}\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea\n._*\n.DS_Store\nnode_modules\ndist/*\npackage-lock.json\nyarn-error.log\nyarn.lock\nwebpack_stats.html\ntmp/*\n.env\n.yarnrc\n\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM jc21/node:latest\n\nMAINTAINER Jamie Curnow <jc@jc21.com>\nLABEL maintainer=\"Jamie Curnow <jc@jc21.com>\"\n\nRUN apt-get update \\\n    && apt-get install -y curl \\\n    && apt-get clean\n\nENV NODE_ENV=production\n\nADD dist                /app/dist\nADD node_modules        /app/node_modules\nADD LICENCE             /app/LICENCE\nADD package.json        /app/package.json\nADD src/backend         /app/src/backend\n\nWORKDIR /app\n\nCMD node --max_old_space_size=250 --abort_on_uncaught_exception src/backend/index.js\n\nHEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://localhost/ || exit 1\n\n"
  },
  {
    "path": "Jenkinsfile",
    "content": "pipeline {\n\tagent any\n\toptions {\n\t\tbuildDiscarder(logRotator(numToKeepStr: '10'))\n\t\tdisableConcurrentBuilds()\n\t}\n\tenvironment {\n\t\tIMAGE           = \"registry-ui\"\n\t\tTAG_VERSION     = getPackageVersion()\n\t\tBRANCH_LOWER    = \"${BRANCH_NAME.toLowerCase().replaceAll('/', '-')}\"\n\t\tBUILDX_NAME     = \"${COMPOSE_PROJECT_NAME}\"\n\t\tBASE_IMAGE_NAME = \"jc21/node:latest\"\n\t\tTEMP_IMAGE_NAME = \"${IMAGE}-build_${BUILD_NUMBER}\"\n\t}\n\tstages {\n\t\tstage('Prepare') {\n\t\t\tsteps {\n\t\t\t\tsh 'docker pull \"${BASE_IMAGE_NAME}\"'\n\t\t\t\tsh 'docker pull \"${DOCKER_CI_TOOLS}\"'\n\t\t\t}\n\t\t}\n\t\tstage('Build') {\n\t\t\tsteps {\n\t\t\t\t// Codebase\n\t\t\t\tsh 'docker run --rm -v $(pwd):/app -w /app \"${BASE_IMAGE_NAME}\" yarn install'\n\t\t\t\tsh 'docker run --rm -v $(pwd):/app -w /app \"${BASE_IMAGE_NAME}\" yarn build'\n\t\t\t\tsh 'docker run --rm -v $(pwd):/app -w /app \"${BASE_IMAGE_NAME}\" chown -R \"$(id -u):$(id -g)\" *'\n\t\t\t\tsh 'rm -rf node_modules'\n\t\t\t\tsh 'docker run --rm -v $(pwd):/app -w /app \"${BASE_IMAGE_NAME}\" yarn install --prod'\n\t\t\t\tsh 'docker run --rm -v $(pwd):/data \"${DOCKER_CI_TOOLS}\" node-prune'\n\n\t\t\t\t// Docker Build\n\t\t\t\tsh 'docker build --pull --no-cache --squash --compress -t \"${TEMP_IMAGE_NAME}\" .'\n\n\t\t\t\t// Zip it\n\t\t\t\tsh 'rm -rf zips'\n\t\t\t\tsh 'mkdir -p zips'\n\t\t\t\tsh '''docker run --rm -v \"$(pwd):/data/docker-registry-ui\" -w /data \"${DOCKER_CI_TOOLS}\" zip -qr \"/data/docker-registry-ui/zips/docker-registry-ui_${TAG_VERSION}.zip\" docker-registry-ui -x \\\\\n\t\t\t\t\t\t\\\\*.gitkeep \\\\\n\t\t\t\t\t\tdocker-registry-ui/zips\\\\* \\\\\n\t\t\t\t\t\tdocker-registry-ui/bin\\\\* \\\\\n\t\t\t\t\t\tdocker-registry-ui/src/frontend\\\\* \\\\\n\t\t\t\t\t\tdocker-registry-ui/tmp\\\\* \\\\\n\t\t\t\t\t\tdocker-registry-ui/node_modules\\\\* \\\\\n\t\t\t\t\t\tdocker-registry-ui/.git\\\\* \\\\\n\t\t\t\t\t\tdocker-registry-ui/.env \\\\\n\t\t\t\t\t\tdocker-registry-ui/.babelrc \\\\\n\t\t\t\t\t\tdocker-registry-ui/yarn\\\\* \\\\\n\t\t\t\t\t\tdocker-registry-ui/.gitignore \\\\\n\t\t\t\t\t\tdocker-registry-ui/Dockerfile \\\\\n\t\t\t\t\t\tdocker-registry-ui/nodemon.json \\\\\n\t\t\t\t\t\tdocker-registry-ui/webpack.config.js \\\\\n\t\t\t\t\t\tdocker-registry-ui/webpack_stats.html\n\t\t\t\t'''\n\t\t\t}\n\t\t\tpost {\n\t\t\t\talways {\n\t\t\t\t\tsh 'docker run --rm -v $(pwd):/app -w /app \"${BASE_IMAGE_NAME}\" chown -R \"$(id -u):$(id -g)\" *'\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tstage('Publish Develop') {\n\t\t\twhen {\n\t\t\t\tbranch 'develop'\n\t\t\t}\n\t\t\tsteps {\n\t\t\t\tsh 'docker tag \"${TEMP_IMAGE_NAME}\" \"jc21/${IMAGE}:develop\"'\n\t\t\t\twithCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {\n\t\t\t\t\tsh \"docker login -u '${duser}' -p '$dpass'\"\n\t\t\t\t\tsh 'docker push \"jc21/${IMAGE}:develop\"'\n\t\t\t\t}\n\n\t\t\t\t// Artifacts\n\t\t\t\tdir(path: 'zips') {\n\t\t\t\t\tarchiveArtifacts(artifacts: '**/*.zip', caseSensitive: true, onlyIfSuccessful: true)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tstage('Publish Master') {\n\t\t\twhen {\n\t\t\t\tbranch 'master'\n\t\t\t}\n\t\t\tsteps {\n\t\t\t\t// Public Registry\n\t\t\t\tsh 'docker tag \"${TEMP_IMAGE_NAME}\" \"jc21/${IMAGE}:latest\"'\n\t\t\t\tsh 'docker tag \"${TEMP_IMAGE_NAME}\" \"jc21/${IMAGE}:${TAG_VERSION}\"'\n\t\t\t\twithCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {\n\t\t\t\t\tsh \"docker login -u '${duser}' -p '$dpass'\"\n\t\t\t\t\tsh 'docker push \"jc21/${IMAGE}:latest\"'\n\t\t\t\t\tsh 'docker push \"jc21/${IMAGE}:${TAG_VERSION}\"'\n\t\t\t\t}\n\n\t\t\t\t// Artifacts\n\t\t\t\tdir(path: 'zips') {\n\t\t\t\t\tarchiveArtifacts(artifacts: '**/*.zip', caseSensitive: true, onlyIfSuccessful: true)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\ttriggers {\n\t\tbitbucketPush()\n\t}\n\tpost {\n\t\tsuccess {\n\t\t\tjuxtapose event: 'success'\n\t\t\tsh 'figlet \"SUCCESS\"'\n\t\t}\n\t\tfailure {\n\t\t\tjuxtapose event: 'failure'\n\t\t\tsh 'figlet \"FAILURE\"'\n\t\t}\n\t\talways {\n\t\t\tsh 'docker rmi \"${TEMP_IMAGE_NAME}\"'\n\t\t}\n\t}\n}\n\ndef getPackageVersion() {\n\tver = sh(script: 'docker run --rm -v $(pwd):/data \"${DOCKER_CI_TOOLS}\" bash -c \"cat /data/package.json|jq -r \\'.version\\'\"', returnStdout: true)\n\treturn ver.trim()\n}\n"
  },
  {
    "path": "LICENCE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2017 Jamie Curnow, Brisbane Australia (https://jc21.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "![Docker Registry UI](https://public.jc21.com/docker-registry-ui/github.png \"Docker Registry UI\")\n\n# Docker Registry UI\n\n![Version](https://img.shields.io/badge/version-2.0.2-green.svg)\n![Stars](https://img.shields.io/docker/stars/jc21/registry-ui.svg)\n![Pulls](https://img.shields.io/docker/pulls/jc21/registry-ui.svg)\n\nHave you ever wanted a visual website to show you the contents of your Docker Registry? Look no further. Now you can list your Images, Tags and info in style.\n\nThis project comes as a [pre-built docker image](https://hub.docker.com/r/jc21/registry-ui/) capable of connecting to another registry.\n\nNote: This project only works with Docker Registry v2.\n\n\n## Getting started\n\n### Creating a full Docker Registry Stack with this UI\n\nBy far the easiest way to get up and running. Refer to the example [docker-compose.yml](https://github.com/jc21/docker-registry-ui/blob/master/doc/full-stack/docker-compose.yml)\nexample file, put it on your Docker host and run:\n\n```bash\ndocker-compose up -d\n```\n\nThen hit your server on http://127.0.0.1\n\n\n### If you have your own Docker Registry to connect to\n\nHere's a `docker-compose.yml` for you:\n\n```bash\nversion: \"2\"\nservices:\n  app:\n    image: jc21/registry-ui\n    ports:\n      - 80:80\n    environment:\n      - REGISTRY_HOST=your-registry-server.com:5000\n      - REGISTRY_SSL=true\n      - REGISTRY_DOMAIN=your-registry-server.com:5000\n      - REGISTRY_STORAGE_DELETE_ENABLED=\n      - REGISTRY_USER=\n      - REGISTRY_PASS=\n    restart: on-failure\n```\n\nIf you are like most people and want your docker registry and your docker ui to co-exist on the same domain on the same port, please\nrefer to the Nginx configuration used by the [docker-registry-ui-proxy image](https://github.com/jc21/docker-registry-ui-proxy/blob/master/conf.d/proxy.conf)\nas an example. Note that there are some tweaks in there that you will need to be able to push successfully.\n\n\n## Environment Variables\n\n- **`REGISTRY_HOST`** - *Required:* The registry hostname and optional port to connect to for API calls\n- **`REGISTRY_SSL`** - *Optional:* Specify `true` for this if the registry is accessed via HTTPS\n- **`REGISTRY_DOMAIN`** - *Optional:* This is the registry domain to display in the UI for example push/pull code\n- **`REGISTRY_STORAGE_DELETE_ENABLED`** - *Optional:* Specify `true` or `1` to enable deletion features, but see below first!\n- **`REGISTRY_USER`** - *Optional:* If your docker registry is behind basic auth, specify the username\n- **`REGISTRY_PASS`** - *Optional:* If your docker registry is behind basic auth, specify the password\n\nRefer to the docker documentation for setting up [native basic auth](https://docs.docker.com/registry/deploying/#restricting-access).\n\n\n## Deletion Support\n\nRegistry deletion support sux. It is disabled by default in this project on purpose\nbecause you need to accomplish extra steps to get it up and running, sort of.\n\n#### Permit deleting on the Registry\n\nThis step is pretty simple and involves adding an environment variable to your Docker Registry Container when you start it up:\n\n```bash\ndocker run -d -p 5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true --name my-registry registry:2\n```\n\n#### Enabling Deletions in the UI\n\nSame as the Registry, just add the **`REGISTRY_STORAGE_DELETE_ENABLED=true`** environment variable to the `registry-ui` container. Note that `true` is the only\nacceptable value for this environment variable.\n\n\n#### Cleaning up the Registry\n\nWhen you delete an image from the registry this won't actually remove the blob layers as you might expect. For this reason you have to run this command on your docker registry host to perform garbage collection:\n\n```bash\ndocker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml\n```\n\nAnd if you wanted to make a cron job that runs every 30 mins:\n\n```\n0,30 * * * * /bin/docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml >> /dev/null 2>&1\n```\n\n\n## Screenshots\n\n[![Dashboard](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-1.jpg \"Dashboard\")](https://public.jc21.com/docker-registry-ui/screenshots/drui-1.jpg)\n[![Image](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-2.jpg \"Image\")](https://public.jc21.com/docker-registry-ui/screenshots/drui-2.jpg)\n[![Pulling](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-3.jpg \"Pulling\")](https://public.jc21.com/docker-registry-ui/screenshots/drui-3.jpg)\n[![Pushing](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-4.jpg \"Pushing\")](https://public.jc21.com/docker-registry-ui/screenshots/drui-4.jpg)\n\n\n## TODO\n\n- Add pagination to Repositories, currently only 300 images will be fetched\n- Add support for token based registry authentication mechanisms\n"
  },
  {
    "path": "bin/build",
    "content": "#!/bin/bash\n\nsudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script build\nexit $?\n"
  },
  {
    "path": "bin/build-dev",
    "content": "#!/bin/bash\n\nsudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script dev\nexit $?\n"
  },
  {
    "path": "bin/npm",
    "content": "#!/bin/bash\n\nsudo /usr/local/bin/docker-compose run --no-deps --rm app npm $@\nexit $?\n"
  },
  {
    "path": "bin/watch",
    "content": "#!/bin/bash\n\nsudo docker run --rm -it \\\n  -p 8124:8080 \\\n  -v $(pwd):/app \\\n  -w /app \\\n  jc21/node:latest npm run-script watch\n\nexit $?\n"
  },
  {
    "path": "bin/yarn",
    "content": "#!/bin/bash\n\nsudo /usr/local/bin/docker-compose run --no-deps --rm app yarn $@\nexit $?\n"
  },
  {
    "path": "doc/full-stack/docker-compose.yml",
    "content": "version: \"2\"\nservices:\n  registry:\n    image: registry:2\n    environment:\n      - REGISTRY_HTTP_SECRET=o43g2kjgn2iuhv2k4jn2f23f290qfghsdg\n      - REGISTRY_STORAGE_DELETE_ENABLED=\n    volumes:\n      - ./registry-data:/var/lib/registry\n  ui:\n    image: jc21/registry-ui\n    environment:\n      - NODE_ENV=production\n      - REGISTRY_HOST=registry:5000\n      - REGISTRY_SSL=\n      - REGISTRY_DOMAIN=\n      - REGISTRY_STORAGE_DELETE_ENABLED=\n    links:\n      - registry\n    restart: on-failure\n  proxy:\n    image: jc21/registry-ui-proxy\n    ports:\n      - 80:80\n    depends_on:\n      - ui\n      - registry\n    links:\n      - ui\n      - registry\n    restart: on-failure\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"2\"\nservices:\n  app:\n    image: jc21/node:latest\n    ports:\n      - 4000:80\n    environment:\n      - DEBUG=\n      - FORCE_COLOR=1\n      - NODE_ENV=development\n      - REGISTRY_HOST=${REGISTRY_HOST}\n      - REGISTRY_DOMAIN=${REGISTRY_HOST}\n      - REGISTRY_STORAGE_DELETE_ENABLED=true\n      - REGISTRY_SSL=${REGISTRY_SSL}\n      - REGISTRY_USER=${REGISTRY_USER}\n      - REGISTRY_PASS=${REGISTRY_PASS}\n    volumes:\n      - .:/app\n    working_dir: /app\n    command: node --max_old_space_size=250 --abort_on_uncaught_exception node_modules/nodemon/bin/nodemon.js\n\n"
  },
  {
    "path": "nodemon.json",
    "content": "{\n    \"verbose\": false,\n    \"ignore\": [\"dist\", \"data\", \"src/frontend\"],\n    \"ext\": \"js json ejs\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"docker-registry-ui\",\n    \"version\": \"2.0.2\",\n    \"description\": \"A nice web interface for managing your Docker Registry images\",\n    \"main\": \"src/backend/index.js\",\n    \"dependencies\": {\n        \"ajv\": \"^6.5.4\",\n        \"batchflow\": \"^0.4.0\",\n        \"body-parser\": \"^1.18.3\",\n        \"compression\": \"^1.7.3\",\n        \"config\": \"^2.0.1\",\n        \"ejs\": \"^2.6.1\",\n        \"express\": \"^4.16.4\",\n        \"express-winston\": \"^3.0.1\",\n        \"html-entities\": \"^1.2.1\",\n        \"json-schema-ref-parser\": \"^6.0.1\",\n        \"lodash\": \"^4.17.11\",\n        \"path\": \"^0.12.7\",\n        \"restler\": \"^3.4.0\",\n        \"signale\": \"^1.2.1\"\n    },\n    \"devDependencies\": {\n        \"babel-core\": \"^6.26.3\",\n        \"babel-loader\": \"^7.1.4\",\n        \"babel-minify-webpack-plugin\": \"^0.3.1\",\n        \"babel-preset-env\": \"^1.7.0\",\n        \"@hyperapp/html\": \"git+https://github.com/maxholman/hyperapp-html.git#5bde674d42c87bb8191f8cc11a8a3c7d334e3dfb\",\n        \"babel-plugin-transform-react-jsx\": \"^6.24.1\",\n        \"copy-webpack-plugin\": \"^4.5.4\",\n        \"css-loader\": \"^1.0.0\",\n        \"file-loader\": \"^2.0.0\",\n        \"html-loader\": \"^0.5.5\",\n        \"html-webpack-plugin\": \"^3.2.0\",\n        \"hyperapp\": \"^1.2.9\",\n        \"hyperapp-hash-router\": \"^0.1.0\",\n        \"imports-loader\": \"^0.8.0\",\n        \"jquery\": \"^3.3.1\",\n        \"jquery-serializejson\": \"^2.8.1\",\n        \"mini-css-extract-plugin\": \"^0.4.4\",\n        \"moment\": \"^2.22.2\",\n        \"node-sass\": \"^4.9.4\",\n        \"nodemon\": \"^1.18.4\",\n        \"numeral\": \"^2.0.6\",\n        \"sass-loader\": \"^7.1.0\",\n        \"style-loader\": \"^0.23.1\",\n        \"tabler-ui\": \"git+https://github.com/tabler/tabler.git#a09fd463309f2b395653e3615c98d1e8aca35b31\",\n        \"uglifyjs-webpack-plugin\": \"^2.0.1\",\n        \"webpack\": \"^4.12.0\",\n        \"webpack-cli\": \"^3.0.8\",\n        \"webpack-visualizer-plugin\": \"^0.1.11\"\n    },\n    \"scripts\": {\n        \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n        \"dev\": \"webpack --mode development\",\n        \"build\": \"webpack --mode production\",\n        \"watch\": \"webpack-dev-server --mode development\"\n    },\n    \"signale\": {\n        \"displayDate\": true,\n        \"displayTimestamp\": true\n    },\n    \"author\": \"\",\n    \"license\": \"MIT\"\n}\n"
  },
  {
    "path": "src/backend/app.js",
    "content": "'use strict';\n\nconst express     = require('express');\nconst bodyParser  = require('body-parser');\nconst compression = require('compression');\nconst log         = require('./logger').express;\n\n/**\n * App\n */\nconst app = express();\napp.use(bodyParser.json());\napp.use(bodyParser.urlencoded({extended: true}));\napp.use(compression());\n\n/**\n * General Logging, BEFORE routes\n */\napp.disable('x-powered-by');\napp.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);\napp.enable('strict routing');\n\n// pretty print JSON when not live\nif (process.env.NODE_ENV !== 'production') {\n    app.set('json spaces', 2);\n}\n\n// set the view engine to ejs\napp.set('view engine', 'ejs');\n\n// CORS for everything\napp.use(require('./lib/express/cors'));\n\n// General security/cache related headers + server header\napp.use(function (req, res, next) {\n    res.set({\n        'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload',\n        'X-XSS-Protection':          '0',\n        'X-Content-Type-Options':    'nosniff',\n        'X-Frame-Options':           'DENY',\n        'Cache-Control':             'no-cache, no-store, max-age=0, must-revalidate',\n        Pragma:                      'no-cache',\n        Expires:                     0\n    });\n    next();\n});\n\n/**\n * Routes\n */\napp.use('/assets', express.static('dist/assets'));\napp.use('/css', express.static('dist/css'));\napp.use('/fonts', express.static('dist/fonts'));\napp.use('/images', express.static('dist/images'));\napp.use('/js', express.static('dist/js'));\napp.use('/api', require('./routes/api/main'));\napp.use('/', require('./routes/main'));\n\n// production error handler\n// no stacktraces leaked to user\napp.use(function (err, req, res, next) {\n\n    let payload = {\n        error: {\n            code:    err.status,\n            message: err.public ? err.message : 'Internal Error'\n        }\n    };\n\n    if (process.env.NODE_ENV === 'development') {\n        payload.debug = {\n            stack:    typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\\n') : null,\n            previous: err.previous\n        };\n    }\n\n    // Not every error is worth logging - but this is good for now until it gets annoying.\n    if (typeof err.stack !== 'undefined' && err.stack) {\n        log.warn(err.stack);\n    }\n\n    res\n        .status(err.status || 500)\n        .send(payload);\n});\n\nmodule.exports = app;\n"
  },
  {
    "path": "src/backend/index.js",
    "content": "#!/usr/bin/env node\n\n'use strict';\n\nconst logger = require('./logger').global;\nconst config = require('config');\n\nlet port = process.env.PORT || 80;\n\nif (config.has('port')) {\n    port = config.get('port');\n}\n\nif (!process.env.REGISTRY_HOST) {\n    logger.error('Error: REGISTRY_HOST environment variable was not found!');\n    process.exit(1);\n}\n\nfunction appStart () {\n\n    const app          = require('./app');\n    const apiValidator = require('./lib/validator/api');\n\n    return apiValidator.loadSchemas\n        .then(() => {\n            const server = app.listen(port, () => {\n                logger.info('PID ' + process.pid + ' listening on port ' + port + ' ...');\n                logger.info('Registry Host: ' + process.env.REGISTRY_HOST);\n\n                process.on('SIGTERM', () => {\n                    logger.info('PID ' + process.pid + ' received SIGTERM');\n                    server.close(() => {\n                        logger.info('Stopping.');\n                        process.exit(0);\n                    });\n                });\n            });\n        })\n        .catch(err => {\n            logger.error(err.message);\n            setTimeout(appStart, 1000);\n        });\n}\n\ntry {\n    appStart();\n} catch (err) {\n    logger.error(err.message, err);\n    process.exit(1);\n}\n"
  },
  {
    "path": "src/backend/internal/repo.js",
    "content": "'use strict';\n\nconst REGISTRY_HOST = process.env.REGISTRY_HOST;\nconst REGISTRY_SSL  = process.env.REGISTRY_SSL && process.env.REGISTRY_SSL.toLowerCase() === 'true' || parseInt(process.env.REGISTRY_SSL, 10) === 1;\nconst REGISTRY_USER = process.env.REGISTRY_USER;\nconst REGISTRY_PASS = process.env.REGISTRY_PASS;\n\nconst _         = require('lodash');\nconst Docker    = require('../lib/docker-registry');\nconst batchflow = require('batchflow');\nconst registry  = new Docker(REGISTRY_HOST, REGISTRY_SSL, REGISTRY_USER, REGISTRY_PASS);\nconst errors    = require('../lib/error');\nconst logger    = require('../logger').registry;\n\nconst internalRepo = {\n\n    /**\n     * @param  {String}   name\n     * @param  {Boolean}  full\n     * @return {Promise}\n     */\n    get: (name, full) => {\n        return registry.getImageTags(name)\n            .then(tags_data => {\n                // detect errors\n                if (typeof tags_data.errors !== 'undefined' && tags_data.errors.length) {\n                    let top_err = tags_data.errors.shift();\n                    if (top_err.code === 'NAME_UNKNOWN') {\n                        throw new errors.ItemNotFoundError(name);\n                    } else {\n                        throw new errors.RegistryError(top_err.code, top_err.message);\n                    }\n                }\n\n                if (full && tags_data.tags !== null) {\n                    // Order the tags naturally, but put latest at the top if it exists\n                    let latest_idx = tags_data.tags.indexOf('latest');\n                    if (latest_idx !== -1) {\n                        _.pullAt(tags_data.tags, [latest_idx]);\n                    }\n\n                    // sort\n                    tags_data.tags = tags_data.tags.sort((a, b) => a.localeCompare(b));\n\n                    if (latest_idx !== -1) {\n                        tags_data.tags.unshift('latest');\n                    }\n\n                    return new Promise((resolve, reject) => {\n                        batchflow(tags_data.tags).sequential()\n                            .each((i, tag, next) => {\n                                // for each tag, we want to get 2 manifests.\n                                // Version 2 returns the layers and the correct image id\n                                // Version 1 returns the history we want to pluck from\n                                registry.getManifest(tags_data.name, tag, 2)\n                                    .then(manifest2_result => {\n                                        manifest2_result.name       = tag;\n                                        manifest2_result.image_name = name;\n\n                                        return registry.getManifest(tags_data.name, tag, 1)\n                                            .then(manifest1_result => {\n                                                manifest2_result.info = null;\n\n                                                if (typeof manifest1_result.history !== 'undefined' && manifest1_result.history.length) {\n                                                    let info = manifest1_result.history.shift();\n                                                    if (typeof info.v1Compatibility !== undefined) {\n                                                        info = JSON.parse(info.v1Compatibility);\n\n                                                        // Remove cruft\n                                                        if (typeof info.config !== 'undefined') {\n                                                            delete info.config;\n                                                        }\n\n                                                        if (typeof info.container_config !== 'undefined') {\n                                                            delete info.container_config;\n                                                        }\n                                                    }\n\n                                                    manifest2_result.info = info;\n                                                }\n\n                                                next(manifest2_result);\n                                            });\n                                    })\n                                    .catch(err => {\n                                        logger.error(err);\n                                        next(null);\n                                    });\n                            })\n                            .error(err => {\n                                reject(err);\n                            })\n                            .end(results => {\n                                tags_data.tags = results || null;\n                                resolve(tags_data);\n                            });\n                    });\n                } else {\n                    return tags_data;\n                }\n            });\n    },\n\n    /**\n     * All repos\n     *\n     * @param   {Boolean}   [with_tags]\n     * @returns {Promise}\n     */\n    getAll: with_tags => {\n        return registry.getImages()\n            .then(result => {\n                if (typeof result.errors !== 'undefined' && result.errors.length) {\n                    let first_err = result.errors.shift();\n                    throw new errors.RegistryError(first_err.code, first_err.message);\n                } else if (typeof result.repositories !== 'undefined') {\n                    let repositories = [];\n\n                    // sort images\n                    result.repositories = result.repositories.sort((a, b) => a.localeCompare(b));\n\n                    _.map(result.repositories, function (repo) {\n                        repositories.push({\n                            name: repo\n                        });\n                    });\n\n                    return repositories;\n                }\n\n                return result;\n            })\n            .then(images => {\n                if (with_tags) {\n                    return new Promise((resolve, reject) => {\n                        batchflow(images).sequential()\n                            .each((i, image, next) => {\n                                let image_result = image;\n                                // for each image\n                                registry.getImageTags(image.name)\n                                    .then(tags_result => {\n                                        if (typeof tags_result === 'string') {\n                                            // usually some sort of error\n                                            logger.error('Tags result was: ', tags_result);\n                                            image_result.tags = null;\n                                        } else if (typeof tags_result.tags !== 'undefined' && tags_result.tags !== null) {\n                                            // Order the tags naturally, but put latest at the top if it exists\n                                            let latest_idx = tags_result.tags.indexOf('latest');\n                                            if (latest_idx !== -1) {\n                                                _.pullAt(tags_result.tags, [latest_idx]);\n                                            }\n\n                                            // sort tags\n                                            image_result.tags = tags_result.tags.sort((a, b) => a.localeCompare(b));\n\n                                            if (latest_idx !== -1) {\n                                                image_result.tags.unshift('latest');\n                                            }\n                                        }\n\n                                        next(image_result);\n                                    })\n                                    .catch(err => {\n                                        logger.error(err);\n                                        image_result.tags = null;\n                                        next(image_result);\n                                    });\n                            })\n                            .error(err => {\n                                reject(err);\n                            })\n                            .end(results => {\n                                resolve(results);\n                            });\n                    });\n                } else {\n                    return images;\n                }\n            });\n    },\n\n    /**\n     * Delete a image/tag\n     *\n     * @param   {String}   name\n     * @param   {String}   digest\n     * @returns {Promise}\n     */\n    delete: (name, digest) => {\n        return registry.deleteImage(name, digest);\n    }\n};\n\nmodule.exports = internalRepo;\n"
  },
  {
    "path": "src/backend/lib/docker-registry.js",
    "content": "'use strict';\n\nconst _    = require('lodash');\nconst rest = require('restler');\n\n/**\n *\n * @param   {String}   domain\n * @param   {Boolean}  use_ssl\n * @param   {String}   [username]\n * @param   {String}   [password]\n * @returns {module}\n */\nmodule.exports = function (domain, use_ssl, username, password) {\n\n    this._baseurl = 'http' + (use_ssl ? 's' : '') + '://' + (username ? username + ':' + password + '@' : '') + domain + '/v2/';\n\n    /**\n     * @param   {Integer}  [version]\n     * @returns {Object}\n     */\n    this.getUrlOptions = function (version) {\n        let options = {\n            headers: {\n                'User-Agent': 'Docker Registry UI'\n            }\n        };\n\n        if (version === 2) {\n            options.headers.Accept = 'application/vnd.docker.distribution.manifest.v2+json';\n        }\n\n        return options;\n    };\n\n    /**\n     * @param   {Integer}  [limit]\n     * @returns {Promise}\n     */\n    this.getImages = function (limit) {\n        limit = limit || 300;\n\n        return new Promise((resolve, reject) => {\n            rest.get(this._baseurl + '_catalog?n=' + limit, this.getUrlOptions())\n                .on('timeout', function (ms) {\n                    reject(new Error('Request timed out after ' + ms + 'ms'));\n                })\n                .on('complete', function (result) {\n                    if (result instanceof Error) {\n                        reject(result);\n                    } else {\n                        resolve(result);\n                    }\n                });\n        });\n    };\n\n    /**\n     * @param   {String}   image\n     * @param   {Integer}  [limit]\n     * @returns {Promise}\n     */\n    this.getImageTags = function (image, limit) {\n        limit = limit || 300;\n\n        return new Promise((resolve, reject) => {\n            rest.get(this._baseurl + image + '/tags/list?n=' + limit, this.getUrlOptions())\n                .on('timeout', function (ms) {\n                    reject(new Error('Request timed out after ' + ms + 'ms'));\n                })\n                .on('complete', function (result) {\n                    if (result instanceof Error) {\n                        reject(result);\n                    } else {\n                        resolve(result);\n                    }\n                });\n        });\n    };\n\n    /**\n     * @param   {String}  image\n     * @param   {String}  digest\n     * @returns {Promise}\n     */\n    this.deleteImage = function (image, digest) {\n        return new Promise((resolve, reject) => {\n            rest.del(this._baseurl + image + '/manifests/' + digest, this.getUrlOptions())\n                .on('timeout', function (ms) {\n                    reject(new Error('Request timed out after ' + ms + 'ms'));\n                })\n                .on('202', function () {\n                    resolve(true);\n                })\n                .on('404', function () {\n                    resolve(false);\n                })\n                .on('complete', function (result) {\n                    if (result instanceof Error) {\n                        reject(result);\n                    } else {\n                        if (typeof result.errors !== 'undefined' && result.errors.length) {\n                            let err = result.errors.shift();\n                            resolve(err);\n                        }\n                    }\n                });\n        });\n    };\n\n    /**\n     * @param   {String}  image\n     * @param   {String}  layer_digest\n     * @returns {Promise}\n     */\n    this.deleteLayer = function (image, layer_digest) {\n        return new Promise((resolve, reject) => {\n            rest.del(this._baseurl + image + '/blobs/' + layer_digest, this.getUrlOptions())\n                .on('timeout', function (ms) {\n                    reject(new Error('Request timed out after ' + ms + 'ms'));\n                })\n                .on('202', function () {\n                    resolve(true);\n                })\n                .on('404', function () {\n                    resolve(false);\n                })\n                .on('complete', function (result) {\n                    if (result instanceof Error) {\n                        reject(result);\n                    } else {\n                        if (typeof result.errors !== 'undefined' && result.errors.length) {\n                            let err = result.errors.shift();\n                            resolve(err);\n                        }\n                    }\n                });\n        });\n    };\n\n    /**\n     * @param   {String}   image\n     * @param   {String}   reference     can be a tag or digest\n     * @param   {Integer}  [version]     1 or 2, defaults to 1\n     * @returns {Promise}\n     */\n    this.getManifest = function (image, reference, version) {\n        version = version || 1;\n\n        return new Promise((resolve, reject) => {\n            rest.get(this._baseurl + image + '/manifests/' + reference, this.getUrlOptions(version))\n                .on('timeout', function (ms) {\n                    reject(new Error('Request timed out after ' + ms + 'ms'));\n                })\n                .on('complete', function (result, response) {\n                    if (result instanceof Error) {\n                        reject(result);\n                    } else {\n                        if (typeof result === 'string') {\n                            result = JSON.parse(result);\n                        }\n\n                        result.digest = null;\n                        if (typeof response.headers['docker-content-digest'] !== 'undefined') {\n                            result.digest = response.headers['docker-content-digest'];\n                        }\n\n                        resolve(result);\n                    }\n                });\n        });\n    };\n\n    return this;\n};\n"
  },
  {
    "path": "src/backend/lib/error.js",
    "content": "'use strict';\n\nconst _    = require('lodash');\nconst util = require('util');\n\nmodule.exports = {\n\n    ItemNotFoundError: function (id, previous) {\n        Error.captureStackTrace(this, this.constructor);\n        this.name     = this.constructor.name;\n        this.previous = previous;\n        this.message  = 'Item Not Found - ' + id;\n        this.public   = true;\n        this.status   = 404;\n    },\n\n    RegistryError: function (code, message, previous) {\n        Error.captureStackTrace(this, this.constructor);\n        this.name     = this.constructor.name;\n        this.previous = previous;\n        this.message  = code + ': ' + message;\n        this.public   = true;\n        this.status   = 500;\n    },\n\n    InternalValidationError: function (message, previous) {\n        Error.captureStackTrace(this, this.constructor);\n        this.name     = this.constructor.name;\n        this.previous = previous;\n        this.message  = message;\n        this.status   = 400;\n        this.public   = false;\n    },\n\n    ValidationError: function (message, previous) {\n        Error.captureStackTrace(this, this.constructor);\n        this.name     = this.constructor.name;\n        this.previous = previous;\n        this.message  = message;\n        this.public   = true;\n        this.status   = 400;\n    }\n};\n\n_.forEach(module.exports, function (error) {\n    util.inherits(error, Error);\n});\n"
  },
  {
    "path": "src/backend/lib/express/cors.js",
    "content": "'use strict';\n\nconst validator = require('../validator');\n\nmodule.exports = function (req, res, next) {\n\n    if (req.headers.origin) {\n\n        // very relaxed validation....\n        validator({\n            type: 'string',\n            pattern: '^[a-z\\\\-]+:\\\\/\\\\/(?:[\\\\w\\\\-\\\\.]+(:[0-9]+)?/?)?$'\n        }, req.headers.origin)\n            .then(function () {\n                res.set({\n                    'Access-Control-Allow-Origin':      req.headers.origin,\n                    'Access-Control-Allow-Credentials': true,\n                    'Access-Control-Allow-Methods':     'OPTIONS, GET, POST',\n                    'Access-Control-Allow-Headers':     'Content-Type, Cache-Control, Pragma, Expires, Authorization, X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit',\n                    'Access-Control-Max-Age':           5 * 60,\n                    'Access-Control-Expose-Headers':    'X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit'\n                });\n                next();\n            })\n            .catch(next);\n\n    } else {\n        // No origin\n        next();\n    }\n\n};\n"
  },
  {
    "path": "src/backend/lib/express/pagination.js",
    "content": "'use strict';\n\nlet _ = require('lodash');\n\nmodule.exports = function (default_sort, default_offset, default_limit, max_limit) {\n\n    /**\n     * This will setup the req query params with filtered data and defaults\n     *\n     * sort    will be an array of fields and their direction\n     * offset  will be an int, defaulting to zero if no other default supplied\n     * limit   will be an int, defaulting to 50 if no other default supplied, and limited to the max if that was supplied\n     *\n     */\n\n    return function (req, res, next) {\n\n        req.query.offset = typeof req.query.limit === 'undefined' ? default_offset || 0 : parseInt(req.query.offset, 10);\n        req.query.limit  = typeof req.query.limit === 'undefined' ? default_limit || 50 : parseInt(req.query.limit, 10);\n\n        if (max_limit && req.query.limit > max_limit) {\n            req.query.limit = max_limit;\n        }\n\n        // Sorting\n        let sort       = typeof req.query.sort === 'undefined' ? default_sort : req.query.sort;\n        let myRegexp   = /.*\\.(asc|desc)$/ig;\n        let sort_array = [];\n\n        sort = sort.split(',');\n        _.map(sort, function (val) {\n            let matches = myRegexp.exec(val);\n\n            if (matches !== null) {\n                let dir = matches[1];\n                sort_array.push({\n                    field: val.substr(0, val.length - (dir.length + 1)),\n                    dir: dir.toLowerCase()\n                });\n            } else {\n                sort_array.push({\n                    field: val,\n                    dir: 'asc'\n                });\n            }\n        });\n\n        // Sort will now be in this format:\n        // [\n        //    { field: 'field1', dir: 'asc' },\n        //    { field: 'field2', dir: 'desc' }\n        // ]\n\n        req.query.sort = sort_array;\n        next();\n    };\n};\n"
  },
  {
    "path": "src/backend/lib/helpers.js",
    "content": "'use strict';\n\nconst moment = require('moment');\nconst _      = require('lodash');\n\nmodule.exports = {\n\n    /**\n     * Takes an expression such as 30d and returns a moment object of that date in future\n     *\n     * Key      Shorthand\n     * ==================\n     * years         y\n     * quarters      Q\n     * months        M\n     * weeks         w\n     * days          d\n     * hours         h\n     * minutes       m\n     * seconds       s\n     * milliseconds  ms\n     *\n     * @param {String}  expression\n     * @returns {Object}\n     */\n    parseDatePeriod: function (expression) {\n        let matches = expression.match(/^([0-9]+)(y|Q|M|w|d|h|m|s|ms)$/m);\n        if (matches) {\n            return moment().add(matches[1], matches[2]);\n        }\n\n        return null;\n    },\n\n    /**\n     * This will return an object that has the defaults supplied applied to it\n     * if they didn't exist already.\n     *\n     * @param  {Object} obj\n     * @param  {Object} defaults\n     * @return {Object}\n     */\n    applyObjectDefaults: function (obj, defaults) {\n        return _.assign({}, defaults, obj);\n    },\n\n    /**\n     * Returns a random integer between min (included) and max (excluded)\n     * Using Math.round() will give you a non-uniform distribution!\n     *\n     * @param   {Integer} min\n     * @param   {Integer} max\n     * @returns {Integer}\n     */\n    getRandomInt: function (min, max) {\n        min = Math.ceil(min);\n        max = Math.floor(max);\n        return Math.floor(Math.random() * (max - min)) + min;\n    },\n\n    /**\n     * Removes any fields with . joins in them, to avoid table joining select exposure\n     * Also makes sure an 'id' field exists\n     *\n     * @param   {Array} fields\n     * @returns {Array}\n     */\n    sanitizeFields: function (fields) {\n        if (fields.indexOf('id') === -1) {\n            fields.unshift('id');\n        }\n\n        let sanitized = [];\n        for (let x = 0; x < fields.length; x++) {\n            if (fields[x].indexOf('.') === -1) {\n                sanitized.push(fields[x]);\n            }\n        }\n\n        return sanitized;\n    },\n\n    /**\n     *\n     * @param   {String} input\n     * @param   {String} [allowed]\n     * @returns {String}\n     */\n    stripHtml: function (input, allowed) {\n        allowed                = (((allowed || '') + '').toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join('');\n\n        let tags               = /<\\/?([a-z][a-z0-9]*)\\b[^>]*>/gi;\n        let commentsAndPhpTags = /<!--[\\s\\S]*?-->|<\\?(?:php)?[\\s\\S]*?\\?>/gi;\n\n        return input.replace(commentsAndPhpTags, '').replace(tags, function ($0, $1) {\n            return allowed.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : '';\n        });\n    },\n\n    /**\n     *\n     * @param   {String} text\n     * @returns {String}\n     */\n    stripJiraMarkup: function (text) {\n        return text.replace(/(?:^|[^{]{)[^}]+}/gi, \"\\n\");\n    },\n\n    /**\n     * @param   {String} content\n     * @returns {String}\n     */\n    compactWhitespace: function (content) {\n        return content\n            .replace(/(\\r|\\n)+/gim, ' ')\n            .replace(/ +/gim, ' ');\n    },\n\n    /**\n     * @param   {String}  content\n     * @param   {Integer} length\n     * @returns {String}\n     */\n    trimString: function (content, length) {\n        if (content.length > (length - 3)) {\n            //trim the string to the maximum length\n            let trimmed = content.substr(0, length - 3);\n\n            //re-trim if we are in the middle of a word\n            return trimmed.substr(0, Math.min(trimmed.length, trimmed.lastIndexOf(' '))) + '...';\n        }\n\n        return content;\n    },\n\n    /**\n     * @param   {String}  str\n     * @returns {String}\n     */\n    ucwords: function (str) {\n        return (str + '')\n            .replace(/^(.)|\\s+(.)/g, function ($1) {\n                return $1.toUpperCase()\n            })\n    },\n\n    niceVarName: function (name) {\n        return name.replace('_', ' ')\n            .replace(/^(.)|\\s+(.)/g, function ($1) {\n                return $1.toUpperCase();\n            });\n    }\n\n};\n"
  },
  {
    "path": "src/backend/lib/validator/api.js",
    "content": "'use strict';\n\nconst error  = require('../error');\nconst path   = require('path');\nconst parser = require('json-schema-ref-parser');\n\nconst ajv = require('ajv')({\n    verbose:        true,\n    validateSchema: true,\n    allErrors:      false,\n    format:         'full',  // strict regexes for format checks\n    coerceTypes:    true\n});\n\n/**\n * @param {Object} schema\n * @param {Object} payload\n * @returns {Promise}\n */\nfunction apiValidator(schema, payload/*, description*/) {\n    return new Promise(function Promise_apiValidator(resolve, reject) {\n        if (typeof payload === 'undefined') {\n            reject(new error.ValidationError('Payload is undefined'));\n        }\n\n        let validate = ajv.compile(schema);\n        let valid = validate(payload);\n\n        if (valid && !validate.errors) {\n            resolve(payload);\n        } else {\n            let message = ajv.errorsText(validate.errors);\n\n            //console.log(schema);\n            //console.log(payload);\n            //console.log(validate.errors);\n\n            //var first_error = validate.errors.slice(0, 1).pop();\n            let err = new error.ValidationError(message);\n            err.debug = [validate.errors, payload];\n            reject(err);\n        }\n    });\n}\n\napiValidator.loadSchemas = parser\n    .dereference(path.resolve('src/backend/schema/index.json'))\n    .then((schema) => {\n        ajv.addSchema(schema);\n        return schema;\n    });\n\nmodule.exports = apiValidator;\n"
  },
  {
    "path": "src/backend/lib/validator/index.js",
    "content": "'use strict';\n\nconst _           = require('lodash');\nconst error       = require('../error');\nconst definitions = require('../../schema/definitions.json');\n\nRegExp.prototype.toJSON = RegExp.prototype.toString;\n\nconst ajv = require('ajv')({\n    verbose:     true, //process.env.NODE_ENV === 'development',\n    allErrors:   true,\n    format:      'full',  // strict regexes for format checks\n    coerceTypes: true,\n    schemas:     [\n        definitions\n    ]\n});\n\n/**\n *\n * @param {Object} schema\n * @param {Object} payload\n * @returns {Promise}\n */\nfunction validator (schema, payload) {\n    return new Promise(function (resolve, reject) {\n        if (!payload) {\n            reject(new error.InternalValidationError('Payload is falsy'));\n        } else {\n            try {\n                let validate = ajv.compile(schema);\n\n                let valid = validate(payload);\n                if (valid && !validate.errors) {\n                    resolve(_.cloneDeep(payload));\n                } else {\n                    console.log('SCHEMA:', schema);\n                    console.log('PAYLOAD:', payload);\n\n                    let message = ajv.errorsText(validate.errors);\n                    reject(new error.InternalValidationError(message));\n                }\n\n            } catch (err) {\n                reject(err);\n            }\n\n        }\n\n    });\n\n}\n\nmodule.exports = validator;\n"
  },
  {
    "path": "src/backend/logger.js",
    "content": "const {Signale} = require('signale');\n\nmodule.exports = {\n    global:   new Signale({scope: 'Global  '}),\n    migrate:  new Signale({scope: 'Migrate '}),\n    express:  new Signale({scope: 'Express '}),\n    registry: new Signale({scope: 'Registry'}),\n};\n"
  },
  {
    "path": "src/backend/routes/api/main.js",
    "content": "'use strict';\n\nconst express = require('express');\nconst pjson   = require('../../../../package.json');\n\nlet router = express.Router({\n    caseSensitive: true,\n    strict:        true,\n    mergeParams:   true\n});\n\n/**\n * Health Check\n * GET /api\n */\nrouter.get('/', (req, res/*, next*/) => {\n    let version = pjson.version.split('-').shift().split('.');\n\n    res.status(200).send({\n        status:  'OK',\n        version: {\n            major:    parseInt(version.shift(), 10),\n            minor:    parseInt(version.shift(), 10),\n            revision: parseInt(version.shift(), 10)\n        },\n        config:  {\n            REGISTRY_STORAGE_DELETE_ENABLED: process.env.REGISTRY_STORAGE_DELETE_ENABLED && process.env.REGISTRY_STORAGE_DELETE_ENABLED.toLowerCase() === 'true' || parseInt(process.env.REGISTRY_STORAGE_DELETE_ENABLED, 10) === 1,\n            REGISTRY_DOMAIN:                 process.env.REGISTRY_DOMAIN || null\n        }\n    });\n});\n\nrouter.use('/repos', require('./repos'));\n\nmodule.exports = router;\n"
  },
  {
    "path": "src/backend/routes/api/repos.js",
    "content": "'use strict';\n\nconst express      = require('express');\nconst validator    = require('../../lib/validator');\nconst pagination   = require('../../lib/express/pagination');\nconst internalRepo = require('../../internal/repo');\n\nlet router = express.Router({\n    caseSensitive: true,\n    strict:        true,\n    mergeParams:   true\n});\n\n/**\n * /api/repos\n */\nrouter\n    .route('/')\n    .options((req, res) => {\n        res.sendStatus(204);\n    })\n\n    /**\n     * GET /api/repos\n     *\n     * Retrieve all repos\n     */\n    .get(pagination('name', 0, 50, 300), (req, res, next) => {\n        validator({\n            additionalProperties: false,\n            properties:           {\n                tags: {\n                    type: 'boolean'\n                }\n            }\n        }, {\n            tags: (typeof req.query.tags !== 'undefined' ? !!req.query.tags : false)\n        })\n            .then(data => {\n                return internalRepo.getAll(data.tags);\n            })\n            .then(repos => {\n                res.status(200)\n                    .send(repos);\n            })\n            .catch(next);\n    });\n\n/**\n * Specific repo\n *\n * /api/repos/abc123\n */\nrouter\n    .route('/:name([-a-zA-Z0-9/.,_]+)')\n    .options((req, res) => {\n        res.sendStatus(204);\n    })\n\n    /**\n     * GET /api/repos/abc123\n     *\n     * Retrieve a specific repo\n     */\n    .get((req, res, next) => {\n        validator({\n            required:             ['name'],\n            additionalProperties: false,\n            properties:           {\n                name: {\n                    type:      'string',\n                    minLength: 1\n                },\n                full: {\n                    type: 'boolean'\n                }\n            }\n        }, {\n            name: req.params.name,\n            full: (typeof req.query.full !== 'undefined' ? !!req.query.full : false)\n        })\n            .then(data => {\n                return internalRepo.get(data.name, data.full);\n            })\n            .then(repo => {\n                res.status(200)\n                    .send(repo);\n            })\n            .catch(next);\n    })\n\n    /**\n     * DELETE /api/repos/abc123\n     *\n     * Delete a specific image/tag\n     */\n    .delete((req, res, next) => {\n        validator({\n            required:             ['name', 'digest'],\n            additionalProperties: false,\n            properties:           {\n                name:   {\n                    type:      'string',\n                    minLength: 1\n                },\n                digest: {\n                    type:      'string',\n                    minLength: 1\n                }\n            }\n        }, {\n            name:   req.params.name,\n            digest: (typeof req.query.digest !== 'undefined' ? req.query.digest : '')\n        })\n            .then(data => {\n                return internalRepo.delete(data.name, data.digest);\n            })\n            .then(result => {\n                res.status(200)\n                    .send(result);\n            })\n            .catch(next);\n    });\nmodule.exports = router;\n"
  },
  {
    "path": "src/backend/routes/main.js",
    "content": "'use strict';\n\nconst express = require('express');\nconst fs      = require('fs');\n\nconst router = express.Router({\n    caseSensitive: true,\n    strict:        true,\n    mergeParams:   true\n});\n\n/**\n * GET .*\n */\nrouter.get(/(.*)/, function (req, res, next) {\n    req.params.page = req.params['0'];\n    if (req.params.page === '/') {\n        req.params.page = '/index.html';\n    }\n\n    fs.readFile('dist' + req.params.page, 'utf8', function(err, data) {\n        if (err) {\n            if (req.params.page !== '/index.html') {\n                fs.readFile('dist/index.html', 'utf8', function(err2, data) {\n                    if (err2) {\n                        next(err);\n                    } else {\n                        res.contentType('text/html').end(data);\n                    }\n                });\n            } else {\n                next(err);\n            }\n        } else {\n            res.contentType('text/html').end(data);\n        }\n    });\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "src/backend/schema/definitions.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"definitions\",\n  \"definitions\": {\n    \"id\": {\n      \"description\": \"Unique identifier\",\n      \"example\": 123456,\n      \"readOnly\": true,\n      \"type\": \"integer\",\n      \"minimum\": 1\n    },\n    \"token\": {\n      \"type\": \"string\",\n      \"minLength\": 10\n    },\n    \"expand\": {\n      \"anyOf\": [\n        {\n          \"type\": \"null\"\n        },\n        {\n          \"type\": \"array\",\n          \"minItems\": 1,\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      ]\n    },\n    \"sort\": {\n      \"type\": \"array\",\n      \"minItems\": 1,\n      \"items\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"field\",\n          \"dir\"\n        ],\n        \"additionalProperties\": false,\n        \"properties\": {\n          \"field\": {\n            \"type\": \"string\"\n          },\n          \"dir\": {\n            \"type\": \"string\",\n            \"pattern\": \"^(asc|desc)$\"\n          }\n        }\n      }\n    },\n    \"query\": {\n      \"anyOf\": [\n        {\n          \"type\": \"null\"\n        },\n        {\n          \"type\": \"string\",\n          \"minLength\": 1,\n          \"maxLength\": 255\n        }\n      ]\n    },\n    \"criteria\": {\n      \"anyOf\": [\n        {\n          \"type\": \"null\"\n        },\n        {\n          \"type\": \"object\"\n        }\n      ]\n    },\n    \"fields\": {\n      \"anyOf\": [\n        {\n          \"type\": \"null\"\n        },\n        {\n          \"type\": \"array\",\n          \"minItems\": 1,\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      ]\n    },\n    \"omit\": {\n      \"anyOf\": [\n        {\n          \"type\": \"null\"\n        },\n        {\n          \"type\": \"array\",\n          \"minItems\": 1,\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      ]\n    },\n    \"created_on\": {\n      \"description\": \"Date and time of creation\",\n      \"format\": \"date-time\",\n      \"readOnly\": true,\n      \"type\": \"string\"\n    },\n    \"modified_on\": {\n      \"description\": \"Date and time of last update\",\n      \"format\": \"date-time\",\n      \"readOnly\": true,\n      \"type\": \"string\"\n    },\n    \"user_id\": {\n      \"description\": \"User ID\",\n      \"example\": 1234,\n      \"type\": \"integer\",\n      \"minimum\": 1\n    },\n    \"name\": {\n      \"type\": \"string\",\n      \"minLength\": 1,\n      \"maxLength\": 255\n    },\n    \"email\": {\n      \"description\": \"Email Address\",\n      \"example\": \"john@example.com\",\n      \"format\": \"email\",\n      \"type\": \"string\",\n      \"minLength\": 8,\n      \"maxLength\": 100\n    },\n    \"password\": {\n      \"description\": \"Password\",\n      \"type\": \"string\",\n      \"minLength\": 8,\n      \"maxLength\": 255\n    },\n    \"jira_webhook_data\": {\n      \"type\": \"object\",\n      \"additionalProperties\": true,\n      \"required\": [\n        \"webhookEvent\",\n        \"timestamp\"\n      ],\n      \"properties\": {\n        \"webhookEvent\": {\n          \"type\": \"string\",\n          \"minLength\": 2\n        },\n        \"timestamp\": {\n          \"type\": \"integer\",\n          \"minimum\": 1\n        },\n        \"user\": {\n          \"type\": \"object\"\n        },\n        \"issue\": {\n          \"type\": \"object\"\n        }\n      }\n    },\n    \"bitbucket_webhook_data\": {\n      \"type\": \"object\",\n      \"additionalProperties\": true,\n      \"required\": [\n        \"eventKey\",\n        \"date\"\n      ],\n      \"properties\": {\n        \"eventKey\": {\n          \"type\": \"string\",\n          \"minLength\": 2\n        },\n        \"date\": {\n          \"type\": \"string\",\n          \"minimum\": 19\n        },\n        \"actor\": {\n          \"type\": \"object\"\n        },\n        \"pullRequest\": {\n          \"type\": \"object\"\n        }\n      }\n    },\n    \"dockerhub_webhook_data\": {\n      \"type\": \"object\",\n      \"additionalProperties\": true,\n      \"required\": [\n        \"push_data\",\n        \"repository\"\n      ],\n      \"properties\": {\n        \"push_data\": {\n          \"type\": \"object\"\n        },\n        \"repository\": {\n          \"type\": \"object\"\n        }\n      }\n    },\n    \"zendesk_webhook_data\": {\n      \"type\": \"object\",\n      \"additionalProperties\": true,\n      \"required\": [\n        \"ticket\",\n        \"current_user\"\n      ],\n      \"properties\": {\n        \"ticket\": {\n          \"type\": \"object\"\n        },\n        \"current_user\": {\n          \"type\": \"object\"\n        }\n      }\n    },\n    \"service_type\": {\n      \"description\": \"Service Type\",\n      \"example\": \"slack\",\n      \"type\": \"string\",\n      \"minLength\": 2,\n      \"maxLength\": 30,\n      \"pattern\": \"^(slack|jira-webhook|bitbucket-webhook|dockerhub-webhook|zendesk-webhook|jabber)$\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/backend/schema/endpoints/rules.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"endpoints/rules\",\n  \"title\": \"Rules\",\n  \"description\": \"Endpoints relating to Rules\",\n  \"stability\": \"stable\",\n  \"type\": \"object\",\n  \"definitions\": {\n    \"id\": {\n      \"$ref\": \"../definitions.json#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"../definitions.json#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"../definitions.json#/definitions/modified_on\"\n    },\n    \"user_id\": {\n      \"$ref\": \"../definitions.json#/definitions/user_id\"\n    },\n    \"priority_order\": {\n      \"description\": \"Priority Order\",\n      \"example\": 1,\n      \"type\": \"integer\",\n      \"minimum\": 0\n    },\n    \"in_service_id\": {\n      \"description\": \"Incoming Service ID\",\n      \"example\": 1234,\n      \"type\": \"integer\",\n      \"minimum\": 1\n    },\n    \"trigger\": {\n      \"description\": \"Trigger Type\",\n      \"example\": \"assigned\",\n      \"type\": \"string\",\n      \"minLength\": 2,\n      \"maxLength\": 50\n    },\n    \"extra_conditions\": {\n      \"description\": \"Extra Incoming Trigger Conditions\",\n      \"example\": {\n        \"project\": \"BB\"\n      },\n      \"type\": \"object\"\n    },\n    \"out_service_id\": {\n      \"description\": \"Outgoing Service ID\",\n      \"example\": 1234,\n      \"type\": \"integer\",\n      \"minimum\": 1\n    },\n    \"out_template_id\": {\n      \"description\": \"Outgoing Template ID\",\n      \"example\": 1234,\n      \"type\": \"integer\",\n      \"minimum\": 1\n    },\n    \"out_template_options\": {\n      \"description\": \"Custom options for Outgoing Template\",\n      \"example\": {\n        \"panel_color\": \"#ff00aa\"\n      },\n      \"type\": \"object\"\n    },\n    \"fired_count\": {\n      \"description\": \"Fired Count\",\n      \"example\": 854,\n      \"readOnly\": true,\n      \"type\": \"integer\",\n      \"minimum\": 1\n    }\n  },\n  \"links\": [\n    {\n      \"title\": \"List\",\n      \"description\": \"Returns a list of Rules\",\n      \"href\": \"/rules\",\n      \"access\": \"private\",\n      \"method\": \"GET\",\n      \"rel\": \"self\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Create\",\n      \"description\": \"Creates a new Rule\",\n      \"href\": \"/rules\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"rel\": \"create\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"in_service_id\",\n          \"trigger\",\n          \"out_service_id\",\n          \"out_template_id\"\n        ],\n        \"properties\": {\n          \"user_id\": {\n            \"$ref\": \"#/definitions/user_id\"\n          },\n          \"priority_order\": {\n            \"$ref\": \"#/definitions/priority_order\"\n          },\n          \"in_service_id\": {\n            \"$ref\": \"#/definitions/in_service_id\"\n          },\n          \"trigger\": {\n            \"$ref\": \"#/definitions/trigger\"\n          },\n          \"extra_conditions\": {\n            \"$ref\": \"#/definitions/extra_conditions\"\n          },\n          \"out_service_id\": {\n            \"$ref\": \"#/definitions/out_service_id\"\n          },\n          \"out_template_id\": {\n            \"$ref\": \"#/definitions/out_template_id\"\n          },\n          \"out_template_options\": {\n            \"$ref\": \"#/definitions/out_template_options\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Update\",\n      \"description\": \"Updates a existing Rule\",\n      \"href\": \"/rules/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"PUT\",\n      \"rel\": \"update\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"priority_order\": {\n            \"$ref\": \"#/definitions/priority_order\"\n          },\n          \"in_service_id\": {\n            \"$ref\": \"#/definitions/in_service_id\"\n          },\n          \"trigger\": {\n            \"$ref\": \"#/definitions/trigger\"\n          },\n          \"extra_conditions\": {\n            \"$ref\": \"#/definitions/extra_conditions\"\n          },\n          \"out_service_id\": {\n            \"$ref\": \"#/definitions/out_service_id\"\n          },\n          \"out_template_id\": {\n            \"$ref\": \"#/definitions/out_template_id\"\n          },\n          \"out_template_options\": {\n            \"$ref\": \"#/definitions/out_template_options\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Delete\",\n      \"description\": \"Deletes a existing Rule\",\n      \"href\": \"/rules/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"DELETE\",\n      \"rel\": \"delete\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    },\n    {\n      \"title\": \"Order\",\n      \"description\": \"Sets the order for the rules\",\n      \"href\": \"/rules/order\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"object\",\n          \"required\": [\n            \"order\",\n            \"rule_id\"\n          ],\n          \"properties\": {\n            \"order\": {\n              \"type\": \"integer\",\n              \"minimum\": 0\n            },\n            \"rule_id\": {\n              \"$ref\": \"../definitions.json#/definitions/id\"\n            }\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    },\n    {\n      \"title\": \"Copy\",\n      \"description\": \"Copies rules from one user to another\",\n      \"href\": \"/rules/copy\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"from\",\n          \"to\"\n        ],\n        \"properties\": {\n          \"from\": {\n            \"type\": \"integer\",\n            \"minimum\": 1\n          },\n          \"to\": {\n            \"type\": \"integer\",\n            \"minimum\": 1\n          },\n          \"service_type\": {\n            \"$ref\": \"../definitions.json#/definitions/service_type\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    }\n  ],\n  \"properties\": {\n    \"id\": {\n      \"$ref\": \"#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"#/definitions/modified_on\"\n    },\n    \"user_id\": {\n      \"$ref\": \"#/definitions/user_id\"\n    },\n    \"priority_order\": {\n      \"$ref\": \"#/definitions/priority_order\"\n    },\n    \"in_service_id\": {\n      \"$ref\": \"#/definitions/in_service_id\"\n    },\n    \"trigger\": {\n      \"$ref\": \"#/definitions/trigger\"\n    },\n    \"extra_conditions\": {\n      \"$ref\": \"#/definitions/extra_conditions\"\n    },\n    \"out_service_id\": {\n      \"$ref\": \"#/definitions/out_service_id\"\n    },\n    \"out_template_id\": {\n      \"$ref\": \"#/definitions/out_template_id\"\n    },\n    \"out_template_options\": {\n      \"$ref\": \"#/definitions/out_template_options\"\n    },\n    \"fired_count\": {\n      \"$ref\": \"#/definitions/fired_count\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/backend/schema/endpoints/services.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"endpoints/services\",\n  \"title\": \"Services\",\n  \"description\": \"Endpoints relating to Services\",\n  \"stability\": \"stable\",\n  \"type\": \"object\",\n  \"definitions\": {\n    \"id\": {\n      \"$ref\": \"../definitions.json#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"../definitions.json#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"../definitions.json#/definitions/modified_on\"\n    },\n    \"type\": {\n      \"$ref\": \"../definitions.json#/definitions/service_type\"\n    },\n    \"name\": {\n      \"description\": \"Name\",\n      \"example\": \"JiraBot\",\n      \"type\": \"string\",\n      \"minLength\": 2,\n      \"maxLength\": 100\n    },\n    \"data\": {\n      \"description\": \"Data\",\n      \"example\": {\"api_token\": \"xox-somethingrandom\"},\n      \"type\": \"object\"\n    }\n  },\n  \"links\": [\n    {\n      \"title\": \"List\",\n      \"description\": \"Returns a list of Services\",\n      \"href\": \"/services\",\n      \"access\": \"private\",\n      \"method\": \"GET\",\n      \"rel\": \"self\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Create\",\n      \"description\": \"Creates a new Service\",\n      \"href\": \"/services\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"rel\": \"create\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"type\",\n          \"name\",\n          \"data\"\n        ],\n        \"properties\": {\n          \"type\": {\n            \"$ref\": \"#/definitions/type\"\n          },\n          \"name\": {\n            \"$ref\": \"#/definitions/name\"\n          },\n          \"data\": {\n            \"$ref\": \"#/definitions/data\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Update\",\n      \"description\": \"Updates a existing Service\",\n      \"href\": \"/services/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"PUT\",\n      \"rel\": \"update\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"type\": {\n            \"$ref\": \"#/definitions/type\"\n          },\n          \"name\": {\n            \"$ref\": \"#/definitions/name\"\n          },\n          \"data\": {\n            \"$ref\": \"#/definitions/data\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Delete\",\n      \"description\": \"Deletes a existing Service\",\n      \"href\": \"/services/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"DELETE\",\n      \"rel\": \"delete\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    },\n    {\n      \"title\": \"Test\",\n      \"description\": \"Tests a existing Service\",\n      \"href\": \"/services/{definitions.identity.example}/test\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"rel\": \"test\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"username\",\n          \"message\"\n        ],\n        \"properties\": {\n          \"username\": {\n            \"type\": \"string\",\n            \"minLength\": 1\n          },\n          \"message\": {\n            \"type\": \"string\",\n            \"minLength\": 1\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    },\n    {\n      \"title\": \"User List\",\n      \"description\": \"Get User List of a Service\",\n      \"href\": \"/services/{definitions.identity.example}/users\",\n      \"access\": \"private\",\n      \"method\": \"GET\",\n      \"rel\": \"users\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"username\",\n          \"message\"\n        ],\n        \"properties\": {\n          \"username\": {\n            \"type\": \"string\",\n            \"minLength\": 1\n          },\n          \"message\": {\n            \"type\": \"string\",\n            \"minLength\": 1\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    }\n  ],\n  \"properties\": {\n    \"id\": {\n      \"$ref\": \"#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"#/definitions/modified_on\"\n    },\n    \"type\": {\n      \"$ref\": \"#/definitions/type\"\n    },\n    \"name\": {\n      \"$ref\": \"#/definitions/name\"\n    },\n    \"data\": {\n      \"$ref\": \"#/definitions/data\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/backend/schema/endpoints/templates.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"endpoints/templates\",\n  \"title\": \"Templates\",\n  \"description\": \"Endpoints relating to Templates\",\n  \"stability\": \"stable\",\n  \"type\": \"object\",\n  \"definitions\": {\n    \"id\": {\n      \"$ref\": \"../definitions.json#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"../definitions.json#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"../definitions.json#/definitions/modified_on\"\n    },\n    \"service_type\": {\n      \"$ref\": \"../definitions.json#/definitions/service_type\"\n    },\n    \"in_service_type\": {\n      \"$ref\": \"../definitions.json#/definitions/service_type\"\n    },\n    \"name\": {\n      \"description\": \"Name of Template\",\n      \"example\": \"Assigned Task Compact\",\n      \"type\": \"string\",\n      \"minLength\": 1,\n      \"maxLength\": 100\n    },\n    \"content\": {\n      \"description\": \"Content\",\n      \"example\": \"{\\\"text\\\": \\\"Hello World\\\"}\",\n      \"type\": \"string\"\n    },\n    \"default_options\": {\n      \"description\": \"Default Options\",\n      \"example\": {\n        \"panel_color\": \"#ff0000\"\n      },\n      \"type\": \"object\"\n    },\n    \"example_data\": {\n      \"description\": \"Example Data\",\n      \"example\": {\n        \"summary\": \"Example Jira Summary\"\n      },\n      \"type\": \"object\"\n    },\n    \"event_types\": {\n      \"description\": \"Event Types\",\n      \"example\": {\n        \"summary\": [\"assigned\", \"resolved\"]\n      },\n      \"type\": \"array\",\n      \"minItems\": 1,\n      \"items\": {\n        \"type\": \"string\",\n        \"minLength\": 1\n      }\n    },\n    \"render_engine\": {\n      \"description\": \"Render Engine\",\n      \"example\": \"liquid\",\n      \"type\": \"string\",\n      \"pattern\": \"^(ejs|liquid)$\"\n    }\n  },\n  \"links\": [\n    {\n      \"title\": \"List\",\n      \"description\": \"Returns a list of Templates\",\n      \"href\": \"/templates\",\n      \"access\": \"private\",\n      \"method\": \"GET\",\n      \"rel\": \"self\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Create\",\n      \"description\": \"Creates a new Templates\",\n      \"href\": \"/templates\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"rel\": \"create\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"service_type\",\n          \"in_service_type\",\n          \"name\",\n          \"content\",\n          \"default_options\",\n          \"example_data\",\n          \"event_types\"\n        ],\n        \"properties\": {\n          \"service_type\": {\n            \"$ref\": \"#/definitions/service_type\"\n          },\n          \"in_service_type\": {\n            \"$ref\": \"#/definitions/in_service_type\"\n          },\n          \"name\": {\n            \"$ref\": \"#/definitions/name\"\n          },\n          \"content\": {\n            \"$ref\": \"#/definitions/content\"\n          },\n          \"default_options\": {\n            \"$ref\": \"#/definitions/default_options\"\n          },\n          \"example_data\": {\n            \"$ref\": \"#/definitions/default_options\"\n          },\n          \"event_types\": {\n            \"$ref\": \"#/definitions/event_types\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Update\",\n      \"description\": \"Updates a existing Template\",\n      \"href\": \"/templates/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"PUT\",\n      \"rel\": \"update\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"service_type\": {\n            \"$ref\": \"#/definitions/service_type\"\n          },\n          \"in_service_type\": {\n            \"$ref\": \"#/definitions/in_service_type\"\n          },\n          \"name\": {\n            \"$ref\": \"#/definitions/name\"\n          },\n          \"content\": {\n            \"$ref\": \"#/definitions/content\"\n          },\n          \"default_options\": {\n            \"$ref\": \"#/definitions/default_options\"\n          },\n          \"example_data\": {\n            \"$ref\": \"#/definitions/default_options\"\n          },\n          \"event_types\": {\n            \"$ref\": \"#/definitions/event_types\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Delete\",\n      \"description\": \"Deletes a existing Template\",\n      \"href\": \"/templates/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"DELETE\",\n      \"rel\": \"delete\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    }\n  ],\n  \"properties\": {\n    \"id\": {\n      \"$ref\": \"#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"#/definitions/modified_on\"\n    },\n    \"service_type\": {\n      \"$ref\": \"#/definitions/service_type\"\n    },\n    \"in_service_type\": {\n      \"$ref\": \"#/definitions/in_service_type\"\n    },\n    \"name\": {\n      \"$ref\": \"#/definitions/name\"\n    },\n    \"content\": {\n      \"$ref\": \"#/definitions/content\"\n    },\n    \"default_options\": {\n      \"$ref\": \"#/definitions/default_options\"\n    },\n    \"example_data\": {\n      \"$ref\": \"#/definitions/example_data\"\n    },\n    \"event_types\": {\n      \"$ref\": \"#/definitions/event_types\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/backend/schema/endpoints/tokens.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"endpoints/tokens\",\n  \"title\": \"Token\",\n  \"description\": \"Tokens are required to authenticate against the JiraBot API\",\n  \"stability\": \"stable\",\n  \"type\": \"object\",\n  \"definitions\": {\n    \"identity\": {\n      \"description\": \"Email Address or other 3rd party providers identifier\",\n      \"example\": \"john@example.com\",\n      \"type\": \"string\"\n    },\n    \"secret\": {\n      \"description\": \"A password or key\",\n      \"example\": \"correct horse battery staple\",\n      \"type\": \"string\"\n    },\n    \"token\": {\n      \"description\": \"JWT\",\n      \"example\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk\",\n      \"type\": \"string\"\n    },\n    \"expires\": {\n      \"description\": \"Token expiry time\",\n      \"format\": \"date-time\",\n      \"type\": \"string\"\n    },\n    \"scope\": {\n      \"description\": \"Scope of the Token, defaults to 'user'\",\n      \"example\": \"user\",\n      \"type\": \"string\"\n    }\n  },\n  \"links\": [\n    {\n      \"title\": \"Create\",\n      \"description\": \"Creates a new token.\",\n      \"href\": \"/tokens\",\n      \"access\": \"public\",\n      \"method\": \"POST\",\n      \"rel\": \"create\",\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"identity\",\n          \"secret\"\n        ],\n        \"properties\": {\n          \"identity\": {\n            \"$ref\": \"#/definitions/identity\"\n          },\n          \"secret\": {\n            \"$ref\": \"#/definitions/secret\"\n          },\n          \"scope\": {\n            \"$ref\": \"#/definitions/scope\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"token\": {\n            \"$ref\": \"#/definitions/token\"\n          },\n          \"expires\": {\n            \"$ref\": \"#/definitions/expires\"\n          }\n        }\n      }\n    },\n    {\n      \"title\": \"Refresh\",\n      \"description\": \"Returns a new token.\",\n      \"href\": \"/tokens\",\n      \"access\": \"private\",\n      \"method\": \"GET\",\n      \"rel\": \"self\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {},\n      \"targetSchema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"token\": {\n            \"$ref\": \"#/definitions/token\"\n          },\n          \"expires\": {\n            \"$ref\": \"#/definitions/expires\"\n          },\n          \"scope\": {\n            \"$ref\": \"#/definitions/scope\"\n          }\n        }\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "src/backend/schema/endpoints/users.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"endpoints/users\",\n  \"title\": \"Users\",\n  \"description\": \"Endpoints relating to Users\",\n  \"stability\": \"stable\",\n  \"type\": \"object\",\n  \"definitions\": {\n    \"id\": {\n      \"$ref\": \"../definitions.json#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"../definitions.json#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"../definitions.json#/definitions/modified_on\"\n    },\n    \"name\": {\n      \"description\": \"Name\",\n      \"example\": \"Jamie Curnow\",\n      \"type\": \"string\",\n      \"minLength\": 2,\n      \"maxLength\": 100\n    },\n    \"nickname\": {\n      \"description\": \"Nickname\",\n      \"example\": \"Jamie\",\n      \"type\": \"string\",\n      \"minLength\": 2,\n      \"maxLength\": 50\n    },\n    \"email\": {\n      \"$ref\": \"../definitions.json#/definitions/email\"\n    },\n    \"avatar\": {\n      \"description\": \"Avatar\",\n      \"example\": \"http://somewhere.jpg\",\n      \"type\": \"string\",\n      \"minLength\": 2,\n      \"maxLength\": 150,\n      \"readOnly\": true\n    },\n    \"roles\": {\n      \"description\": \"Roles\",\n      \"example\": [\n        \"admin\"\n      ],\n      \"type\": \"array\"\n    },\n    \"is_disabled\": {\n      \"description\": \"Is Disabled\",\n      \"example\": false,\n      \"type\": \"boolean\"\n    }\n  },\n  \"links\": [\n    {\n      \"title\": \"List\",\n      \"description\": \"Returns a list of Users\",\n      \"href\": \"/users\",\n      \"access\": \"private\",\n      \"method\": \"GET\",\n      \"rel\": \"self\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Create\",\n      \"description\": \"Creates a new User\",\n      \"href\": \"/users\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"rel\": \"create\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"name\",\n          \"nickname\",\n          \"email\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"$ref\": \"#/definitions/name\"\n          },\n          \"nickname\": {\n            \"$ref\": \"#/definitions/nickname\"\n          },\n          \"email\": {\n            \"$ref\": \"#/definitions/email\"\n          },\n          \"roles\": {\n            \"$ref\": \"#/definitions/roles\"\n          },\n          \"is_disabled\": {\n            \"$ref\": \"#/definitions/is_disabled\"\n          },\n          \"auth\": {\n            \"type\": \"object\",\n            \"description\": \"Auth Credentials\",\n            \"example\": {\n              \"type\": \"password\",\n              \"secret\": \"bigredhorsebanana\"\n            }\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Update\",\n      \"description\": \"Updates a existing User\",\n      \"href\": \"/users/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"PUT\",\n      \"rel\": \"update\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"$ref\": \"#/definitions/name\"\n          },\n          \"nickname\": {\n            \"$ref\": \"#/definitions/nickname\"\n          },\n          \"email\": {\n            \"$ref\": \"#/definitions/email\"\n          },\n          \"roles\": {\n            \"$ref\": \"#/definitions/roles\"\n          },\n          \"is_disabled\": {\n            \"$ref\": \"#/definitions/is_disabled\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"properties\": {\n          \"$ref\": \"#/properties\"\n        }\n      }\n    },\n    {\n      \"title\": \"Delete\",\n      \"description\": \"Deletes a existing User\",\n      \"href\": \"/users/{definitions.identity.example}\",\n      \"access\": \"private\",\n      \"method\": \"DELETE\",\n      \"rel\": \"delete\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    },\n    {\n      \"title\": \"Set Password\",\n      \"description\": \"Sets a password for an existing User\",\n      \"href\": \"/users/{definitions.identity.example}/auth\",\n      \"access\": \"private\",\n      \"method\": \"PUT\",\n      \"rel\": \"update\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"type\",\n          \"secret\"\n        ],\n        \"properties\": {\n          \"type\": {\n            \"type\": \"string\",\n            \"pattern\": \"^password$\"\n          },\n          \"current\": {\n            \"type\": \"string\",\n            \"minLength\": 1,\n            \"maxLength\": 64\n          },\n          \"secret\": {\n            \"type\": \"string\",\n            \"minLength\": 8,\n            \"maxLength\": 64\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    },\n    {\n      \"title\": \"Set Service Settings\",\n      \"description\": \"Sets service settings for an existing User\",\n      \"href\": \"/users/{definitions.identity.example}/services\",\n      \"access\": \"private\",\n      \"method\": \"POST\",\n      \"rel\": \"update\",\n      \"http_header\": {\n        \"$ref\": \"../examples.json#/definitions/auth_header\"\n      },\n      \"schema\": {\n        \"type\": \"object\",\n        \"required\": [\n          \"settings\"\n        ],\n        \"properties\": {\n          \"settings\": {\n            \"type\": \"object\"\n          }\n        }\n      },\n      \"targetSchema\": {\n        \"type\": \"boolean\"\n      }\n    }\n  ],\n  \"properties\": {\n    \"id\": {\n      \"$ref\": \"#/definitions/id\"\n    },\n    \"created_on\": {\n      \"$ref\": \"#/definitions/created_on\"\n    },\n    \"modified_on\": {\n      \"$ref\": \"#/definitions/modified_on\"\n    },\n    \"name\": {\n      \"$ref\": \"#/definitions/name\"\n    },\n    \"nickname\": {\n      \"$ref\": \"#/definitions/nickname\"\n    },\n    \"email\": {\n      \"$ref\": \"#/definitions/email\"\n    },\n    \"avatar\": {\n      \"$ref\": \"#/definitions/avatar\"\n    },\n    \"roles\": {\n      \"$ref\": \"#/definitions/roles\"\n    },\n    \"is_disabled\": {\n      \"$ref\": \"#/definitions/is_disabled\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/backend/schema/examples.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"examples\",\n  \"type\": \"object\",\n  \"definitions\": {\n    \"name\": {\n      \"description\": \"Name\",\n      \"example\": \"John Smith\",\n      \"type\": \"string\",\n      \"minLength\": 1,\n      \"maxLength\": 255\n    },\n    \"auth_header\": {\n      \"Authorization\": \"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk\",\n      \"X-API-Version\": \"next\"\n    },\n    \"token\": {\n      \"type\": \"string\",\n      \"description\": \"JWT\",\n      \"example\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/backend/schema/index.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"Juxtapose REST API\",\n  \"description\": \"This is the Juxtapose REST API\",\n  \"$id\": \"root\",\n  \"version\": \"1.0.0\",\n  \"links\": [\n    {\n      \"href\": \"http://juxtapose/api\",\n      \"rel\": \"self\"\n    }\n  ],\n  \"properties\": {\n    \"tokens\": {\n      \"$ref\": \"endpoints/tokens.json\"\n    },\n    \"users\": {\n      \"$ref\": \"endpoints/users.json\"\n    },\n    \"services\": {\n      \"$ref\": \"endpoints/services.json\"\n    },\n    \"templates\": {\n      \"$ref\": \"endpoints/templates.json\"\n    },\n    \"rules\": {\n      \"$ref\": \"endpoints/rules.json\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/frontend/app-images/favicons/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"/images/favicons/mstile-150x150.png\"/>\n            <TileColor>#f5f5f5</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "src/frontend/app-images/favicons/site.webmanifest",
    "content": "{\n    \"name\": \"\",\n    \"short_name\": \"\",\n    \"icons\": [\n        {\n            \"src\": \"/images/favicons/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/images/favicons/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#ffffff\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "src/frontend/html/index.html",
    "content": "<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n    <head>\n        <meta charset=\"utf-8\">\n        <meta name=\"viewport\" content=\"width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0\">\n        <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n        <meta http-equiv=\"Content-Language\" content=\"en\">\n        <meta name=\"msapplication-TileColor\" content=\"#2d89ef\">\n        <meta name=\"theme-color\" content=\"#4188c9\">\n        <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">\n        <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n        <meta name=\"mobile-web-app-capable\" content=\"yes\">\n        <meta name=\"HandheldFriendly\" content=\"True\">\n        <meta name=\"MobileOptimized\" content=\"320\">\n        <title>Docker Registry UI</title>\n        <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/images/favicons/apple-touch-icon.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/images/favicons/favicon-32x32.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/images/favicons/favicon-16x16.png\">\n        <link rel=\"manifest\" href=\"/images/favicons/site.webmanifest\">\n        <link rel=\"mask-icon\" href=\"/images/favicons/safari-pinned-tab.svg\" color=\"#5bbad5\">\n        <link rel=\"shortcut icon\" href=\"/images/favicons/favicon.ico\">\n        <meta name=\"msapplication-TileColor\" content=\"#f5f5f5\">\n        <meta name=\"msapplication-config\" content=\"/images/favicons/browserconfig.xml\">\n        <meta name=\"theme-color\" content=\"#ffffff\">\n        <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,500,500i,600,600i,700,700i&amp;subset=latin-ext\">\n    </head>\n    <body>\n        <div class=\"page\">\n            <div class=\"page-main\">\n                <div class=\"header\">\n                    <div class=\"container\">\n                        <div class=\"d-flex\">\n                            <a class=\"navbar-brand\" href=\"/\">\n                                <img src=\"/images/favicons/favicon-32x32.png\" border=\"0\"> &nbsp; Docker Registry\n                            </a>\n                        </div>\n                    </div>\n                </div>\n                <div id=\"app\">\n                    <!-- app -->\n                    <span class=\"loader\"></span>\n                </div>\n            </div>\n            <footer class=\"footer\">\n                <div class=\"container\">\n                    <div class=\"row align-items-center flex-row-reverse\">\n                        <div class=\"col-auto ml-auto\">\n                            <div class=\"row align-items-center\">\n                                <div class=\"col-auto\">\n                                    <ul class=\"list-inline list-inline-dots mb-0\">\n                                        <li class=\"list-inline-item\"><a href=\"https://github.com/jc21/docker-registry-ui?utm_source=docker-registry-ui\">Fork me on Github</a></li>\n                                    </ul>\n                                </div>\n                            </div>\n                        </div>\n                        <div class=\"col-12 col-lg-auto mt-3 mt-lg-0 text-center\">\n                            v<span id=\"version_number\"></span> &copy; 2018 <a href=\"https://jc21.com?utm_source=docker-registry-ui\" target=\"_blank\">jc21.com</a>. Theme by <a href=\"https://github.com/tabler/tabler?utm_source=docker-registry-ui\" target=\"_blank\">Tabler</a>\n                        </div>\n                    </div>\n                </div>\n            </footer>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "src/frontend/js/actions.js",
    "content": "import {location} from 'hyperapp-hash-router';\nimport Api from './lib/api';\nimport $ from 'jquery';\nimport moment from 'moment';\n\nconst fetching = {};\n\nconst actions = {\n    location: location.actions,\n\n    /**\n     * @param state\n     * @returns {*}\n     */\n    updateState: state => state,\n\n    /**\n     * @returns {Function}\n     */\n    bootstrap: () => async (state, actions) => {\n        try {\n            let status = await Api.status();\n            $('#version_number').text([status.version.major, status.version.minor, status.version.revision].join('.'));\n            let repos = await Api.Repos.getAll(true);\n\n            // Hack to remove any image that has no tags\n            let clean_repos = [];\n            repos.map(repo => {\n                if (typeof repo.tags !== 'undefined' && repo.tags !== null && repo.tags.length) {\n                    clean_repos.push(repo);\n                }\n            });\n\n            actions.updateState({isLoading: false, status: status, repos: clean_repos, globalError: null});\n        } catch (err) {\n            actions.updateState({isLoading: false, globalError: err});\n        }\n    },\n\n    /**\n     * @returns {Function}\n     */\n    fetchImage: image_id => async (state, actions) => {\n        if (typeof fetching[image_id] === 'undefined' || !fetching[image_id]) {\n            fetching[image_id] = true;\n\n            let image_item = {\n                err:       null,\n                timestamp: parseInt(moment().format('X'), 10),\n                data:      null\n            };\n\n            try {\n                image_item.data = await Api.Repos.get(image_id, true);\n            } catch (err) {\n                image_item.err = err;\n            }\n\n            let new_state              = {images: state.images};\n            new_state.images[image_id] = image_item;\n            actions.updateState(new_state);\n            fetching[image_id] = false;\n        }\n    },\n\n    deleteImageClicked: e => async (state, actions) => {\n        let $btn     = $(e.currentTarget).addClass('btn-loading disabled').prop('disabled', true);\n        let $modal   = $btn.parents('.modal').first();\n        let image_id = $btn.data('image_id');\n\n        Api.Repos.delete(image_id, $btn.data('digest'))\n            .then(result => {\n                if (typeof result.code !== 'undefined' && result.code === 'UNSUPPORTED') {\n                    throw new Error('Deleting is not enabled on the Registry');\n                } else if (result === true) {\n                    $modal.modal('hide');\n\n                    let new_state = {\n                        isLoaded: true,\n                        images:   state.images\n                    };\n\n                    delete new_state.images[image_id];\n\n                    setTimeout(function () {\n                        actions.updateState(new_state);\n\n                        actions.location.go('/');\n                        actions.bootstrap();\n                    }, 300);\n                } else {\n                    throw new Error('Unrecognized response: ' + JSON.stringify(result));\n                }\n            })\n            .catch(err => {\n                console.error(err);\n                $modal.find('.modal-body').append($('<p>').addClass('text-danger').text(err.message));\n                $btn.removeClass('btn-loading disabled').prop('disabled', false);\n            });\n    }\n};\n\nexport default actions;\n"
  },
  {
    "path": "src/frontend/js/components/app/image-tag.js",
    "content": "import {div, h3, p, a} from '@hyperapp/html';\nimport Utils from '../../lib/utils';\n\nexport default (tag, config) => {\n    let total_size = 0;\n    if (typeof tag.layers !== 'undefined' && tag.layers) {\n        tag.layers.map(layer => total_size += layer.size);\n        total_size = total_size / 1024 / 1024;\n        total_size = total_size.toFixed(0);\n    }\n\n    let domain = config.REGISTRY_DOMAIN || window.location.hostname;\n\n    return div({class: 'card tag-card'}, [\n        div({class: 'card-header'},\n            h3({class: 'card-title'}, tag.name)\n        ),\n        div({class: 'card-alert alert alert-secondary mb-0 pull-command'},\n            'docker pull ' + domain + '/' + tag.image_name + ':' + tag.name\n        ),\n        div({class: 'card-body'},\n            div({class: 'row'}, [\n                div({class: 'col-lg-3 col-sm-6'}, [\n                    div({class: 'h6'}, 'Image ID'),\n                    p(Utils.getShortDigestId(tag.config.digest))\n                ]),\n                div({class: 'col-lg-3 col-sm-6'}, [\n                    div({class: 'h6'}, 'Author'),\n                    p(tag.info.author)\n                ]),\n                div({class: 'col-lg-3 col-sm-6'}, [\n                    div({class: 'h6'}, 'Docker Version'),\n                    p(tag.info.docker_version)\n                ]),\n                div({class: 'col-lg-3 col-sm-6'}, [\n                    div({class: 'h6'}, 'Size'),\n                    p(total_size ? total_size + ' mb' : 'Unknown')\n                ])\n            ])\n        )\n    ]);\n}\n"
  },
  {
    "path": "src/frontend/js/components/app/insecure-registries.js",
    "content": "import {div, h3, h4, p, pre, code} from '@hyperapp/html';\n\nexport default domain => div({class: 'card'},\n    div({class: 'card-header'},\n        h3({class: 'card-title'}, 'Insecure Registries')\n    ),\n    div({class: 'card-body'}, [\n        p('If this registry is insecure and doesn\\'t hide behind SSL certificates then you will need to configure your Docker client to allow pushing to this insecure registry.'),\n        h4('Linux'),\n        p('Edit or you may even need to create the following file on your Linux server:'),\n        pre(\n            code('/etc/docker/daemon.json')\n        ),\n        p('And save the following content:'),\n        pre(\n            code(JSON.stringify({'insecure-registries': [domain]}, null, 2))\n        ),\n        p('You will need to restart your Docker service before these changes will take effect.')\n    ])\n);\n"
  },
  {
    "path": "src/frontend/js/components/tabler/big-error.js",
    "content": "import {div, i, h1, p, a} from '@hyperapp/html';\n\n/**\n * @param {Number}  code\n * @param {String}  message\n * @param {*}       [detail]\n * @para, {Boolean} [hide_back_button]\n */\nexport default (code, message, detail, hide_back_button) =>\n    div({class: 'container text-center'}, [\n        div({class: 'display-1 text-muted mb-5'}, [\n            i({class: 'si si-exclamation'}),\n            code\n        ]),\n        h1({class: 'h2 mb-3'}, message),\n        p({class: 'h4 text-muted font-weight-normal mb-7'}, detail),\n        hide_back_button ? null : a({class: 'btn btn-primary', href: 'javascript:history.back();'}, [\n            i({class: 'fe fe-arrow-left mr-2'}),\n            'Go back'\n        ])\n    ]);\n"
  },
  {
    "path": "src/frontend/js/components/tabler/icon-stat-card.js",
    "content": "import {div, i, span, h4, small} from '@hyperapp/html';\nimport Utils from '../../lib/utils';\n\n/**\n * @param {String|Number}  stat_number\n * @param {String}  stat_text\n * @param {String}  icon        without 'fe-' prefix\n * @param {String}  color       ie: 'green' from tabler 'bg-' class names\n */\nexport default (stat_number, stat_text, icon, color) =>\n    div({class: 'card p-3'},\n        div({class: 'd-flex align-items-center'}, [\n            span({class: 'stamp stamp-md bg-' + color + ' mr-3'},\n                i({class: 'fe fe-' + icon})\n            ),\n            div({},\n                h4({class: 'm-0'}, [\n                    typeof stat_number === 'number' ? Utils.niceNumber(stat_number) : stat_number,\n                    small(' ' + stat_text)\n                ])\n            )\n        ])\n    );\n"
  },
  {
    "path": "src/frontend/js/components/tabler/modal.js",
    "content": "import {div} from '@hyperapp/html';\nimport $ from 'jquery';\n\nexport default (content, onclose) => div({class: 'modal fade', tabindex: '-1', role: 'dialog', ariaHidden: 'true', oncreate: function (elm) {\n    let modal = $(elm);\n    modal.modal('show');\n\n    if (typeof onclose === 'function') {\n        modal.on('hidden.bs.modal', onclose);\n    }\n}}, content);\n"
  },
  {
    "path": "src/frontend/js/components/tabler/nav.js",
    "content": "import {div, i, ul, li, a} from '@hyperapp/html';\nimport {Link} from 'hyperapp-hash-router';\n\nexport default (show_delete) => {\n\n    let selected = 'images';\n    if (window.location.hash.substr(0, 14) === '#/instructions') {\n        selected = 'instructions';\n    }\n\n    return div({class: 'header collapse d-lg-flex p-0', id: 'headerMenuCollapse'},\n        div({class: 'container'},\n            div({class: 'row align-items-center'},\n                div({class: 'col-lg order-lg-first'}, [\n                    ul({class: 'nav nav-tabs border-0 flex-column flex-lg-row'}, [\n                        li({class: 'nav-item'},\n                            Link({class: 'nav-link' + (selected === 'images' ? ' active' : ''), to: '/'}, [\n                                i({class: 'fe fe-box'}),\n                                'Images'\n                            ])\n                        ),\n                        li({class: 'nav-item'}, [\n                            a({class: 'nav-link' + (selected === 'instructions' ? ' active' : ''), href: 'javascript:void(0)', 'data-toggle': 'dropdown'}, [\n                                i({class: 'fe fe-feather'}),\n                                'Instructions'\n                            ]),\n                            div({class: 'dropdown-menu dropdown-menu-arrow'}, [\n                                Link({class: 'dropdown-item', to: '/instructions/pulling'}, 'Pulling'),\n                                Link({class: 'dropdown-item', to: '/instructions/pushing'}, 'Pushing'),\n                                show_delete ? Link({class: 'dropdown-item', to: '/instructions/deleting'}, 'Deleting') : null\n                            ])\n                        ])\n                    ])\n                ])\n            )\n        )\n    );\n}\n"
  },
  {
    "path": "src/frontend/js/components/tabler/stat-card.js",
    "content": "import {div, i} from '@hyperapp/html';\nimport Utils from '../../lib/utils';\n\n/**\n * @param {String|Number}  big_stat\n * @param {String}  stat_text\n * @param {String}  small_stat\n * @param {Boolean} negative       If truthy, shows as red. Otherwise, green.\n */\nexport default (big_stat, stat_text, small_stat, negative) =>\n    div({class: 'card'},\n        div({class: 'card-body p-3 text-center'}, [\n            small_stat ? div({class: 'text-right ' + (negative ? 'text-red' : 'text-green')}, [\n                small_stat,\n                i({class: 'fe ' + (negative ? 'fe-chevron-down' : 'fe-chevron-up')})\n            ]) : null,\n            div({class: 'h1 m-0'}, typeof big_stat === 'number' ? Utils.niceNumber(big_stat) : big_stat),\n            div({class: 'text-muted mb-4'}, stat_text)\n        ])\n    );\n"
  },
  {
    "path": "src/frontend/js/components/tabler/table-body.js",
    "content": "import {tbody} from '@hyperapp/html';\nimport Trow from './table-row';\nimport _ from 'lodash';\n\n/**\n * @param   {Object}   fields\n * @param   {Array}    rows\n */\nexport default (fields, rows) => {\n    let field_keys = [];\n\n    _.map(fields, (val, key) => {\n        field_keys.push(key);\n    });\n\n    return tbody(rows.map(row => {\n        return Trow(_.pick(row, field_keys), fields);\n    }));\n}\n\n"
  },
  {
    "path": "src/frontend/js/components/tabler/table-card.js",
    "content": "import {div, table} from '@hyperapp/html';\nimport Thead from './table-head';\nimport Tbody from './table-body';\n\n/**\n * @param   {Array}    header\n * @param   {Object}   fields\n * @param   {Array}    rows\n */\nexport default (header, fields, rows) =>\n    div({class: 'card'},\n        div({class: 'table-responsive'},\n            table({class: 'table table-hover table-outline table-vcenter text-nowrap card-table'}, [\n                Thead(header),\n                Tbody(fields, rows)\n            ])\n        )\n    );\n"
  },
  {
    "path": "src/frontend/js/components/tabler/table-head.js",
    "content": "import {thead, tr, th} from '@hyperapp/html';\nimport _ from 'lodash';\n\n/**\n * @param {Array}   header\n */\nexport default function (header) {\n    let cells = [];\n\n    _.map(header, cell => {\n        if (typeof cell === 'object' && typeof cell.class !== 'undefined' && cell.class) {\n            cells.push(th({class: cell.class}, cell.value));\n        } else {\n            cells.push(th(cell));\n        }\n    });\n\n    return thead({},\n        tr({}, cells)\n    );\n};\n"
  },
  {
    "path": "src/frontend/js/components/tabler/table-row.js",
    "content": "import {tr, td} from '@hyperapp/html';\nimport _ from 'lodash';\n\n/**\n * @param   {Object}  row\n * @param   {Object}  fields\n */\nexport default function (row, fields) {\n    let cells = [];\n\n    _.map(row, (cell, key) => {\n        let manipulator = fields[key].manipulator || null;\n        let value = cell;\n\n        if (typeof cell === 'object' && cell !== null && typeof cell.value !== 'undefined') {\n            value = cell.value;\n        }\n\n        if (typeof manipulator === 'function') {\n            value = manipulator(value, cell);\n        }\n\n        if (typeof cell.attributes !== 'undefined' && cell.attributes) {\n            cells.push(td(cell.attributes, value));\n        } else {\n            cells.push(td(value));\n        }\n    });\n\n    return tr(cells);\n};\n"
  },
  {
    "path": "src/frontend/js/index.js",
    "content": "// This has to exist here so that Webpack picks it up\nimport '../scss/styles.scss';\n\nimport $ from 'jquery';\nimport {app} from 'hyperapp';\nimport actions from './actions';\nimport state from './state';\nimport {location} from 'hyperapp-hash-router';\nimport router from './router';\n\nglobal.jQuery = $;\nglobal.$      = $;\n\nwindow.tabler = {\n    colors: {\n        'blue':               '#467fcf',\n        'blue-darkest':       '#0e1929',\n        'blue-darker':        '#1c3353',\n        'blue-dark':          '#3866a6',\n        'blue-light':         '#7ea5dd',\n        'blue-lighter':       '#c8d9f1',\n        'blue-lightest':      '#edf2fa',\n        'azure':              '#45aaf2',\n        'azure-darkest':      '#0e2230',\n        'azure-darker':       '#1c4461',\n        'azure-dark':         '#3788c2',\n        'azure-light':        '#7dc4f6',\n        'azure-lighter':      '#c7e6fb',\n        'azure-lightest':     '#ecf7fe',\n        'indigo':             '#6574cd',\n        'indigo-darkest':     '#141729',\n        'indigo-darker':      '#282e52',\n        'indigo-dark':        '#515da4',\n        'indigo-light':       '#939edc',\n        'indigo-lighter':     '#d1d5f0',\n        'indigo-lightest':    '#f0f1fa',\n        'purple':             '#a55eea',\n        'purple-darkest':     '#21132f',\n        'purple-darker':      '#42265e',\n        'purple-dark':        '#844bbb',\n        'purple-light':       '#c08ef0',\n        'purple-lighter':     '#e4cff9',\n        'purple-lightest':    '#f6effd',\n        'pink':               '#f66d9b',\n        'pink-darkest':       '#31161f',\n        'pink-darker':        '#622c3e',\n        'pink-dark':          '#c5577c',\n        'pink-light':         '#f999b9',\n        'pink-lighter':       '#fcd3e1',\n        'pink-lightest':      '#fef0f5',\n        'red':                '#e74c3c',\n        'red-darkest':        '#2e0f0c',\n        'red-darker':         '#5c1e18',\n        'red-dark':           '#b93d30',\n        'red-light':          '#ee8277',\n        'red-lighter':        '#f8c9c5',\n        'red-lightest':       '#fdedec',\n        'orange':             '#fd9644',\n        'orange-darkest':     '#331e0e',\n        'orange-darker':      '#653c1b',\n        'orange-dark':        '#ca7836',\n        'orange-light':       '#feb67c',\n        'orange-lighter':     '#fee0c7',\n        'orange-lightest':    '#fff5ec',\n        'yellow':             '#f1c40f',\n        'yellow-darkest':     '#302703',\n        'yellow-darker':      '#604e06',\n        'yellow-dark':        '#c19d0c',\n        'yellow-light':       '#f5d657',\n        'yellow-lighter':     '#fbedb7',\n        'yellow-lightest':    '#fef9e7',\n        'lime':               '#7bd235',\n        'lime-darkest':       '#192a0b',\n        'lime-darker':        '#315415',\n        'lime-dark':          '#62a82a',\n        'lime-light':         '#a3e072',\n        'lime-lighter':       '#d7f2c2',\n        'lime-lightest':      '#f2fbeb',\n        'green':              '#5eba00',\n        'green-darkest':      '#132500',\n        'green-darker':       '#264a00',\n        'green-dark':         '#4b9500',\n        'green-light':        '#8ecf4d',\n        'green-lighter':      '#cfeab3',\n        'green-lightest':     '#eff8e6',\n        'teal':               '#2bcbba',\n        'teal-darkest':       '#092925',\n        'teal-darker':        '#11514a',\n        'teal-dark':          '#22a295',\n        'teal-light':         '#6bdbcf',\n        'teal-lighter':       '#bfefea',\n        'teal-lightest':      '#eafaf8',\n        'cyan':               '#17a2b8',\n        'cyan-darkest':       '#052025',\n        'cyan-darker':        '#09414a',\n        'cyan-dark':          '#128293',\n        'cyan-light':         '#5dbecd',\n        'cyan-lighter':       '#b9e3ea',\n        'cyan-lightest':      '#e8f6f8',\n        'gray':               '#868e96',\n        'gray-darkest':       '#1b1c1e',\n        'gray-darker':        '#36393c',\n        'gray-light':         '#aab0b6',\n        'gray-lighter':       '#dbdde0',\n        'gray-lightest':      '#f3f4f5',\n        'gray-dark':          '#343a40',\n        'gray-dark-darkest':  '#0a0c0d',\n        'gray-dark-darker':   '#15171a',\n        'gray-dark-dark':     '#2a2e33',\n        'gray-dark-light':    '#717579',\n        'gray-dark-lighter':  '#c2c4c6',\n        'gray-dark-lightest': '#ebebec'\n    }\n};\n\nimport tabler from 'tabler-core';\n\nconst main = app(\n    state,\n    actions,\n    router,\n    document.getElementById('app')\n);\n\nlocation.subscribe(main.location);\n\nmain.bootstrap();\nsetInterval(main.bootstrap, 30000);\n"
  },
  {
    "path": "src/frontend/js/lib/api.js",
    "content": "import $ from 'jquery';\n\n/**\n * @param {String}  message\n * @param {*}       debug\n * @param {Integer} [code]\n * @constructor\n */\nconst ApiError = function (message, debug, code) {\n    let temp  = Error.call(this, message);\n    temp.name = this.name = 'ApiError';\n    this.stack   = temp.stack;\n    this.message = temp.message;\n    this.debug   = debug;\n    this.code    = code;\n};\n\nApiError.prototype = Object.create(Error.prototype, {\n    constructor: {\n        value:        ApiError,\n        writable:     true,\n        configurable: true\n    }\n});\n\n/**\n *\n * @param   {String} verb\n * @param   {String} path\n * @param   {Object} [data]\n * @param   {Object} [options]\n * @returns {Promise}\n */\nfunction fetch (verb, path, data, options) {\n    options = options || {};\n\n    return new Promise(function (resolve, reject) {\n        let api_url = '/api/';\n        let url     = api_url + path;\n\n        $.ajax({\n            url:         url,\n            data:        typeof data === 'object' ? JSON.stringify(data) : data,\n            type:        verb,\n            dataType:    'json',\n            contentType: 'application/json; charset=UTF-8',\n            crossDomain: true,\n            timeout:     (options.timeout ? options.timeout : 15000),\n            xhrFields:   {\n                withCredentials: true\n            },\n\n            success: function (data, textStatus, response) {\n                let total = response.getResponseHeader('X-Dataset-Total');\n                if (total !== null) {\n                    resolve({\n                        data:       data,\n                        pagination: {\n                            total:  parseInt(total, 10),\n                            offset: parseInt(response.getResponseHeader('X-Dataset-Offset'), 10),\n                            limit:  parseInt(response.getResponseHeader('X-Dataset-Limit'), 10)\n                        }\n                    });\n                } else {\n                    resolve(response);\n                }\n            },\n\n            error: function (xhr, status, error_thrown) {\n                let code = 400;\n\n                if (typeof xhr.responseJSON !== 'undefined' && typeof xhr.responseJSON.error !== 'undefined' && typeof xhr.responseJSON.error.message !== 'undefined') {\n                    error_thrown = xhr.responseJSON.error.message;\n                    code         = xhr.responseJSON.error.code || 500;\n                }\n\n                reject(new ApiError(error_thrown, xhr.responseText, code));\n            }\n        });\n    });\n}\n\nexport default {\n    status: function () {\n        return fetch('get', '');\n    },\n\n    Repos: {\n        /**\n         * @param   {Boolean}  [with_tags]\n         * @returns {Promise}\n         */\n        getAll: function (with_tags) {\n            return fetch('get', 'repos' + (with_tags ? '?tags=1' : ''));\n        },\n\n        /**\n         * @param   {String}  name\n         * @param   {Boolean} [full]\n         * @returns {Promise}\n         */\n        get: function (name, full) {\n            return fetch('get', 'repos/' + name + (full ? '?full=1' : ''));\n        },\n\n        /**\n         * @param   {String}  name\n         * @param   {String} [digest]\n         * @returns {Promise}\n         */\n        delete: function (name, digest) {\n            return fetch('delete', 'repos/' + name + '?digest=' + digest);\n        }\n    }\n};\n"
  },
  {
    "path": "src/frontend/js/lib/manipulators.js",
    "content": "import {div} from '@hyperapp/html';\nimport {Link} from 'hyperapp-hash-router';\n\nexport default {\n\n    /**\n     * @returns {Function}\n     */\n    imageName: function () {\n        return (value, cell) => {\n            return Link({to: '/image/' + value}, value);\n        }\n    },\n\n    /**\n     * @param   {String} delimiter\n     * @returns {Function}\n     */\n    joiner: delimiter => (value, cell) => value.join(delimiter)\n\n};\n"
  },
  {
    "path": "src/frontend/js/lib/utils.js",
    "content": "import numeral from 'numeral';\n\nexport default {\n\n    /**\n     * @param   {Integer} number\n     * @returns {String}\n     */\n    niceNumber: function (number) {\n        return numeral(number).format('0,0');\n    },\n\n    /**\n     * @param   {String}  digest\n     * @returns {String}\n     */\n    getShortDigestId: function (digest) {\n        return digest.replace(/^sha256:(.{12}).*/gim, '$1');\n    }\n};\n"
  },
  {
    "path": "src/frontend/js/router.js",
    "content": "import {Route} from 'hyperapp-hash-router';\nimport {div, span, a, p} from '@hyperapp/html';\nimport ImagesRoute from './routes/images';\nimport ImageRoute from './routes/image';\nimport PushingRoute from './routes/instructions/pushing';\nimport PullingRoute from './routes/instructions/pulling';\nimport DeletingRoute from './routes/instructions/deleting';\nimport BigError from './components/tabler/big-error';\n\nexport default (state, actions) => {\n    if (state.isLoading) {\n        return span({class: 'loader'});\n    } else {\n\n        if (state.globalError !== null && state.globalError) {\n            return BigError(state.globalError.code || '500', state.globalError.message,\n                [\n                    p('There may be a problem communicating with the Registry'),\n                    a({\n                        class: 'btn btn-link', onclick: function () {\n                            actions.bootstrap();\n                        }\n                    }, 'Refresh')\n                ],\n                true\n            );\n        } else {\n            return div(\n                Route({path: '/', render: ImagesRoute(state, actions)}),\n                Route({path: '/image/:imageId', render: ImageRoute(state, actions)}),\n                Route({path: '/image/:imageDomain/:imageId', render: ImageRoute(state, actions)}),\n                Route({path: '/instructions/pushing', render: PushingRoute(state, actions)}),\n                Route({path: '/instructions/pulling', render: PullingRoute(state, actions)}),\n                Route({path: '/instructions/deleting', render: DeletingRoute(state, actions)})\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "src/frontend/js/routes/image.js",
    "content": "import {div, h1, span, a, h4, button, p} from '@hyperapp/html';\nimport Nav from '../components/tabler/nav';\nimport BigError from '../components/tabler/big-error';\nimport ImageTag from '../components/app/image-tag';\nimport Modal from '../components/tabler/modal';\nimport moment from 'moment';\n\nexport default (state, actions) => params => {\n    let image_id            = params.match.params.imageId;\n    let view                = [];\n    let delete_enabled      = state.status.config.REGISTRY_STORAGE_DELETE_ENABLED || false;\n    let refresh             = false;\n    let digest              = null;\n    let now                 = parseInt(moment().format('X'), 10);\n    let append_delete_model = false;\n    let image               = null;\n\n    if (typeof params.match.params.imageDomain !== 'undefined' && params.match.params.imageDomain.length > 0) {\n      image_id = [params.match.params.imageDomain, image_id].join('/');\n    }\n\n    // if image doesn't exist in state: refresh\n    if (typeof state.images[image_id] === 'undefined' || !state.images[image_id]) {\n        refresh = true;\n    } else {\n        image = state.images[image_id];\n\n        // if image does exist, but hasn't been refreshed in < 30 seconds, refresh\n        if (image.timestamp < (now - 30)) {\n            refresh = true;\n\n            // if image does exist, but has error, show error\n        } else if (image.err) {\n            view.push(BigError(image.err.code, image.err.message,\n                a({\n                    class: 'btn btn-link', onclick: function () {\n                        actions.fetchImage(image_id);\n                    }\n                }, 'Refresh')\n            ));\n\n            // if image does exist, but has no error and no data, 404\n        } else if (!image.data || typeof image.data.tags === 'undefined' || image.data.tags === null || !image.data.tags.length) {\n            view.push(BigError(404, image_id + ' does not exist in this Registry',\n                a({\n                    class: 'btn btn-link', onclick: function () {\n                        actions.fetchImage(image_id);\n                    }\n                }, 'Refresh')\n            ));\n        } else {\n            // Show it\n            // This is where shit gets weird. Digest is the same for all tags, but only stored with a tag.\n            digest              = image.data.tags[0].digest;\n            append_delete_model = delete_enabled && state.confirmDeleteImage === image_id;\n\n            view.push(h1({class: 'page-title mb-5'}, [\n                delete_enabled ? a({\n                    class: 'btn btn-secondary btn-sm ml-2 pull-right', onclick: function () {\n                        actions.updateState({confirmDeleteImage: image_id});\n                    }\n                }, 'Delete') : null,\n                image_id\n            ]));\n            view.push(div(image.data.tags.map(tag => ImageTag(tag, state.status.config))));\n        }\n    }\n\n    if (refresh) {\n        view.push(span({class: 'loader'}));\n        actions.fetchImage(image_id);\n    }\n\n    return div(\n        Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED),\n        div({class: 'my-3 my-md-5'},\n            div({class: 'container'}, view)\n        ),\n        // Delete modal\n        append_delete_model ? Modal(\n            div({class: 'modal-dialog'},\n                div({class: 'modal-content'}, [\n                    div({class: 'modal-header text-left'},\n                        h4({class: 'modal-title'}, 'Confirm Delete')\n                    ),\n                    div({class: 'modal-body'},\n                        p('Are you sure you want to delete this image and tag' + (image.data.tags.length === 1 ? '' : 's') + '?')\n                    ),\n                    div({class: 'modal-footer'}, [\n                        button({\n                            class:           'btn btn-danger',\n                            type:            'button',\n                            onclick:         actions.deleteImageClicked,\n                            'data-image_id': image_id,\n                            'data-digest':   digest\n                        }, 'Yes I\\'m sure'),\n                        button({class: 'btn btn-default', type: 'button', 'data-dismiss': 'modal'}, 'Cancel')\n                    ])\n                ])\n            ),\n            // onclose function\n            function () {\n                actions.updateState({confirmDeleteImage: null});\n            }) : null\n    );\n}\n"
  },
  {
    "path": "src/frontend/js/routes/images.js",
    "content": "import {Link} from 'hyperapp-hash-router';\nimport {div, h4, p} from '@hyperapp/html';\nimport Nav from '../components/tabler/nav';\nimport TableCard from '../components/tabler/table-card';\nimport Manipulators from '../lib/manipulators';\nimport {a} from '@hyperapp/html/dist/html';\n\nexport default (state, actions) => params => {\n    let content = null;\n\n    if (!state.repos || !state.repos.length) {\n        // empty\n        content = div({class: 'alert alert-success'}, [\n            h4('Nothing to see here!'),\n            p('There are no images in this Registry yet.'),\n            div({class: 'btn-list'},\n                Link({class: 'btn btn-success', to: '/instructions/pushing'}, 'How to push an image')\n            )\n        ]);\n\n    } else {\n        content = TableCard([\n                'Name',\n                'Tags'\n            ], {\n                name: {manipulator: Manipulators.imageName()},\n                tags: {manipulator: Manipulators.joiner(', ')}\n            }, state.repos);\n    }\n\n    return div(\n        Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED),\n        div({class: 'my-3 my-md-5'},\n            div({class: 'container'}, content)\n        ),\n        p({class: 'text-center'},\n            a({\n                class: 'btn btn-link text-faded', onclick: function () {\n                    actions.bootstrap();\n                }\n            }, 'Refresh')\n        )\n    );\n}\n"
  },
  {
    "path": "src/frontend/js/routes/instructions/deleting.js",
    "content": "import {div, h1, h3, p, pre, code} from '@hyperapp/html';\nimport Nav from '../../components/tabler/nav';\n\nexport default (state, actions) => params => div(\n    Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED),\n    div({class: 'my-3 my-md-5'},\n        div({class: 'container'}, [\n            h1({class: 'page-title mb-5'}, 'Deleting from this Registry'),\n            div({class: 'card'},\n                div({class: 'card-body'},\n                    p('Deleting from a Docker Registry is possible, but not very well implemented. For this reason, deletion options were disabled in this Registry UI project by default. However if you still want to be able to delete images from this registry you will need to set a few things up.'),\n                )\n            ),\n            div({class: 'card'}, [\n                div({class: 'card-header'},\n                    h3({class: 'card-title'}, 'Permit deleting on the Registry')\n                ),\n                div({class: 'card-body'}, [\n                    p('This step is pretty simple and involves adding an environment variable to your Docker Registry Container when you start it up:'),\n                    pre(\n                        code('docker run -d -p 5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true --name my-registry registry:2')\n                    )\n                ])\n            ]),\n            div({class: 'card'}, [\n                div({class: 'card-header'},\n                    h3({class: 'card-title'}, 'Cleaning up the Registry')\n                ),\n                div({class: 'card-body'}, [\n                    p('When you delete an image from the registry this won\\'t actually remove the blob layers as you might expect. For this reason you have to run this command on your docker registry host to perform garbage collection:'),\n                    pre(\n                        code('docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml')\n                    ),\n                    p('And if you wanted to make a cron job that runs every 30 mins:'),\n                    pre(\n                        code('0,30 * * * * /bin/docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml >> /dev/null 2>&1')\n                    )\n                ])\n            ])\n        ])\n    )\n);\n\n"
  },
  {
    "path": "src/frontend/js/routes/instructions/pulling.js",
    "content": "import {div, h1, p, pre, code} from '@hyperapp/html';\nimport Nav from '../../components/tabler/nav';\nimport Insecure from '../../components/app/insecure-registries';\n\nexport default (state, actions) => params => {\n    let domain = state.status.config.REGISTRY_DOMAIN || window.location.hostname;\n\n    return div(\n        Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED),\n        div({class: 'my-3 my-md-5'},\n            div({class: 'container'}, [\n                h1({class: 'page-title mb-5'}, 'Pulling from this Registry'),\n                div({class: 'card'},\n                    div({class: 'card-body'},\n                        p('Viewing any Image from the Repositories menu will give you a command in the following format:'),\n                        pre(\n                            code('docker pull ' + domain + '/<someimage>:<tag>')\n                        )\n                    )\n                ),\n                Insecure(domain)\n            ])\n        )\n    );\n}\n"
  },
  {
    "path": "src/frontend/js/routes/instructions/pushing.js",
    "content": "import {div, h1, p, pre, code} from '@hyperapp/html';\nimport Nav from '../../components/tabler/nav';\nimport Insecure from '../../components/app/insecure-registries';\n\nexport default (state, actions) => params => {\n    let domain = state.status.config.REGISTRY_DOMAIN || window.location.hostname;\n\n    return div(\n        Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED),\n        div({class: 'my-3 my-md-5'},\n            div({class: 'container'}, [\n                h1({class: 'page-title mb-5'}, 'Pushing to this Registry'),\n                div({class: 'card'},\n                    div({class: 'card-body'},\n                        p('After you pull or build an image:'),\n                        pre(\n                            code('docker tag <someimage> ' + domain + '/<someimage>:<tag>' + \"\\n\" +\n                                'docker push ' + domain + '/<someimage>:<tag>')\n                        )\n                    )\n                ),\n                Insecure(domain)\n            ])\n        )\n    );\n}\n"
  },
  {
    "path": "src/frontend/js/state.js",
    "content": "import {location} from 'hyperapp-hash-router';\n\nexport default {\n    location:           location.state,\n    isLoading:          true,\n    globalError:        null,\n    confirmDeleteImage: null,\n    images:             {}\n};\n"
  },
  {
    "path": "src/frontend/scss/styles.scss",
    "content": "@import \"~tabler-ui/dist/assets/css/dashboard\";\n\n/* Before any JS content is loaded */\n#app > .loader, .container > .loader {\n    position: absolute;\n    left: 49%;\n    top: 40%;\n    display: block;\n}\n\n.tag-card {\n    .pull-command {\n        font-family: Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n    }\n}\n\n.pull-right {\n    float: right;\n}\n\n.text-faded {\n    opacity: 0.5;\n}\n"
  },
  {
    "path": "webpack.config.js",
    "content": "const path                 = require('path');\nconst webpack              = require('webpack');\nconst HtmlWebPackPlugin    = require('html-webpack-plugin');\nconst MiniCssExtractPlugin = require('mini-css-extract-plugin');\nconst Visualizer           = require('webpack-visualizer-plugin');\nconst CopyWebpackPlugin    = require('copy-webpack-plugin');\n\nmodule.exports = {\n    entry:        './src/frontend/js/index.js',\n    output:       {\n        path:       path.resolve(__dirname, 'dist'),\n        filename:   'js/main.js',\n        publicPath: '/'\n    },\n    resolve:      {\n        alias: {\n            'tabler-core':      'tabler-ui/dist/assets/js/core',\n            'bootstrap':        'tabler-ui/dist/assets/js/vendors/bootstrap.bundle.min',\n            'sparkline':        'tabler-ui/dist/assets/js/vendors/jquery.sparkline.min',\n            'selectize':        'tabler-ui/dist/assets/js/vendors/selectize.min',\n            'tablesorter':      'tabler-ui/dist/assets/js/vendors/jquery.tablesorter.min',\n            'vector-map':       'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-2.0.3.min',\n            'vector-map-de':    'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-de-merc',\n            'vector-map-world': 'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-world-mill',\n            'circle-progress':  'tabler-ui/dist/assets/js/vendors/circle-progress.min'\n        }\n    },\n    module:       {\n        rules: [\n            // Shims for tabler-ui\n            {\n                test:   /assets\\/js\\/core/,\n                loader: 'imports-loader?bootstrap'\n            },\n            {\n                test:   /jquery-jvectormap-de-merc/,\n                loader: 'imports-loader?vector-map'\n            },\n            {\n                test:   /jquery-jvectormap-world-mill/,\n                loader: 'imports-loader?vector-map'\n            },\n\n            // other:\n            {\n                test:    /\\.js$/,\n                exclude: /node_modules/,\n                use:     {\n                    loader: 'babel-loader'\n                }\n            },\n            {\n                test: /\\.html$/,\n                use:  [\n                    {\n                        loader:  'html-loader',\n                        options: {\n                            minimize: false,\n                            hash:     true\n                        }\n                    }\n                ]\n            },\n            {\n                test: /\\.scss$/,\n                use:  [\n                    MiniCssExtractPlugin.loader,\n                    'css-loader',\n                    'sass-loader'\n                ]\n            },\n            {\n                test: /.*tabler.*\\.(jpe?g|gif|png|svg|eot|woff|ttf)$/,\n                use:  [\n                    {\n                        loader:  'file-loader',\n                        options: {\n                            outputPath: 'assets/tabler-ui/'\n                        }\n                    }\n                ]\n            }\n        ]\n    },\n    plugins:      [\n        new webpack.ProvidePlugin({\n            $:      'jquery',\n            jQuery: 'jquery'\n        }),\n        new HtmlWebPackPlugin({\n            template: './src/frontend/html/index.html',\n            filename: './index.html'\n        }),\n        new MiniCssExtractPlugin({\n            filename:      'css/[name].css',\n            chunkFilename: 'css/[id].css'\n        }),\n        new Visualizer({\n            filename: '../webpack_stats.html'\n        }),\n        new CopyWebpackPlugin([{\n            from:    'src/frontend/app-images',\n            to:      'images',\n            toType:  'dir',\n            context: '/app'\n        }])\n    ]\n};\n"
  }
]