├── .eslintignore ├── .eslintrc-browser.js ├── .eslintrc-node.js ├── .github ├── renovate.json └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── .janitor ├── Dockerfile ├── db.json └── janitor.json ├── LICENSE ├── Makefile ├── README.md ├── api ├── admin-api.js ├── blog-api.js ├── hosts-api.js ├── index.js ├── projects-api.js └── user-api.js ├── app.js ├── join.js ├── lib ├── azure.js ├── blog.js ├── boot.js ├── certificates.js ├── configurations.js ├── db.js ├── docker.js ├── events.js ├── github.js ├── hosts.js ├── log.js ├── machines.js ├── metrics.js ├── oauth2.js ├── proxy-heuristics.js ├── routes.js ├── sessions.js ├── streams.js └── users.js ├── package.json ├── static ├── browserconfig.xml ├── css │ ├── api-reference.css │ ├── blog.css │ ├── bootstrap-theme.css │ ├── bootstrap-theme.min.css │ ├── bootstrap.css │ ├── bootstrap.min.css │ ├── containers.css │ ├── data.css │ ├── janitor-new.css │ ├── janitor.css │ ├── landing.css │ ├── projects.css │ └── settings.css ├── favicon.ico ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 ├── img │ ├── bmo.svg │ ├── bors.svg │ ├── c9.svg │ ├── chrome.svg │ ├── chromium.svg │ ├── cozy.svg │ ├── discourse.svg │ ├── dspace.svg │ ├── favicons │ │ ├── favicon-16x16.png │ │ ├── favicon-196x196.png │ │ ├── favicon-32x32.png │ │ └── favicon.svg │ ├── firefox.svg │ ├── git.svg │ ├── github.svg │ ├── icons │ │ ├── arrow-down.svg │ │ ├── arrow-up.svg │ │ ├── check.svg │ │ ├── edit.svg │ │ ├── error.svg │ │ ├── link.svg │ │ ├── menu.svg │ │ ├── search.svg │ │ └── warning.svg │ ├── janitor.svg │ ├── kde.svg │ ├── kresus.svg │ ├── linux.svg │ ├── nightly.svg │ ├── partners │ │ ├── datadog.svg │ │ ├── irill.png │ │ └── mozilla.svg │ ├── peertube.svg │ ├── privatebin.svg │ ├── rust.svg │ ├── servo.svg │ ├── thefiletree.svg │ ├── thunderbird.svg │ └── vim.svg ├── js │ ├── admin.js │ ├── blog.js │ ├── bootstrap-3.3.7.min.js │ ├── dygraph-2.0.0.min.js │ ├── fetch-2.0.3.min.js │ ├── graphs.js │ ├── janitor-new.js │ ├── janitor.js │ ├── jquery-3.2.1.min.js │ ├── login.js │ ├── projects.js │ ├── promise-6.0.2.min.js │ ├── scout.min.js │ └── timeago-3.0.2.min.js ├── manifest.json ├── robots.txt └── service-worker.js ├── templates ├── 404.html ├── about.html ├── admin-docker.html ├── admin-header.html ├── admin-hosts.html ├── admin-integrations.html ├── admin-projects.html ├── admin-users.html ├── blog.html ├── configurations │ ├── .config │ │ └── hub │ ├── .gitconfig │ ├── .gitignore │ ├── .hgrc │ ├── .netrc │ └── .ssh │ │ └── authorized_keys ├── containers.html ├── data.html ├── design.html ├── footer-old.html ├── footer.html ├── header-insecure-old.html ├── header-insecure.html ├── header-old.html ├── header.html ├── landing.html ├── login.html ├── notifications.html ├── project.html ├── projects.html ├── reference-api.html ├── settings-account.html ├── settings-configurations.html ├── settings-header.html ├── settings-integrations.html ├── settings-notifications.html └── settings.html └── tests └── tests.js /.eslintignore: -------------------------------------------------------------------------------- 1 | static/js/*.min.js -------------------------------------------------------------------------------- /.eslintrc-browser.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": false 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "standard" 9 | ], 10 | "globals": { 11 | "$": true, 12 | "Dygraph": true, 13 | "Scout": true, 14 | "ajaxForm": true, 15 | "timeago": true, 16 | "updateFormStatus": true, 17 | }, 18 | "rules": { 19 | // Override some of standard js rules. 20 | "semi": ["error", "always"], 21 | "comma-dangle": [ 22 | "error", { 23 | "arrays": "only-multiline", 24 | "objects": "only-multiline", 25 | "imports": "never", 26 | "exports": "never", 27 | "functions": "never", 28 | } 29 | ], 30 | 31 | // Override some eslint base rules because we're using ES5. 32 | "no-new": "off", 33 | 34 | // Custom rules. 35 | "no-console": ["error", {"allow": ["warn", "error"]}], 36 | } 37 | }; -------------------------------------------------------------------------------- /.eslintrc-node.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": false, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:node/recommended", 10 | "standard" 11 | ], 12 | "plugins": [ 13 | "node" 14 | ], 15 | "rules": { 16 | // Override some of standard js rules 17 | "semi": ["error", "always"], 18 | "comma-dangle": ["error", "only-multiline"], 19 | "camelcase": "off", 20 | "no-var": "error", 21 | "prefer-const": "error", 22 | 23 | // Override some eslint base rules because we're using node. 24 | "no-console": "off", 25 | } 26 | }; -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "packageRules": [ 6 | { 7 | "updateTypes": [ 8 | "minor", 9 | "patch", 10 | "pin", 11 | "digest" 12 | ], 13 | "automerge": true 14 | }, 15 | { 16 | "depTypeList": [ 17 | "devDependencies" 18 | ], 19 | "automerge": true 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | NODE: 10.x 9 | 10 | jobs: 11 | ci: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Clone repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: "${{ env.NODE }}" 22 | 23 | - name: Install npm dependencies 24 | run: npm install 25 | 26 | - name: Lint 27 | run: npm run lint 28 | 29 | - name: Run tests 30 | run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .c9 2 | .DS_Store 3 | Thumbs.db 4 | npm-debug.log 5 | node_modules 6 | package-lock.json 7 | 8 | db.json 9 | docker.ca 10 | docker.crt 11 | docker.key 12 | janitor.log 13 | janitor.pid 14 | backups 15 | tokens 16 | tests/db.json 17 | tests/templates 18 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "dockerfiles"] 2 | path = dockerfiles 3 | url = https://github.com/janitortechnology/dockerfiles 4 | -------------------------------------------------------------------------------- /.janitor/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM janitortechnology/ubuntu-dev 2 | 3 | # Download Janitor's source code and install its dependencies. 4 | RUN git clone --recursive https://github.com/JanitorTechnology/janitor /home/user/janitor \ 5 | && cd /home/user/janitor \ 6 | && npm install 7 | WORKDIR /home/user/janitor 8 | 9 | # Add Janitor database with default values for local development. 10 | COPY db.json /home/user/janitor/ 11 | RUN sudo chown user:user /home/user/janitor/db.json 12 | 13 | # Configure the IDEs to use Janitor's source directory as workspace. 14 | ENV WORKSPACE /home/user/janitor/ 15 | 16 | # Expose all Janitor server ports. 17 | EXPOSE 8080 8081 18 | -------------------------------------------------------------------------------- /.janitor/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "hostname": "localhost", 3 | "ports": { 4 | "http": 8081, 5 | "https": 8080 6 | }, 7 | "security": { 8 | "forceHttp": true, 9 | "forceInsecure": true 10 | }, 11 | "mailer": { 12 | "block": true, 13 | "from": "", 14 | "host": "", 15 | "auth": { 16 | "user": "", 17 | "pass": "" 18 | } 19 | }, 20 | "hosts": { 21 | "localhost": { 22 | "properties": { 23 | "port": "2376", 24 | "ca": "", 25 | "crt": "", 26 | "key": "" 27 | }, 28 | "oauth2client": { 29 | "id": "", 30 | "secret": "" 31 | } 32 | } 33 | }, 34 | "projects": {}, 35 | "users": {}, 36 | "admins": { 37 | "admin@localhost": true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.janitor/janitor.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Janitor", 3 | "description": "The fastest development system in the world.", 4 | "icon": "https://janitor.technology/img/janitor.svg", 5 | "docker": { 6 | "image": "janitortechnology/janitor" 7 | }, 8 | "ports": { 9 | "22": { 10 | "label": "SSH", 11 | "proxy": "none" 12 | }, 13 | "8080": { 14 | "label": "Preview", 15 | "proxy": "https", 16 | "preview": true 17 | }, 18 | "8088": { 19 | "label": "VNC", 20 | "proxy": "https" 21 | }, 22 | "8089": { 23 | "label": "Cloud9", 24 | "proxy": "https" 25 | }, 26 | "8090": { 27 | "label": "Theia", 28 | "proxy": "https" 29 | } 30 | }, 31 | "scripts": { 32 | "Start server": "node app", 33 | "Live-reload server": "npm run watch", 34 | "Check coding style": "npm run lint", 35 | "Fix coding style": "npm run lint-fix", 36 | "Run tests": "npm test", 37 | "Update source code": "git pull --rebase origin master", 38 | "Send to code review": "hub pull-request" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile: Tools to help you install Janitor on Ubuntu/Debian. 2 | # Copyright © 2015 Team Janitor. All rights reserved. 3 | # The following code is covered by the AGPL-3.0 license. 4 | 5 | 6 | ### HELP ### 7 | 8 | # This is a self-documented Makefile. 9 | help: 10 | cat Makefile | less 11 | 12 | 13 | ### SET UP NON-SUDO WEB PORTS ### 14 | 15 | # If unspecified, auto-detect the primary network interface (e.g. "eth0"). 16 | ifeq ($(strip $(PRIMARY_INTERFACE)),) 17 | PRIMARY_INTERFACE := `route | grep default | awk '{print $$8}'` 18 | endif 19 | 20 | ports: 21 | cat /etc/rc.local | grep -ve "^exit 0$$" > rc.local 22 | printf "\n# Non-sudo web ports for Janitor.\n" >> rc.local 23 | printf "iptables -t nat -A PREROUTING -i $(PRIMARY_INTERFACE) -p tcp --dport 80 -j REDIRECT --to-port 1080\n" >> rc.local 24 | printf "iptables -t nat -I OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-port 1080\n" >> rc.local 25 | printf "iptables -t nat -A PREROUTING -i $(PRIMARY_INTERFACE) -p tcp --dport 443 -j REDIRECT --to-port 1443\n" >> rc.local 26 | printf "iptables -t nat -I OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-port 1443\n" >> rc.local 27 | printf "\nexit 0\n" >> rc.local 28 | sudo chown root:root rc.local 29 | sudo chmod 755 rc.local # read/write/exec by owner, read/exec by all 30 | sudo mv /etc/rc.local /etc/rc.local.old 31 | sudo mv rc.local /etc/rc.local 32 | sudo /etc/rc.local 33 | 34 | unports: 35 | rm -f rc.local 36 | sudo mv /etc/rc.local.old /etc/rc.local 37 | 38 | 39 | ### ENABLE TLS DOCKER REMOTE API ### 40 | 41 | # Install certificates allowing secure remote access to the local Docker host. 42 | # Use the new "daemon.json" file, and work around a configuration conflict: 43 | # See https://github.com/moby/moby/issues/25471#issuecomment-341912718 44 | docker: docker.ca docker.crt docker.key 45 | sudo mkdir -p /etc/systemd/system/docker.service.d 46 | printf "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd\n" | sudo tee /etc/systemd/system/docker.service.d/simple_dockerd.conf # work around "-H fd://" conflict 47 | sudo systemctl daemon-reload 48 | sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.old 2>/dev/null; true # backup any prior daemon.json, but ignore errors 49 | printf "{\n \"tls\": true,\n \"tlsverify\": true,\n \"tlscacert\": \"$$(pwd)/docker.ca\",\n \"tlscert\": \"$$(pwd)/docker.crt\",\n \"tlskey\": \"$$(pwd)/docker.key\",\n \"icc\": false,\n \"hosts\": [\"tcp://0.0.0.0:2376\", \"unix:///var/run/docker.sock\"]\n}\n" | sudo tee /etc/docker/daemon.json 50 | sudo service docker restart && sleep 1 51 | 52 | # Delete all the installed certificates. 53 | undocker: 54 | sudo rm -f /etc/systemd/system/docker.service.d/simple_dockerd.conf # remove "-H fd://" conflict work-around 55 | sudo systemctl daemon-reload 56 | sudo mv /etc/docker/daemon.json.old /etc/docker/daemon.json 2>/dev/null || sudo rm /etc/docker/daemon.json 2>/dev/null; true # restore any prior daemon.json, but ignore errors 57 | sudo rm -f docker.crt docker.key docker.ca 58 | rm -f extfile.cnf ca.crt docker.csr 59 | sudo service docker restart && sleep 1 60 | 61 | 62 | .PHONY: help ports unports docker undocker 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Janitor 2 | 3 | [![Github Actions](https://github.com/JanitorTechnology/janitor/workflows/CI/badge.svg?branch=master)](https://github.com/JanitorTechnology/janitor/actions?query=workflow%3ACI+branch%3Amaster) 4 | [![Docker Hub](https://img.shields.io/docker/build/janitortechnology/janitor.svg)](https://hub.docker.com/r/janitortechnology/janitor/) 5 | [![Greenkeeper](https://img.shields.io/badge/greenkeeper-enabled-brightgreen.svg)](https://greenkeeper.io/) 6 | [![NPM version](https://img.shields.io/npm/v/janitor.technology.svg)](https://www.npmjs.com/package/janitor.technology) 7 | [![NPM dependencies](https://img.shields.io/david/JanitorTechnology/janitor.svg)](https://david-dm.org/JanitorTechnology/janitor) 8 | [![IRC channel](https://img.shields.io/badge/%23janitor-on%20freenode-brightgreen.svg)](https://kiwiirc.com/client/irc.freenode.net/?#janitor "irc.freenode.net#janitor") 9 | 10 | *Fix bugs, faster* 11 | 12 | [![Janitor video](https://j.gifs.com/m89qbk.gif)](http://www.youtube.com/watch?v=5sNDMIh-iVw "Coding Firefox directly in the Web (using Cloud9 and Janitor)") 13 | 14 | ## Try it live 15 | 16 | Sign in to [janitor.technology](https://janitor.technology). 17 | 18 | ## Try it at home 19 | 20 | Install [Node.js](https://nodejs.org) (version 8 minimum) (and optionally [Docker](https://www.docker.com)). 21 | 22 | Clone this repository: 23 | 24 | git clone https://github.com/janitortechnology/janitor 25 | cd janitor/ 26 | 27 | Install dependencies: 28 | 29 | npm install 30 | 31 | Configure `./db.json` for a local use or simply download the following [configuration](https://raw.githubusercontent.com/JanitorTechnology/dockerfiles/master/janitor/db.json). 32 | 33 | Start the server: 34 | 35 | node app 36 | 37 | Then hit [https://localhost:1443](https://localhost:1443/)! 38 | 39 | ## Hack it 40 | 41 | You can hack Janitor directly [on Janitor](https://janitor.technology/projects/)! 42 | 43 | Check your code: 44 | 45 | npm run lint 46 | 47 | Auto-fix your code: 48 | 49 | npm run lint-fix 50 | 51 | Test your code: 52 | 53 | npm test 54 | 55 | Auto-restart the server when its files are modified: 56 | 57 | npm run watch 58 | 59 | Run the server in the background (use `tail -f janitor.log` to check on it): 60 | 61 | npm run app 62 | 63 | ## Help wanted! 64 | 65 | - If you find bugs, please open [issues](https://github.com/janitortechnology/janitor/issues). 66 | - To suggest changes, please open [pull requests](https://help.github.com/articles/using-pull-requests/). 67 | - For general questions, please ask on [Discourse](https://discourse.janitor.technology/) or [IRC](https://kiwiirc.com/client/irc.freenode.net/?#janitor "irc.freenode.net#janitor"). 68 | 69 | ## Thanks 70 | 71 | - [IRILL](http://www.irill.org/) and [Mozilla](https://www.mozilla.org/) for hosting this project. 72 | - [Datadog](https://www.datadoghq.com/) for monitoring the health and performance of our servers. 73 | - [Cloud9](https://c9.io/) for sponsoring alpha accounts. 74 | -------------------------------------------------------------------------------- /api/admin-api.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const jsonpatch = require('fast-json-patch'); 5 | const selfapi = require('selfapi'); 6 | 7 | const azure = require('../lib/azure'); 8 | const db = require('../lib/db'); 9 | const events = require('../lib/events'); 10 | const log = require('../lib/log'); 11 | const users = require('../lib/users'); 12 | 13 | // API resource to manage the Janitor instance itself. 14 | const adminAPI = module.exports = selfapi({ 15 | title: 'Admin' 16 | }); 17 | 18 | // API sub-resource to manage Azure hosting. 19 | const azureAPI = adminAPI.api('/azure'); 20 | 21 | azureAPI.patch('/credentials', { 22 | title: 'Update Azure credentials', 23 | description: 'Update Azure Active Directory application credentials (with JSON Patch).', 24 | 25 | handler: (request, response) => { 26 | const { user } = request; 27 | if (!user || !users.isAdmin(user)) { 28 | response.statusCode = 403; // Forbidden 29 | response.json({ error: 'Unauthorized' }, null, 2); 30 | return; 31 | } 32 | 33 | const { credentials } = db.get('azure'); 34 | 35 | const chunks = []; 36 | request.on('data', chunk => chunks.push(chunk)); 37 | request.on('end', () => { 38 | try { 39 | const json = Buffer.concat(chunks).toString(); 40 | const operations = JSON.parse(json); 41 | jsonpatch.applyPatch(credentials, operations, true); 42 | } catch (error) { 43 | log('[fail] patching azure credentials', error); 44 | response.statusCode = 400; // Bad Request 45 | response.json({ error: 'Invalid JSON Patch' }, null, 2); 46 | return; 47 | } 48 | 49 | db.save(); 50 | response.json({ message: 'JSON Patch applied' }, null, 2); 51 | }); 52 | }, 53 | 54 | examples: [{ 55 | request: { 56 | body: JSON.stringify([ 57 | { op: 'replace', path: '/tenantId', value: '1234-5678' } 58 | ], null, 2) 59 | }, 60 | response: { 61 | body: JSON.stringify({ message: 'JSON Patch applied' }, null, 2) 62 | } 63 | }], 64 | }); 65 | 66 | azureAPI.get('/virtualmachines', { 67 | title: 'List all virtual machines', 68 | description: 'List all virtual machines in Azure.', 69 | 70 | handler: async (request, response) => { 71 | const { user } = request; 72 | if (!user || !users.isAdmin(user)) { 73 | response.statusCode = 403; // Forbidden 74 | response.json({ error: 'Unauthorized' }, null, 2); 75 | return; 76 | } 77 | 78 | try { 79 | const virtualMachines = await azure.getAllVirtualMachines(); 80 | response.json(virtualMachines, null, 2); 81 | } catch (error) { 82 | log('[fail] fetching azure virtual machines', error); 83 | response.statusCode = 500; // Internal Server Error 84 | response.json({ error: 'Could not fetch virtual machines' }, null, 2); 85 | } 86 | }, 87 | 88 | examples: [], 89 | }); 90 | 91 | // API sub-resource to manage scheduled events. 92 | const eventsAPI = adminAPI.api('/events'); 93 | 94 | eventsAPI.get({ 95 | title: 'List past system events', 96 | 97 | handler: (request, response) => { 98 | const { user } = request; 99 | if (!user || !users.isAdmin(user)) { 100 | response.statusCode = 403; // Forbidden 101 | response.json({ error: 'Unauthorized' }, null, 2); 102 | return; 103 | } 104 | 105 | response.json(events.get(), null, 2); 106 | }, 107 | 108 | examples: [{ 109 | response: { 110 | body: json => { 111 | try { return Array.isArray(JSON.parse(json)); } catch (error) { return false; } 112 | } 113 | } 114 | }] 115 | }); 116 | 117 | eventsAPI.get('/queue', { 118 | title: 'List upcoming system events', 119 | 120 | handler: (request, response) => { 121 | const { user } = request; 122 | if (!user || !users.isAdmin(user)) { 123 | response.statusCode = 403; // Forbidden 124 | response.json({ error: 'Unauthorized' }, null, 2); 125 | return; 126 | } 127 | 128 | response.json(events.getQueue(), null, 2); 129 | }, 130 | 131 | examples: [{ 132 | response: { 133 | body: json => { 134 | try { return Array.isArray(JSON.parse(json)); } catch (error) { return false; } 135 | } 136 | } 137 | }] 138 | }); 139 | 140 | // API sub-resource to manage OAuth2 providers. 141 | const oauth2providersAPI = adminAPI.api('/oauth2providers', { 142 | beforeEachTest: next => { 143 | const providers = db.get('oauth2providers'); 144 | providers.github.id = '1234'; 145 | providers.github.secret = '123456'; 146 | next(); 147 | } 148 | }); 149 | 150 | oauth2providersAPI.get({ 151 | title: 'List OAuth2 providers', 152 | 153 | handler: (request, response) => { 154 | const { user } = request; 155 | if (!user || !users.isAdmin(user)) { 156 | response.statusCode = 403; // Forbidden 157 | response.json({ error: 'Unauthorized' }, null, 2); 158 | return; 159 | } 160 | 161 | const providers = db.get('oauth2providers'); 162 | response.json(providers, null, 2); 163 | }, 164 | 165 | examples: [{ 166 | response: { 167 | body: JSON.stringify({ 168 | github: { 169 | id: '1234', 170 | secret: '123456', 171 | hostname: 'github.com', 172 | api: 'api.github.com' 173 | } 174 | }, null, 2) 175 | } 176 | }] 177 | }); 178 | 179 | // API sub-resource to manage a single OAuth2 provider. 180 | const oauth2providerAPI = oauth2providersAPI.api('/:provider', { 181 | beforeEachTest: oauth2providersAPI.beforeEachTest 182 | }); 183 | 184 | oauth2providerAPI.patch({ 185 | title: 'Update an OAuth2 provider', 186 | description: 'Update an OAuth2 provider configuration (with JSON Patch).', 187 | 188 | handler: (request, response) => { 189 | const { user } = request; 190 | if (!user || !users.isAdmin(user)) { 191 | response.statusCode = 403; // Forbidden 192 | response.json({ error: 'Unauthorized' }, null, 2); 193 | return; 194 | } 195 | 196 | const { provider: providerId } = request.query; 197 | const provider = db.get('oauth2providers')[providerId]; 198 | if (!provider) { 199 | response.statusCode = 404; 200 | response.json({ error: 'Provider not found' }, null, 2); 201 | return; 202 | } 203 | 204 | const chunks = []; 205 | request.on('data', chunk => chunks.push(chunk)); 206 | request.on('end', () => { 207 | try { 208 | const json = Buffer.concat(chunks).toString(); 209 | const operations = JSON.parse(json); 210 | jsonpatch.applyPatch(provider, operations, true); 211 | } catch (error) { 212 | log('[fail] patching oauth2 provider', error); 213 | response.statusCode = 400; // Bad Request 214 | response.json({ error: 'Invalid JSON Patch' }, null, 2); 215 | return; 216 | } 217 | 218 | db.save(); 219 | response.json(provider, null, 2); 220 | }); 221 | }, 222 | 223 | examples: [{ 224 | request: { 225 | urlParameters: { provider: 'github' }, 226 | body: JSON.stringify([ 227 | { op: 'add', path: '/secret', value: '654321' }, 228 | ], null, 2), 229 | }, 230 | response: { 231 | body: JSON.stringify({ 232 | id: '1234', 233 | secret: '654321', 234 | hostname: 'github.com', 235 | api: 'api.github.com', 236 | }, null, 2), 237 | } 238 | }] 239 | }); 240 | -------------------------------------------------------------------------------- /api/blog-api.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const selfapi = require('selfapi'); 5 | 6 | const blog = require('../lib/blog'); 7 | const log = require('../lib/log'); 8 | 9 | // API resource to manage Janitor's Discourse-backed news section. 10 | const blogAPI = module.exports = selfapi({ 11 | title: 'Blog' 12 | }); 13 | 14 | blogAPI.post('/synchronize', { 15 | title: 'Synchronize Blog', 16 | description: 'Pull the blog section from Discourse.', 17 | 18 | handler: async (_request, response) => { 19 | try { 20 | const { count } = await blog.synchronize(); 21 | log('synchronized blog', { count }); 22 | response.json({ count }, null, 2); 23 | } catch (error) { 24 | log('[fail] synchronized blog', error); 25 | response.statusCode = 500; // Internal Server Error 26 | response.json({ error: 'Could not synchronize' }, null, 2); 27 | } 28 | }, 29 | 30 | // FIXME: Re-enable this test once syncing with Discourse doesn't cause frequent errors. 31 | // This can be achieved by rate-limiting our own sync requests, and/or by using a Discourse token. 32 | // See https://github.com/JanitorTechnology/janitor/issues/219 33 | examples: [/* { 34 | response: { 35 | body: JSON.stringify({ count: 13 }, null, 2) 36 | } 37 | } */] 38 | }); 39 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const selfapi = require('selfapi'); 5 | 6 | // Janitor API root resource. 7 | const api = module.exports = selfapi({ 8 | title: 'Janitor API', 9 | description: 10 | 'A simple JSON API to interact with Janitor containers, hosts and projects.' 11 | }); 12 | 13 | // Janitor API sub-resources. 14 | api.api('/blog', require('./blog-api')); 15 | api.api('/hosts', require('./hosts-api')); 16 | api.api('/projects', require('./projects-api')); 17 | api.api('/user', require('./user-api')); 18 | api.api('/admin', require('./admin-api')); 19 | -------------------------------------------------------------------------------- /api/projects-api.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const selfapi = require('selfapi'); 5 | 6 | const db = require('../lib/db'); 7 | const log = require('../lib/log'); 8 | const machines = require('../lib/machines'); 9 | const users = require('../lib/users'); 10 | 11 | // API resource to manage Janitor software projects. 12 | const projectsAPI = module.exports = selfapi({ 13 | title: 'Projects' 14 | }); 15 | 16 | projectsAPI.get({ 17 | title: 'List projects', 18 | description: 19 | 'List all the software projects supported by this Janitor instance.', 20 | 21 | handler: (request, response) => { 22 | const projects = db.get('projects'); 23 | response.json(Object.keys(projects), null, 2); 24 | }, 25 | 26 | examples: [{ 27 | response: { 28 | body: JSON.stringify(['test-project'], null, 2) 29 | } 30 | }] 31 | }); 32 | 33 | // API sub-resource to manage a single software project. 34 | const projectAPI = projectsAPI.api('/:project'); 35 | 36 | projectAPI.post('pull', { 37 | title: 'Pull a project', 38 | description: 'Trigger a Docker image pull for a given software project.', 39 | 40 | handler: (request, response) => { 41 | const { user, query } = request; 42 | // FIXME: Make this API handler accessible to Docker Hub web hooks, so that 43 | // it's easy to automatically deploy new project images. 44 | if (!users.isAdmin(user)) { 45 | response.statusCode = 403; // Forbidden 46 | response.json({ error: 'Unauthorized' }, null, 2); 47 | return; 48 | } 49 | 50 | const projectId = query.project; 51 | const projects = db.get('projects'); 52 | if (!projects[projectId]) { 53 | response.statusCode = 404; // Not Found 54 | response.json({ error: 'Project not found' }, null, 2); 55 | return; 56 | } 57 | 58 | machines.pull(projectId, (error, data) => { 59 | if (error) { 60 | log('[fail] pulling project', projectId, error); 61 | response.statusCode = 500; // Internal Server Error 62 | response.json({ error: 'Could not pull project' }, null, 2); 63 | return; 64 | } 65 | 66 | response.json(data, null, 2); 67 | }); 68 | }, 69 | 70 | examples: [{ 71 | request: { 72 | urlParameters: { project: 'test-project' } 73 | }, 74 | response: { 75 | body: JSON.stringify({ 76 | image: 'image:latest', 77 | created: 1500000000000, 78 | }, null, 2) 79 | } 80 | }] 81 | }); 82 | -------------------------------------------------------------------------------- /lib/azure.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const ComputeManagementClient = require('azure-arm-compute'); 5 | const msRestAzure = require('ms-rest-azure'); 6 | 7 | const db = require('./db'); 8 | 9 | load(); 10 | 11 | // Load our Azure Active Directory application configuration. 12 | function load () { 13 | const azure = db.get('azure'); 14 | if (!azure.credentials) { 15 | azure.credentials = {}; 16 | } 17 | 18 | // You can customize these values in `./db.json` or via `/admin/`. 19 | const { credentials } = azure; 20 | 21 | // "Application ID" or "Client ID". 22 | if (!credentials.clientId) { 23 | credentials.clientId = ''; 24 | } 25 | 26 | // "Application Secret" or "Authentication Key". 27 | if (!credentials.clientSecret) { 28 | credentials.clientSecret = ''; 29 | } 30 | 31 | // "Domain" or "Directory ID" or "Tenant ID". 32 | if (!credentials.tenantId) { 33 | credentials.tenantId = ''; 34 | } 35 | 36 | // "Azure Subscription ID". 37 | if (!credentials.subscriptionId) { 38 | credentials.subscriptionId = ''; 39 | } 40 | } 41 | 42 | async function getComputeClient () { 43 | const { clientId, clientSecret, tenantId, subscriptionId } = db.get('azure').credentials; 44 | if (!clientId || !clientSecret || !tenantId || !subscriptionId) { 45 | throw new Error('Azure credentials not set up'); 46 | } 47 | 48 | const { credentials } = await new Promise((resolve, reject) => { 49 | msRestAzure.loginWithServicePrincipalSecret(clientId, clientSecret, tenantId, 50 | (error, credentials, subscriptions) => { 51 | if (error) { 52 | reject(error); 53 | return; 54 | } 55 | 56 | resolve({ credentials, subscriptions }); 57 | } 58 | ); 59 | }); 60 | 61 | return new ComputeManagementClient(credentials, subscriptionId); 62 | } 63 | 64 | // Get all Azure Virtual Machines. 65 | exports.getAllVirtualMachines = async function () { 66 | const client = await getComputeClient(); 67 | 68 | return new Promise((resolve, reject) => { 69 | client.virtualMachines.listAll((error, virtualMachines) => { 70 | if (error) { 71 | reject(error); 72 | return; 73 | } 74 | 75 | resolve(virtualMachines); 76 | }); 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /lib/blog.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const https = require('https'); 5 | 6 | const db = require('./db'); 7 | const metrics = require('./metrics'); 8 | 9 | // Big note for anybody who's trying to understand how the blog works: 10 | // Discourse is made up of "topics" that contain one or more "posts". 11 | // The first post in a published[^1] blog[^2] topic is considered the blog post itself. 12 | // The rest are comments on it. 13 | // 14 | // [^1] "published" is a staff-only-tag. 15 | // [^2] "blog" is a category, and is not staff-only. 16 | 17 | const discourseBlogTopicsUrl = 'https://discourse.janitor.technology/tags/published?category=9&parent_category=8&order=created&ascending=false'; 18 | const discourseBlogPostUrlTemplate = 'https://discourse.janitor.technology/t/{{slug}}/{{id}}/1'; 19 | const discourseBlogCommentsUrlTemplate = 'https://discourse.janitor.technology/t/{{slug}}/{{id}}/2'; 20 | const default_delay = 500; 21 | let delay = default_delay; 22 | 23 | // Perform an asynchronous Discourse API request. 24 | function fetchDiscourseAPI (url) { 25 | return new Promise((resolve, reject) => { 26 | const auth = db.get('discourse-auth-api'); 27 | const auth_param = auth ? ('?api_key=' + auth.key + '&auth_username=' + auth.username) : ''; 28 | const urlj = url + auth_param; 29 | const options = { 30 | headers: { 31 | Accept: 'application/json' 32 | } 33 | }; 34 | https.get(urlj, options, response => { 35 | const chunks = []; 36 | response.on('data', chunk => chunks.push(chunk)); 37 | response.on('error', reject); 38 | response.on('end', () => { 39 | const json = Buffer.concat(chunks).toString(); 40 | const { statusCode } = response; 41 | if (statusCode === 429) { 42 | setTimeout(() => { 43 | fetchDiscourseAPI(url).then(resolve, reject); 44 | delay = delay * 2; 45 | console.log('Blog sync API delay: ', delay); 46 | }, delay); 47 | return; 48 | } else if (statusCode < 200 || statusCode >= 300) { 49 | reject(new Error('Unexpected Discourse API response: ' + 50 | statusCode + '\n' + json + urlj)); 51 | return; 52 | } 53 | try { 54 | resolve(JSON.parse(json)); 55 | } catch (error) { 56 | reject(error); 57 | } 58 | delay = default_delay; 59 | }); 60 | }); 61 | }); 62 | } 63 | 64 | // Get the cached Discourse-backed blog. 65 | exports.getDb = function () { 66 | return db.get('blog', { 67 | topics: [], 68 | }); 69 | }; 70 | 71 | // Given a post from the database, get the title URL. 72 | exports.getPostUrl = function (topic) { 73 | return discourseBlogPostUrlTemplate 74 | .replace('{{slug}}', topic.slug) 75 | .replace('{{id}}', topic.id); 76 | }; 77 | 78 | // Given a post from the database, get the comment URL. 79 | exports.getCommentsUrl = function (topic) { 80 | return discourseBlogCommentsUrlTemplate 81 | .replace('{{slug}}', topic.slug) 82 | .replace('{{id}}', topic.id); 83 | }; 84 | 85 | // Synchronize the Discourse-backed blog into the database. 86 | exports.synchronize = async function () { 87 | const blog = module.exports.getDb(); 88 | const time = Date.now(); 89 | const publishedTag = await fetchDiscourseAPI(discourseBlogTopicsUrl); 90 | const { topics } = publishedTag.topic_list; 91 | // Order topics 92 | topics.sort((a, b) => { 93 | if (a.created_at > b.created_at) { 94 | return -1; 95 | } 96 | if (a.created_at === b.created_at) { 97 | return 0; 98 | } 99 | return 1; 100 | }); 101 | const topicsPromises = topics.map(async topic => { 102 | const bodyUrl = module.exports.getPostUrl(topic); 103 | const { post_stream } = await fetchDiscourseAPI(bodyUrl); 104 | topic.post_body_html = post_stream.posts[0].cooked; 105 | return topic; 106 | }); 107 | blog.topics = await Promise.all(topicsPromises); 108 | const now = Date.now(); 109 | metrics.set(blog, 'updated', now); 110 | metrics.push(blog, 'pull-time', [now, now - time]); 111 | db.save(); 112 | return { count: topics.length }; 113 | }; 114 | -------------------------------------------------------------------------------- /lib/configurations.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | 'use strict'; 5 | 6 | const fleau = require('fleau'); 7 | const fs = require('fs'); 8 | const nodePath = require('path'); 9 | 10 | const db = require('./db'); 11 | const log = require('./log'); 12 | 13 | const templatesDirectory = './templates/configurations'; 14 | 15 | exports.allowed = [ 16 | '.config/hub', 17 | '.ssh/authorized_keys', 18 | '.arcrc', 19 | '.emacs', 20 | '.eslintrc', 21 | '.gdbinit', 22 | '.gitconfig', 23 | '.gitignore', 24 | '.hgrc', 25 | '.nanorc', 26 | '.netrc', 27 | '.vimrc', 28 | ]; 29 | exports.defaults = {}; 30 | load(); 31 | 32 | // Read and pre-compile all default configuration templates. 33 | function load (subDirectory = '') { 34 | const directory = nodePath.join(templatesDirectory, subDirectory); 35 | // List all template files and sub-directories in this directory. 36 | fs.readdir(directory, (error, fileNames) => { 37 | if (error) { 38 | log('[fail] could not read directory:', directory, error); 39 | return; 40 | } 41 | 42 | fileNames.forEach(fileName => { 43 | const file = nodePath.join(subDirectory, fileName); 44 | const path = nodePath.join(templatesDirectory, file); 45 | fs.readFile(path, 'utf8', (error, content) => { 46 | if (error) { 47 | if (error.code === 'EISDIR') { 48 | // This file is a sub-directory, load its own files recursively. 49 | load(file); 50 | return; 51 | } 52 | 53 | log('[fail] could not read configuration template:', file, error); 54 | return; 55 | } 56 | 57 | try { 58 | exports.defaults[file] = fleau.create(content); 59 | } catch (error) { 60 | log('[fail] could not create fleau template for:', file, error); 61 | } 62 | }); 63 | }); 64 | }); 65 | } 66 | 67 | // Reset a user configuration to its default template value. 68 | exports.resetToDefault = function (user, file, callback) { 69 | const template = exports.defaults[file]; 70 | if (!template) { 71 | callback(new Error('No default configuration for: ' + file)); 72 | return; 73 | } 74 | 75 | let stream = null; 76 | try { 77 | stream = template({ user }); 78 | } catch (error) { 79 | log('[fail] fleau templating error with:', file, error); 80 | callback(new Error('Could not run fleau template for: ' + file)); 81 | return; 82 | } 83 | 84 | let content = ''; 85 | stream.on('data', chunk => { content += String(chunk); }); 86 | stream.on('end', () => { 87 | user.configurations[file] = content; 88 | db.save(); 89 | callback(); 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const fs = require('fs'); 5 | 6 | // The datastore. 7 | const file = './db.json'; 8 | let store = {}; 9 | load(); 10 | 11 | // Load the datastore from disk synchronously. 12 | 13 | function load () { 14 | let json = '{}'; 15 | 16 | try { 17 | json = fs.readFileSync(file, 'utf8'); 18 | } catch (error) { 19 | if (error.code === 'ENOENT') { 20 | // DB file doesn't exist yet, but will be created by `save()` eventually. 21 | } else { 22 | throw error; // Can't open DB file! 23 | } 24 | } 25 | 26 | store = JSON.parse(json); 27 | } 28 | 29 | // Save the datastore to disk asynchronously. TODO gzip? 30 | 31 | exports.save = function () { 32 | const json = JSON.stringify(store, null, 2); 33 | 34 | fs.writeFile(file, json + '\n', function (error) { 35 | if (error) { 36 | console.error('Can\'t write DB file!', error.stack); 37 | } 38 | fs.chmod(file, 0o600 /* read + write by owner */, function (error) { 39 | if (error) { 40 | console.error('Can\'t protect DB file!', error.stack); 41 | } 42 | }); 43 | }); 44 | }; 45 | 46 | // Get or create an entry in the datastore. 47 | 48 | exports.get = function (key, defaultValue) { 49 | if (!store[key]) { 50 | store[key] = defaultValue || {}; 51 | } 52 | 53 | return store[key]; 54 | }; 55 | -------------------------------------------------------------------------------- /lib/events.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const EventEmitter = require('events'); 5 | 6 | // Note: If you miss an important scheduling feature here, maybe import a "real" 7 | // cron-based scheduler module instead of reimplementing one. 8 | // For example: https://github.com/IMA-WorldHealth/TaskList 9 | 10 | const db = require('./db'); 11 | const log = require('./log'); 12 | 13 | const schedulingInterval = 1000 * 60 * 60 * 10; // Every 10 hours. 14 | 15 | const emitter = new EventEmitter(); 16 | const events = db.get('events'); 17 | load(); 18 | 19 | // Load past and upcoming events from the database. 20 | function load () { 21 | emitter.on('error', error => { 22 | log('[fail] event emitter error', error); 23 | }); 24 | 25 | if (!events.history) { 26 | events.history = []; 27 | } 28 | 29 | if (!events.queue) { 30 | events.queue = []; 31 | } 32 | 33 | // Events should flow through the following states: 34 | // 1. Queued: `event` is added to `events.queue` 35 | // 2. Scheduled: `event.scheduledTime` is set 36 | // 3. Emitted: `event.emittedTime` is set, and `event` is moved from 37 | // `events.queue` to `events.history` 38 | 39 | // Remove any empty event slots (already emitted). 40 | events.queue = events.queue.filter(event => !!event); 41 | 42 | // Re-schedule any un-emitted events. 43 | events.queue.forEach(event => { event.scheduledTime = null; }); 44 | } 45 | 46 | // Start regularly scheduling events. 47 | exports.startScheduling = function () { 48 | setTimeout(processQueue, 0); 49 | }; 50 | 51 | // Get all previously emitted events. 52 | exports.get = function () { 53 | return events.history; 54 | }; 55 | 56 | // Get the queue of upcoming events. 57 | exports.getQueue = function () { 58 | return events.queue 59 | .filter(event => !!event) // Remove any empty event slots (already emitted). 60 | .sort((a, b) => b.dueTime - a.dueTime); // Sort the queue by due date. 61 | }; 62 | 63 | // Register a new event listener. 64 | exports.on = function (eventType, listener) { 65 | emitter.on(eventType, listener); 66 | }; 67 | 68 | // Register a single-use event listener. 69 | exports.once = function (eventType, listener) { 70 | emitter.once(eventType, listener); 71 | }; 72 | 73 | // Emit an event now. 74 | exports.emit = function (eventType, payload = null) { 75 | exports.emitAtTime(eventType, Date.now(), payload); 76 | }; 77 | 78 | // Emit an event at a certain due date (a timestamp). 79 | exports.emitAtTime = function (eventType, dueTime, payload = null) { 80 | const event = createEvent(eventType, dueTime, payload); 81 | events.queue.push(event); 82 | maybeSchedule(event); 83 | }; 84 | 85 | // Process all upcoming events, and schedule any events that are due soon. 86 | // Note: We schedule events at regular intervals instead of just using 87 | // `setTimeout` directly, because `setTimeout` has a maximum range of about 88 | // 24 days, which is not enough for our needs. 89 | // See: https://nodejs.org/api/timers.html#timers_settimeout_callback_delay_args 90 | function processQueue () { 91 | log('processing event queue'); 92 | events.queue.forEach(maybeSchedule); 93 | setTimeout(processQueue, schedulingInterval); 94 | } 95 | 96 | // Emit or schedule an event if it's due soon. 97 | function maybeSchedule (event) { 98 | if (!event || event.scheduledTime) { 99 | // Already emitted or scheduled, nothing to do. 100 | return; 101 | } 102 | 103 | const now = Date.now(); 104 | if (event.dueTime - now >= schedulingInterval) { 105 | // The event is not due yet, schedule it later. 106 | return; 107 | } 108 | 109 | // The event is due soon, schedule it now. 110 | event.scheduledTime = now; 111 | if (event.dueTime <= now) { 112 | // It's due or past due, emit it now. 113 | setTimeout(() => { emitEvent(event); }, 0); 114 | } else { 115 | // It's due soon, emit it before the next queue processing takes place. 116 | setTimeout(() => { emitEvent(event); }, event.dueTime - now); 117 | } 118 | } 119 | 120 | // Emit an event now. 121 | function emitEvent (event) { 122 | event.consumed = emitter.emit(event.type, event.payload); 123 | event.emittedTime = Date.now(); 124 | if (!event.consumed) { 125 | const error = new Error('No listeners for event type: ' + event.type); 126 | log('[fail] lost event', event.type, event.payload, error); 127 | } 128 | 129 | // Remove the emitted event from the queue. 130 | const index = events.queue.indexOf(event); 131 | if (index > -1) { 132 | // Note: We don't use `.splice()`, to keep indexes stable during iteration. 133 | events.queue[index] = null; 134 | } 135 | 136 | events.history.push(event); 137 | db.save(); 138 | } 139 | 140 | // Create a new event to be emitted at a given timestamp. 141 | function createEvent (type, dueTime, payload = null) { 142 | return { 143 | type, 144 | payload, 145 | dueTime, 146 | scheduledTime: null, 147 | emittedTime: null, 148 | consumed: false 149 | }; 150 | } 151 | -------------------------------------------------------------------------------- /lib/github.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const db = require('./db'); 5 | const oauth2 = require('./oauth2'); 6 | 7 | load(); 8 | 9 | // Load our GitHub OAuth2 credentials. 10 | function load () { 11 | const oauth2providers = db.get('oauth2providers'); 12 | 13 | // Add a non-functional, empty GitHub OAuth2 provider by default. 14 | if (!oauth2providers.github) { 15 | oauth2providers.github = { 16 | id: '', 17 | secret: '', 18 | hostname: 'github.com', 19 | api: 'api.github.com', 20 | }; 21 | } 22 | } 23 | 24 | // Associate non-persistent OAuth2 states to sessions. 25 | const oauth2States = {}; 26 | 27 | // Generate a URL allowing users to authorize us as a GitHub OAuth2 application. 28 | exports.getAuthorizationUrl = async function (request) { 29 | const { session } = request; 30 | if (!session || !session.id) { 31 | throw new Error('Request has no associated session'); 32 | } 33 | 34 | // Generate a new OAuth2 state parameter for this authentication link. 35 | const state = await oauth2.generateStateParameter(); 36 | oauth2States[session.id] = state; 37 | 38 | const parameters = { 39 | provider: 'github', 40 | options: { 41 | scope: ['public_repo', 'user:email'], 42 | state, 43 | } 44 | }; 45 | 46 | return oauth2.getAuthorizationUrl(parameters); 47 | }; 48 | 49 | // Exchange a GitHub OAuth2 authorization code against an access token. 50 | exports.authenticate = async function (request) { 51 | const { session } = request; 52 | if (!session || !session.id) { 53 | throw new Error('Request has no associated session'); 54 | } 55 | 56 | const { state } = request.query; 57 | const expectedState = oauth2States[session.id]; 58 | if (!state || String(state) !== String(expectedState)) { 59 | throw new Error('Bad state: Got ' + state + ' but expected ' + 60 | expectedState); 61 | } 62 | 63 | const { code } = request.query; 64 | const parameters = { 65 | provider: 'github', 66 | code, 67 | options: { state }, 68 | }; 69 | 70 | const { accessToken, refreshToken } = await oauth2.getAccessToken(parameters); 71 | delete oauth2States[session.id]; 72 | 73 | return { accessToken, refreshToken }; 74 | }; 75 | 76 | // Perform an authenticated GitHub API request. 77 | async function fetchGitHubAPI (method, path, data, accessToken) { 78 | const parameters = { 79 | provider: 'github', 80 | accessToken, 81 | method, 82 | path, 83 | data, 84 | headers: { 85 | Accept: 'application/vnd.github.v3+json' 86 | } 87 | }; 88 | 89 | const { body, response } = await oauth2.request(parameters); 90 | 91 | const responseStatus = response.statusCode; 92 | if (responseStatus < 200 || responseStatus >= 300) { 93 | throw new Error('GitHub API response status: ' + responseStatus + '\n' + 94 | body); 95 | } 96 | 97 | try { 98 | const data = JSON.parse(body); 99 | return data; 100 | } catch (error) { 101 | throw new Error('Could not parse GitHub API response:\n' + body); 102 | } 103 | } 104 | 105 | // Get the user's public profile information. 106 | // A profile object may contain information like: 107 | // { 108 | // "login": "octocat", 109 | // "name": "monalisa octocat", 110 | // "blog": "https://github.com/blog", 111 | // "location": "San Francisco", 112 | // "bio": "There once was...", 113 | // } 114 | // See: https://developer.github.com/v3/users/#get-the-authenticated-user 115 | exports.getUserProfile = function (accessToken) { 116 | return fetchGitHubAPI('GET', '/user', null, accessToken); 117 | }; 118 | 119 | // Get the user's verified email addresses. 120 | exports.getVerifiedEmails = async function (accessToken) { 121 | const emails = await fetchGitHubAPI('GET', '/user/emails', null, accessToken); 122 | 123 | // Don't trust un-verified email addresses. 124 | const verifiedEmails = emails 125 | .filter(email => email.verified) 126 | .map(email => email.email); 127 | return verifiedEmails; 128 | }; 129 | 130 | // Get the user's authorized SSH public keys (doesn't require an access token). 131 | exports.getSSHPublicKeys = function (username) { 132 | return fetchGitHubAPI('GET', `/users/${username}/keys`, null, null); 133 | }; 134 | -------------------------------------------------------------------------------- /lib/hosts.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const oauth2provider = require('oauth2provider'); 5 | 6 | const db = require('./db'); 7 | const log = require('./log'); 8 | 9 | // Get an existing Docker host configuration. 10 | exports.get = function (hostname) { 11 | const hosts = db.get('hosts'); 12 | return hosts[hostname] || null; 13 | }; 14 | 15 | // Find a Docker hostname that fully matches the request's OAuth2 credentials. 16 | exports.authenticate = function (request) { 17 | const { client_id = null, client_secret = null } = request.query; 18 | if (!client_id || !client_secret) { 19 | return null; 20 | } 21 | 22 | const hosts = db.get('hosts'); 23 | for (const hostname in hosts) { 24 | const host = hosts[hostname]; 25 | if (!host || !host.oauth2client || !host.oauth2client.id) { 26 | continue; 27 | } 28 | const { id, secret } = host.oauth2client; 29 | if (String(client_id) === id && String(client_secret) === secret) { 30 | return hostname; 31 | } 32 | } 33 | 34 | return null; 35 | }; 36 | 37 | // Find a Docker hostname that matches just the given OAuth2 client ID. 38 | exports.identify = function (clientId) { 39 | const hosts = db.get('hosts'); 40 | for (const hostname in hosts) { 41 | const host = hosts[hostname]; 42 | if (!host || !host.oauth2client || !host.oauth2client.id) { 43 | continue; 44 | } 45 | if (String(clientId) === host.oauth2client.id) { 46 | return hostname; 47 | } 48 | } 49 | 50 | return null; 51 | }; 52 | 53 | // Create a new Docker host configuration. 54 | exports.create = function (hostname, properties, callback) { 55 | const hosts = db.get('hosts'); 56 | let host = hosts[hostname]; 57 | if (host) { 58 | callback(new Error('Host already exists'), host); 59 | return; 60 | } 61 | 62 | host = { 63 | properties: { 64 | port: properties.port || '2376', 65 | ca: properties.ca || '', 66 | crt: properties.crt || '', 67 | key: properties.key || '' 68 | }, 69 | oauth2client: { 70 | id: '', 71 | secret: '' 72 | } 73 | }; 74 | 75 | exports.resetOAuth2ClientSecret(host, (error) => { 76 | callback(error, host); 77 | }); 78 | 79 | hosts[hostname] = host; 80 | db.save(); 81 | }; 82 | 83 | // Update an existing Docker host configuration. 84 | exports.update = function (hostname, properties, callback) { 85 | const hosts = db.get('hosts'); 86 | const host = hosts[hostname]; 87 | if (!host) { 88 | callback(new Error('No such host: ' + hostname)); 89 | return; 90 | } 91 | 92 | host.properties = { 93 | port: properties.port || '2376', 94 | ca: properties.ca || '', 95 | crt: properties.crt || '', 96 | key: properties.key || '' 97 | }; 98 | db.save(); 99 | 100 | callback(null, host); 101 | }; 102 | 103 | // Delete an existing Docker host configuration. 104 | exports.destroy = function (hostname, callback) { 105 | const hosts = db.get('hosts'); 106 | if (!hosts[hostname]) { 107 | callback(new Error('No such host: ' + hostname)); 108 | return; 109 | } 110 | 111 | delete hosts[hostname]; 112 | db.save(); 113 | 114 | callback(); 115 | }; 116 | 117 | // Reset a host's OAuth2 client credentials. 118 | exports.resetOAuth2ClientSecret = function (host, callback) { 119 | oauth2provider.generateClientCredentials((error, { id, secret }) => { 120 | if (error) { 121 | log('[fail] oauth2provider', error); 122 | callback(error); 123 | return; 124 | } 125 | 126 | if (!host.oauth2client) { 127 | host.oauth2client = {}; 128 | } 129 | if (!host.oauth2client.id) { 130 | host.oauth2client.id = id; 131 | } 132 | host.oauth2client.secret = secret; 133 | db.save(); 134 | 135 | callback(); 136 | }); 137 | }; 138 | 139 | // Pre-authorize a host to make OAuth2 requests on behalf of a user. 140 | exports.issueOAuth2AuthorizationCode = async function (request) { 141 | const { client_id, scope, state } = request.query; 142 | const hostname = exports.identify(client_id); 143 | if (!hostname) { 144 | throw new Error('No such OAuth2 client ID: ' + client_id); 145 | } 146 | 147 | let { redirect_url = null } = request.query; 148 | if (!redirect_url) { 149 | redirect_url = 'https://' + hostname + '/'; 150 | } else if (!redirect_url.startsWith('https://' + hostname + '/')) { 151 | throw new Error('Invalid OAuth2 redirect URL: ' + redirect_url); 152 | } 153 | 154 | const { user } = request; 155 | if (!user) { 156 | throw new Error('No user to authorize OAuth2 host: ' + hostname); 157 | } 158 | 159 | const scopes = parseScopes(scope); 160 | if (!scopes) { 161 | throw new Error('Invalid OAuth2 scope: ' + scope); 162 | } 163 | 164 | const grant = { email: user._primaryEmail, scopes }; 165 | const data = await new Promise((resolve, reject) => { 166 | const onCode = (error, data) => { 167 | if (error) { 168 | reject(error); 169 | return; 170 | } 171 | resolve(data); 172 | }; 173 | 174 | oauth2provider.generateAuthorizationCode(client_id, grant, state, onCode); 175 | }); 176 | 177 | const { code } = data; 178 | redirect_url += (redirect_url.includes('?') ? '&' : '?') + 179 | 'code=' + encodeURIComponent(code) + 180 | '&state=' + encodeURIComponent(state); 181 | 182 | return { code, redirect_url }; 183 | }; 184 | 185 | // Effectively allow a host to make OAuth2 requests on behalf of a user. 186 | exports.issueOAuth2AccessToken = async function (request) { 187 | const { client_id, code, state } = request.query; 188 | const hostname = exports.identify(client_id); 189 | if (!hostname) { 190 | throw new Error('No such OAuth2 client ID: ' + client_id); 191 | } 192 | 193 | const data = await new Promise((resolve, reject) => { 194 | const onToken = (error, data) => { 195 | if (error) { 196 | reject(error); 197 | return; 198 | } 199 | resolve(data); 200 | }; 201 | 202 | // Attempt to generate an access token with hash. 203 | oauth2provider.generateAccessToken(client_id, code, state, onToken); 204 | }); 205 | 206 | const { scope: grant, token, tokenHash } = data; 207 | const scope = stringifyScopes(grant.scopes); 208 | 209 | const authorization = { 210 | client: client_id, 211 | date: Date.now(), 212 | email: grant.email, 213 | scope, 214 | }; 215 | 216 | const oauth2tokens = db.get('oauth2tokens'); 217 | if (oauth2tokens[tokenHash]) { 218 | throw new Error('OAuth2 token hash already exists: ' + tokenHash); 219 | } 220 | 221 | oauth2tokens[tokenHash] = authorization; 222 | db.save(); 223 | 224 | log(hostname, 'was granted access to', scope, 'by', authorization.email); 225 | return { access_token: token, scope }; 226 | }; 227 | 228 | // Find the OAuth2 access scope authorized for a request's access token. 229 | exports.getOAuth2Scope = function (request) { 230 | // Support query parameters like '?access_token='. 231 | let token = request.query.access_token || null; 232 | // Support HTTP headers like 'Authorization: Bearer '. 233 | if (!token && ('authorization' in request.headers)) { 234 | token = request.headers.authorization.split(/\s+/)[1]; 235 | } 236 | 237 | if (!token) { 238 | return null; 239 | } 240 | 241 | // Verify what the provided token is authorized for. 242 | const tokenHash = oauth2provider.hash(token); 243 | const oauth2tokens = db.get('oauth2tokens'); 244 | const authorization = oauth2tokens[tokenHash]; 245 | if (!authorization) { 246 | return null; 247 | } 248 | 249 | const { client, email, scope } = authorization; 250 | const hostname = exports.identify(client); 251 | if (!hostname) { 252 | // The authorized OAuth2 client doesn't exist anymore. 253 | log('[fail] invalid oauth2 client, deleting token:', authorization); 254 | delete oauth2tokens[tokenHash]; 255 | db.save(); 256 | return null; 257 | } 258 | 259 | const scopes = parseScopes(scope); 260 | if (!scopes) { 261 | // The authorized scope is invalid. 262 | log('[fail] invalid oauth2 scope, deleting token:', authorization); 263 | delete oauth2tokens[tokenHash]; 264 | db.save(); 265 | return null; 266 | } 267 | 268 | return { email, hostname, scopes }; 269 | }; 270 | 271 | // Parse a comma-separated String of OAuth2 scopes into a Set object. 272 | function parseScopes (scope) { 273 | if (!scope) { 274 | return null; 275 | } 276 | 277 | const array = String(scope).split(',').map(item => item.trim()); 278 | const scopes = new Set(array); 279 | return scopes; 280 | } 281 | 282 | // Convert a Set of OAuth2 scopes back into a comma-separated String. 283 | function stringifyScopes (scopes) { 284 | return Array.from(scopes).join(','); 285 | } 286 | -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2015 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | // Log messages to the console with a timestamp. 5 | 6 | function log () { 7 | const args = [].slice.call(arguments); 8 | args.unshift('[' + new Date().toISOString() + ']'); 9 | 10 | console.log.apply(console, args); 11 | } 12 | 13 | module.exports = log; 14 | -------------------------------------------------------------------------------- /lib/metrics.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const db = require('./db'); 5 | const log = require('./log'); 6 | 7 | // Set a value for a metric. 8 | 9 | exports.set = function (object, metric, value) { 10 | let data = object.data; 11 | 12 | if (!data) { 13 | data = object.data = {}; 14 | } 15 | 16 | data[metric] = value; 17 | db.save(); 18 | }; 19 | 20 | // Push a value into a metric array. 21 | 22 | exports.push = function (object, metric, value) { 23 | let data = object.data; 24 | 25 | if (!data || !data[metric]) { 26 | exports.set(object, metric, []); 27 | data = object.data; 28 | } 29 | 30 | data[metric].push(value); 31 | db.save(); 32 | }; 33 | 34 | // Get all available metrics. 35 | 36 | exports.get = function (callback) { 37 | const time = Date.now(); 38 | const data = { 39 | users: exports.getUserData(), 40 | projects: exports.getProjectData(), 41 | contributions: exports.getContributionData(), 42 | hosts: exports.getHostData() 43 | }; 44 | 45 | callback(data); 46 | log('data collection took', Date.now() - time, 'ms.'); 47 | }; 48 | 49 | // Get metrics about all users. 50 | 51 | exports.getUserData = function () { 52 | const data = { 53 | users: [], 54 | waitlist: [] 55 | }; 56 | 57 | const users = db.get('users'); 58 | for (const email in users) { 59 | data.users.push([users[email].data.joined]); 60 | } 61 | data.users.sort(); 62 | 63 | const waitlist = db.get('waitlist'); 64 | for (const email in waitlist) { 65 | data.waitlist.push([waitlist[email]]); 66 | } 67 | data.waitlist.sort(); 68 | 69 | return data; 70 | }; 71 | 72 | // Get metrics about all projects. 73 | 74 | exports.getProjectData = function () { 75 | const data = []; 76 | const projects = db.get('projects'); 77 | 78 | for (const projectId in projects) { 79 | const project = projects[projectId]; 80 | data.push({ 81 | project: projectId, 82 | data: project.data 83 | }); 84 | } 85 | 86 | return data; 87 | }; 88 | 89 | // Get metrics about all contributions. 90 | 91 | exports.getContributionData = function () { 92 | const data = { 93 | new: 0, 94 | 'build-failed': 0, 95 | built: 0, 96 | 'start-failed': 0, 97 | started: 0, 98 | merged: 0 99 | }; 100 | const users = db.get('users'); 101 | 102 | for (const email in users) { 103 | const machines = users[email].machines; 104 | 105 | for (const projectId in machines) { 106 | machines[projectId].forEach(function (machine) { 107 | data[machine.status]++; 108 | }); 109 | } 110 | } 111 | 112 | let total = 0; 113 | for (const status in data) { 114 | total += data[status]; 115 | } 116 | data.total = total; 117 | 118 | return data; 119 | }; 120 | 121 | // Get metrics about all connected Docker hosts. 122 | 123 | exports.getHostData = function () { 124 | const data = { 125 | docker: [] 126 | }; 127 | const hosts = db.get('hosts'); 128 | 129 | // eslint-disable-next-line no-unused-vars 130 | for (const hostname in hosts) { 131 | data.docker.push({}); 132 | } 133 | 134 | return data; 135 | }; 136 | -------------------------------------------------------------------------------- /lib/oauth2.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const crypto = require('crypto'); 5 | const oauth = require('oauth'); 6 | 7 | const db = require('./db'); 8 | 9 | // Get client access to a given OAuth2 provider. 10 | function getClient (providerId) { 11 | const providers = db.get('oauth2providers'); 12 | const provider = providers[providerId]; 13 | if (!provider || !provider.id || !provider.secret || !provider.hostname) { 14 | throw new Error('OAuth2 provider not set up: ' + providerId); 15 | } 16 | 17 | const client = new oauth.OAuth2( 18 | provider.id, 19 | provider.secret, 20 | 'https://' + provider.hostname + '/', 21 | provider.authorizePath || 'login/oauth/authorize', 22 | provider.accessTokenPath || 'login/oauth/access_token', 23 | provider.customHeaders || {} 24 | ); 25 | 26 | return { client, provider }; 27 | } 28 | 29 | // Create a new OAuth2 state parameter to protect against CSRF attacks. 30 | exports.generateStateParameter = async function () { 31 | return new Promise((resolve, reject) => { 32 | // Generate 20 hex-digits of cryptographically strong pseudo-random data. 33 | crypto.randomBytes(20 / 2, (error, buffer) => { 34 | if (error) { 35 | reject(error); 36 | return; 37 | } 38 | 39 | const state = buffer.toString('hex'); 40 | resolve(state); 41 | }); 42 | }); 43 | }; 44 | 45 | // Get a URL that clients can visit to request an OAuth2 authorization code. 46 | exports.getAuthorizationUrl = async function (parameters) { 47 | const { provider, options = {} } = parameters; 48 | const { client } = getClient(provider); 49 | return client.getAuthorizeUrl(options); 50 | }; 51 | 52 | // Request an OAuth2 access token in exchange of an OAuth2 autorization code. 53 | exports.getAccessToken = async function (parameters) { 54 | const { code, provider, options = {} } = parameters; 55 | const { client } = getClient(provider); 56 | 57 | return new Promise((resolve, reject) => { 58 | const onResults = (error, accessToken, refreshToken, results) => { 59 | if (error) { 60 | reject(error); 61 | return; 62 | } 63 | 64 | if (results.error) { 65 | reject(results.error); 66 | return; 67 | } 68 | 69 | resolve({ accessToken, refreshToken }); 70 | }; 71 | 72 | client.getOAuthAccessToken(code, options, onResults); 73 | }); 74 | }; 75 | 76 | // Request a new OAuth2 access token using an OAuth2 refresh token. 77 | exports.refreshAccessToken = async function (parameters) { 78 | const { provider, refreshToken: code, options = {} } = parameters; 79 | options.grant_type = 'refresh_token'; 80 | return exports.getAccessToken({ provider, code, options }); 81 | }; 82 | 83 | // Perform an authenticated request using OAuth2 credentials. 84 | exports.request = async function (parameters) { 85 | const { 86 | provider: providerId, 87 | accessToken, 88 | path, 89 | data = null, 90 | headers = {}, 91 | method = 'GET', 92 | serviceRequest = false 93 | } = parameters; 94 | 95 | const { client, provider } = getClient(providerId); 96 | const api = provider.api || provider.hostname; 97 | const body = data ? JSON.stringify(data, null, 2) : null; 98 | let url = 'https://' + api + path; 99 | 100 | if (accessToken) { 101 | headers.Authorization = 'token ' + accessToken; 102 | } else if (serviceRequest) { 103 | url += '?client_id=' + provider.id + '&client_secret=' + provider.secret; 104 | } 105 | 106 | if (body) { 107 | headers['Content-Type'] = 'application/json'; 108 | } 109 | 110 | return new Promise((resolve, reject) => { 111 | const onResponse = (error, responseBody, response) => { 112 | if (error) { 113 | reject(error); 114 | return; 115 | } 116 | 117 | resolve({ body: responseBody, response }); 118 | }; 119 | 120 | client._request(method, url, headers, body, null, onResponse); 121 | }); 122 | }; 123 | -------------------------------------------------------------------------------- /lib/proxy-heuristics.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const http = require('http'); 5 | 6 | const log = require('./log'); 7 | const routes = require('./routes'); 8 | 9 | // In this regex, we expect: 10 | // - a 16+ hex-digit container ID. 11 | // - a numeric port. 12 | // - an optional path that starts with a '/'. 13 | // These anonymous patterns will be captured in a `match` array as `match[1]`, 14 | // `match[2]` and maybe `match[3]`. 15 | exports.proxyUrlPrefix = /^\/([0-9a-f]{16,})\/(\d+)(\/.*)?$/; 16 | 17 | // Parse request URLs (or referer URLs) that look like '/:container/:port/*' to 18 | // identify which container ID and port a request should be proxied to. 19 | exports.handleProxyUrls = function (request, response, next) { 20 | let url = null; 21 | let containerId = null; 22 | let port = null; 23 | let path = null; 24 | 25 | // Look for a container ID and port in `request.url`. 26 | let match = exports.proxyUrlPrefix.exec(request.url); 27 | if (match) { 28 | [url, containerId, port, path] = match; 29 | 30 | // We want the proxied `path` to always begin with a '/'. 31 | // However `path` is empty in URLs like '/abc123/8080?p=1', so we redirect 32 | // them to '/abc123/8080/?p=1' (where `path` is '/'). 33 | if (!path) { 34 | // We can only use `http.ServerResponse`s to redirect, 35 | // not raw `net.Socket`s (as in WebSocket connections). 36 | if (!(response instanceof http.ServerResponse)) { 37 | const error = new Error('Unsupported response type (e.g. WebSocket)'); 38 | log('[fail] trailing slash redirect', error); 39 | response.end(); 40 | return; 41 | } 42 | url = url.includes('?') ? url.replace('?', '/?') : url + '/'; 43 | routes.redirect(response, url, true); 44 | return; 45 | } 46 | 47 | // Locally remove the prefix from `request.url`. 48 | request.url = request.url.replace('/' + containerId + '/' + port, ''); 49 | } else if (request.headers.referer) { 50 | // Look for a container ID and port in `request.headers.referer`. 51 | const referer = new URL(request.headers.referer); 52 | match = exports.proxyUrlPrefix.exec(referer.pathname); 53 | if (match) { 54 | [url, containerId, port, path] = match; 55 | } 56 | } 57 | 58 | if (containerId && port) { 59 | // Add the requested container ID and port to `request.query`. 60 | request.query.container = containerId; 61 | request.query.port = port; 62 | } 63 | 64 | next(); 65 | }; 66 | 67 | // FIXME: Remove all these heuristics when containers and ports are explicitly 68 | // specified in every request, e.g. via domains like: 69 | // 'https://8080.abc123./index.html' 70 | 71 | // Some Cloud9 request URLs seem to include a stable ID, like in: 72 | // '/vfs/1/9cfNR5XK83uYCUk1/socket/?access_token=token&transport=websocket' 73 | // We can use this prefix to associate ambiguous requests to their container. 74 | exports.cloud9VfsUrlPrefix = /^\/vfs\/\d+\/[A-Za-z0-9]{8,}\//; 75 | 76 | // These heuristic functions can evaluate if an ambiguous proxy request is 77 | // intended for a specific port or not. 78 | // They can return: 79 | // true: likely for this port 80 | // false: likely NOT for this port 81 | exports.requestLikelyForPort = { 82 | 8088: function ({ url }) { return url === '/websockify'; }, 83 | 8089: function ({ url }) { 84 | if (url.startsWith('/static/') || url === '/_ping') { 85 | return true; 86 | } 87 | if (this._cloud9VfsUrlPrefix) { 88 | return url.startsWith(this._cloud9VfsUrlPrefix); 89 | } 90 | const match = exports.cloud9VfsUrlPrefix.exec(url); 91 | if (match) { 92 | this._cloud9VfsUrlPrefix = match[0]; 93 | return true; 94 | } 95 | return false; 96 | } 97 | }; 98 | 99 | // Associate some non-persistent data to sessions. 100 | const pastFewProxyRequests = {}; 101 | 102 | // Remember explicit proxy requests in this session for later use. 103 | exports.rememberProxyRequest = function (request) { 104 | const { session } = request; 105 | const { container, port } = request.query; 106 | if (!container || !port) { 107 | // This request isn't helpful. Let's not remember it. 108 | log('[fail] will not remember unhelpful proxy request:', 109 | request.url, request.headers); 110 | return; 111 | } 112 | 113 | let pastRequests = pastFewProxyRequests[session.id]; 114 | if (!pastRequests) { 115 | // This is the first request in this session. 116 | pastRequests = pastFewProxyRequests[session.id] = []; 117 | } else { 118 | // Only remember a requested container port once. 119 | for (let i = 0; i < pastRequests.length; i++) { 120 | const pastRequest = pastRequests[i]; 121 | if (pastRequest.container === container && pastRequest.port === port) { 122 | // We already knew this request. Let's forget the old one. 123 | pastRequests.splice(i, 1); // Remove 1 item at position i. 124 | break; 125 | } 126 | } 127 | } 128 | 129 | // If we have a likeliness heuristic function for this port, remember it too. 130 | const likely = exports.requestLikelyForPort[port]; 131 | pastRequests.unshift({ container, port, likely }); 132 | 133 | // Don't remember too many old requests. 134 | if (pastRequests.length > 20) { 135 | pastRequests.pop(); 136 | } 137 | }; 138 | 139 | // Try to guess which container and port this ambiguous request is for. 140 | exports.guessProxyRequest = function (request) { 141 | const pastRequests = pastFewProxyRequests[request.session.id]; 142 | if (!pastRequests || pastRequests.length <= 0) { 143 | // We don't know any past requests for this session. Give up. 144 | return null; 145 | } 146 | 147 | // Compute the likeliness of each previously requested container port for this 148 | // new ambiguous request. 149 | // Scores: 150 | // 1: likely 151 | // 0: neutral 152 | // -1: unlikely 153 | const rankedRequests = pastRequests.map((pastRequest, index) => { 154 | let score = 0; 155 | if (typeof pastRequest.likely === 'function') { 156 | score = pastRequest.likely(request) ? 1 : -1; 157 | } 158 | const { container, port } = pastRequest; 159 | return { index, container, port, score }; 160 | }); 161 | 162 | // Sort solutions according to their likeliness score. 163 | rankedRequests.sort((a, b) => { 164 | if (a.score !== b.score) { 165 | return b.score - a.score; 166 | } 167 | // If the score is the same, keep the original order (for stable sorting). 168 | return a.index - b.index; 169 | }); 170 | 171 | // Phew! We've identified the most likely requested container and port. 172 | const { container, port, score } = rankedRequests[0]; 173 | if (score < 0) { 174 | // Actually, it still looks unlikely. Give up. 175 | return null; 176 | } 177 | 178 | if (score === 0) { 179 | log('[fail] no proxy heuristic for url:', request.url, 180 | 'headers:', request.headers); 181 | } 182 | 183 | return { container, port }; 184 | }; 185 | -------------------------------------------------------------------------------- /lib/sessions.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const EmailLogin = require('email-login'); 5 | 6 | const db = require('./db'); 7 | const log = require('./log'); 8 | 9 | const login = new EmailLogin({ 10 | db: './tokens/', 11 | mailer: db.get('mailer') 12 | }); 13 | const useSecureCookies = !db.get('security').forceInsecure; 14 | 15 | // Get the cookie name saved from a previous session if any. 16 | const cookieNames = db.get('cookieNames'); 17 | if (!cookieNames.token) { 18 | // Generate a unique cookie name so that it doesn't clash when loading Janitor 19 | // inside a Janitor container (see #93). 20 | cookieNames.token = 'token-' + String(Date.now()); 21 | } 22 | 23 | // Create a new session with a unique token. 24 | exports.create = function (callback) { 25 | login.login((error, token, session) => { 26 | if (error) { 27 | callback(error); 28 | return; 29 | } 30 | 31 | callback(null, token, session); 32 | }); 33 | }; 34 | 35 | // Find the session associated to the given request, or associate a new session. 36 | exports.get = function (request, callback) { 37 | // Extract the session token from the cookies, if available. 38 | const cookiePrefix = cookieNames.token + '='; 39 | const cookies = request.headers.cookie || ''; 40 | const cookie = cookies.split('; ').filter(cookie => { 41 | return cookie.startsWith(cookiePrefix); 42 | })[0]; 43 | const token = cookie ? cookie.slice(cookiePrefix.length) : ''; 44 | 45 | login.authenticate(token, (error, success, session) => { 46 | if (success) { 47 | callback(error, session, token); 48 | return; 49 | } 50 | 51 | // No current session, create a new one. 52 | exports.create((error, token, session) => { 53 | if (error) { 54 | callback(error); 55 | return; 56 | } 57 | 58 | if (request.cookies) { 59 | request.cookies.set(cookieNames.token, token, { 60 | expires: new Date('2038-01-19T03:14:07Z'), 61 | secure: useSecureCookies 62 | }); 63 | } 64 | 65 | callback(null, session, token); 66 | }); 67 | }); 68 | }; 69 | 70 | // Destroy the session associated to the given request. 71 | exports.destroy = function (request, callback) { 72 | exports.get(request, (error, session, token) => { 73 | if (error) { 74 | log('[fail] could not destroy the session', error); 75 | } 76 | 77 | if (request.cookies) { 78 | // Destroy the cookie. 79 | request.cookies.set(cookieNames.token, '', { 80 | overwrite: true, 81 | secure: useSecureCookies 82 | }); 83 | } 84 | 85 | // Destroy the session. 86 | login.logout(token, error => { 87 | callback(error); 88 | }); 89 | }); 90 | }; 91 | 92 | // Send a challenge email to a given email address for verification. 93 | exports.sendVerificationEmail = function (email, token, template, callback) { 94 | login.proveEmail({ 95 | email: email, 96 | token: token, 97 | subject: template.subject, 98 | htmlMessage: template.htmlMessage, 99 | textMessage: template.textMessage 100 | }, error => { 101 | callback(error); 102 | }); 103 | }; 104 | 105 | // Attempt to verify an email address using the given login key. 106 | exports.verifyEmail = function (token, key, callback) { 107 | login.confirmEmail(token, key, (error, token, session) => { 108 | if (error) { 109 | callback(error); 110 | return; 111 | } 112 | 113 | if (!session || !session.emailVerified()) { 114 | callback(new Error('Unverified email: ' + (session && session.email))); 115 | return; 116 | } 117 | 118 | callback(null, session.email); 119 | }); 120 | }; 121 | -------------------------------------------------------------------------------- /lib/streams.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | const stream = require('stream'); 5 | 6 | const log = require('./log'); 7 | 8 | // Streams that are currently in progress, per object. 9 | const objectStreams = new Map(); 10 | 11 | // Get `object[key]` as a stream (may still be receiving new data). 12 | exports.get = function (object, key) { 13 | const passthrough = new stream.PassThrough(); 14 | const streams = objectStreams.get(object); 15 | 16 | if (object[key]) { 17 | passthrough.write(object[key], 'utf8'); 18 | } 19 | 20 | if (streams && streams[key]) { 21 | streams[key].pipe(passthrough); 22 | } else { 23 | passthrough.end(); 24 | } 25 | 26 | return passthrough; 27 | }; 28 | 29 | // Read a stream into `object[key]`. 30 | exports.set = function (object, key, readable) { 31 | let streams = objectStreams.get(object); 32 | 33 | if (!streams) { 34 | streams = {}; 35 | objectStreams.set(object, streams); 36 | } 37 | 38 | streams[key] = readable; 39 | object[key] = ''; 40 | 41 | // Save new data into `object[key]` as it comes in. 42 | readable.on('data', function (chunk) { 43 | object[key] += chunk; 44 | }); 45 | 46 | // Remove the stream if an error occurs. 47 | readable.on('error', function (error) { 48 | if (error) { 49 | log('[fail] could not read the stream', error); 50 | } 51 | 52 | exports.remove(object, key); 53 | }); 54 | 55 | // Clean up when the stream ends. 56 | readable.on('end', function () { 57 | exports.remove(object, key); 58 | }); 59 | }; 60 | 61 | // Remove any stream affected to `object[key]`. 62 | exports.remove = function (object, key) { 63 | const streams = objectStreams.get(object); 64 | 65 | // If the stream doesn't exist, do nothing. 66 | if (!streams || !streams[key]) { 67 | return; 68 | } 69 | 70 | // Delete the stream. 71 | delete streams[key]; 72 | 73 | // Clean up the `streams` object if empty. 74 | if (Object.keys(streams).length < 1) { 75 | objectStreams.delete(object); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "janitor.technology", 3 | "version": "0.0.10", 4 | "description": "The fastest development system in the world.", 5 | "homepage": "https://janitor.technology", 6 | "bugs": { 7 | "url": "https://github.com/janitortechnology/janitor/issues" 8 | }, 9 | "license": "AGPL-3.0", 10 | "author": { 11 | "name": "Jan Keromnes", 12 | "url": "http://jan.tools" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/janitortechnology/janitor" 17 | }, 18 | "scripts": { 19 | "app": "SCRIPT=app npm start", 20 | "join": "SCRIPT=join npm start", 21 | "lint": "eslint -c .eslintrc-node.js *.js api/ lib/ && eslint -c .eslintrc-browser.js static/", 22 | "lint-fix": "eslint -c .eslintrc-node.js *.js api/ lib/ --fix && eslint -c .eslintrc-browser.js static/ --fix", 23 | "rebase": "git pull -q --rebase origin master && git submodule -q update --rebase && npm update", 24 | "prestart": "npm stop && touch janitor.log janitor.pid && chmod 600 janitor.log janitor.pid", 25 | "start": "if [ -z \"$SCRIPT\" ] ; then printf \"Run which Janitor script? [join/app]:\" && read SCRIPT ; fi ; node \"$SCRIPT\" >> janitor.log 2>&1 & printf \"$!\\n\" > janitor.pid", 26 | "poststart": "printf \"[$(date -uIs)] Background process started (PID $(cat janitor.pid), LOGS $(pwd)/janitor.log).\\n\"", 27 | "stop": "if [ -e janitor.pid -a -n \"$(ps h $(cat janitor.pid))\" ] ; then kill $(cat janitor.pid) && printf \"[$(date -uIs)] Background process stopped (PID $(cat janitor.pid)).\\n\" ; fi ; rm -f janitor.pid", 28 | "test": "cd tests && node tests.js", 29 | "prewatch": "touch janitor.log && chmod 600 janitor.log", 30 | "watch": "watch-run --initial --pattern 'app.js,package.json,api/**,lib/**,templates/**' --stop-on-error npm run app & tail -f janitor.log -n 0" 31 | }, 32 | "dependencies": { 33 | "azure-arm-compute": "10.0.0", 34 | "camp": "18.1.1", 35 | "dockerode": "3.2.1", 36 | "email-login": "1.3.2", 37 | "fast-json-patch": "2.2.1", 38 | "fleau": "16.2.0", 39 | "le-acme-core": "2.1.4", 40 | "ms-rest-azure": "3.0.0", 41 | "node-forge": "0.10.0", 42 | "oauth": "0.9.15", 43 | "oauth2provider": "0.0.2", 44 | "selfapi": "1.0.0", 45 | "tar-stream": "2.2.0", 46 | "timeago.js": "4.0.2" 47 | }, 48 | "devDependencies": { 49 | "eslint": "7.25.0", 50 | "eslint-config-standard": "16.0.2", 51 | "eslint-plugin-import": "2.22.1", 52 | "eslint-plugin-node": "11.1.0", 53 | "eslint-plugin-promise": "5.1.0", 54 | "eslint-plugin-standard": "4.1.0", 55 | "watch-run": "1.2.5" 56 | }, 57 | "engines": { 58 | "node": ">=10.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #ffffff 10 | 11 | 12 | -------------------------------------------------------------------------------- /static/css/api-reference.css: -------------------------------------------------------------------------------- 1 | /* API page */ 2 | 3 | .reference h1:not(:first-child) { 4 | margin-top: 90px; 5 | } 6 | 7 | .reference h2 { 8 | margin-top: 50px; 9 | font-weight: 700; 10 | font-size: 18px; 11 | } 12 | 13 | .reference h1 + h2 { 14 | margin-top: 30px; 15 | } 16 | -------------------------------------------------------------------------------- /static/css/blog.css: -------------------------------------------------------------------------------- 1 | /* Blog page */ 2 | 3 | .blog .blog-post { 4 | position: relative; 5 | } 6 | 7 | .blog .blog-post label { 8 | display: inline-block; 9 | position: absolute; 10 | background: transparent; 11 | width: 100%; 12 | bottom: 0; 13 | cursor: pointer; 14 | z-index: 10; 15 | background: url(../img/icons/arrow-down.svg) 50% 40px no-repeat; 16 | } 17 | 18 | .blog-toggle { 19 | display: none; 20 | } 21 | 22 | .blog-toggle:checked + label { 23 | background-image: url(../img/icons/arrow-up.svg); 24 | } 25 | 26 | .blog-toggle:checked ~ .article-wrapper { 27 | height: auto; 28 | overflow: visible; 29 | } 30 | 31 | .blog .article-wrapper { 32 | border-top: solid 1px #ededf0; 33 | padding: 60px 0 80px 0; 34 | overflow: auto; 35 | height: 400px; 36 | overflow: hidden; 37 | } 38 | 39 | .blog .blog-post:first-child .article-wrapper { 40 | border: none; 41 | padding-top: 20px; 42 | } 43 | 44 | .blog .article-wrapper::after { 45 | content: ""; 46 | position: absolute; 47 | width: 100%; 48 | bottom: 0; 49 | background: linear-gradient(rgba(255, 255, 255, .1), rgba(255, 255, 255, .8), #fff, #fff); 50 | } 51 | 52 | .blog .article-wrapper::after, 53 | .blog label { 54 | padding: 50px 0 30px 0; 55 | } 56 | 57 | .blog article h1 { 58 | margin-bottom: 40px; 59 | } 60 | 61 | .blog article p { 62 | color: #0b0b0d; 63 | margin-top: 0; 64 | } 65 | 66 | .blog article h2 { 67 | font-weight: 700; 68 | font-size: 18px; 69 | } 70 | 71 | .blog article p img { 72 | margin: 40px auto; 73 | display: block; 74 | } 75 | 76 | .blog .emoji { 77 | height: 1em; 78 | } 79 | 80 | .blog blockquote { 81 | border-left: solid .25em; 82 | background: #fafafa; 83 | padding: 20px; 84 | margin: 20px 0; 85 | font-style: italic; 86 | } 87 | 88 | .blog blockquote > *:last-child { 89 | margin-bottom: 0; 90 | } 91 | 92 | .onebox { 93 | box-shadow: 0 2px 8px rgba(12,12,13,.1); 94 | padding: 0.75em; 95 | margin: 20px 0; 96 | font-size: 14px; 97 | } 98 | 99 | article.onebox-body { 100 | border: none; 101 | padding: 0 0; 102 | margin-top: 10px; 103 | margin-bottom: 0; 104 | } 105 | 106 | .onebox .source a, 107 | .onebox .label1, 108 | .onebox .label2 { 109 | color: #38383d; 110 | } 111 | 112 | .onebox .source img { 113 | vertical-align: middle; 114 | } 115 | 116 | .onebox .label2 { 117 | float: right; 118 | } 119 | 120 | .onebox-body p { 121 | margin-bottom: 0; 122 | } 123 | 124 | .onebox-body .aspect-image { 125 | max-height: 170px; 126 | --magic-ratio: calc(var(--aspect-ratio) + 0.15); 127 | width: calc(128px * var(--magic-ratio)); 128 | max-width: 20%; 129 | float: left; 130 | margin-right: 10px; 131 | height: auto; 132 | } 133 | 134 | .onebox .site-icon { 135 | width: 16px; 136 | height: 16px; 137 | } 138 | 139 | .onebox-body img { 140 | width: 100%; 141 | } 142 | 143 | .onebox-body h3 { 144 | margin-top: 0; 145 | } 146 | -------------------------------------------------------------------------------- /static/css/containers.css: -------------------------------------------------------------------------------- 1 | /* Projects page */ 2 | 3 | .panel:not(:hover) .editable-toggle:not(:focus) { 4 | visibility: hidden; 5 | } 6 | 7 | .danger-zone { 8 | color: #3e0200; 9 | background: rgba(255, 0, 57, 0.1); 10 | } 11 | -------------------------------------------------------------------------------- /static/css/data.css: -------------------------------------------------------------------------------- 1 | /* Data page */ 2 | 3 | .data-container { 4 | display: flex; 5 | flex-wrap: wrap; 6 | justify-content: center; 7 | position: relative; 8 | } 9 | 10 | .data-topic { 11 | flex: 0 1 100%; 12 | padding: 30px; 13 | position: relative; 14 | } 15 | 16 | .data-topic .number { 17 | font-size: 18px; 18 | font-weight: 700; 19 | color: #008ea4; 20 | } 21 | 22 | .data-topic .unit + .number { 23 | margin-left: 10px; 24 | } 25 | 26 | @media screen and (min-width: 769px) { 27 | .data h1 { 28 | margin-bottom: 60px; 29 | } 30 | 31 | .data-container::after { 32 | content: ''; 33 | width: 50px; 34 | height: 50px; 35 | background: #fff; 36 | position: absolute; 37 | top: 50%; 38 | left: 50%; 39 | transform: translate(-50%, -50%); 40 | } 41 | 42 | .data-topic { 43 | flex-basis: 50%; 44 | } 45 | 46 | .data-topic:not(:nth-child(3)):not(:nth-child(4)) { 47 | border-bottom: solid 1px #d7d7db; 48 | } 49 | 50 | .data-topic:not(:nth-child(2n)) { 51 | border-right: solid 1px #d7d7db; 52 | } 53 | } 54 | 55 | @media screen and (max-width: 768px) { 56 | .data-topic:not(:first-child) { 57 | border-top: solid 1px #d7d7db; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /static/css/landing.css: -------------------------------------------------------------------------------- 1 | /* Landing page */ 2 | 3 | .heading { 4 | font-size: 40px; 5 | font-weight: 300; 6 | } 7 | 8 | .container, .landing-section { 9 | font-size: 16px; 10 | } 11 | 12 | .landing-section { 13 | padding: 50px 10vw; 14 | } 15 | 16 | #intro-video { 17 | max-width: 992px; 18 | width: 100%; 19 | height: 50vh; 20 | margin: 50px auto; 21 | display: block; 22 | position: relative; 23 | } 24 | 25 | #intro-video iframe { 26 | width: 100%; 27 | height: 100%; 28 | border: none; 29 | } 30 | 31 | #quotes { 32 | background: #fafafa; 33 | border-top: solid 1px #eaeaea; 34 | border-bottom: solid 1px #eaeaea; 35 | display: flex; 36 | justify-content: space-around; 37 | } 38 | 39 | @media screen and (max-width: 768px) { 40 | #quotes { 41 | display: block; 42 | } 43 | 44 | .quote { 45 | width: 100%; 46 | } 47 | 48 | .quote:not(:last-child) { 49 | margin-bottom: 50px; 50 | } 51 | } 52 | 53 | .quote { 54 | overflow: auto; 55 | text-align: right; 56 | display: flex; 57 | flex-direction: column; 58 | flex-grow: 1; 59 | flex-basis: 0; 60 | } 61 | 62 | .quote:not(:last-child) { 63 | margin-right: 10%; 64 | } 65 | 66 | .quote blockquote { 67 | font-style: italic; 68 | text-align: justify; 69 | line-height: 1.5; 70 | margin: 0; 71 | color: #666; 72 | padding: 0 5px 20px 0; 73 | } 74 | 75 | .quote blockquote::before, .quote blockquote::after { 76 | content: '"'; 77 | font-size: 18px; 78 | font-weight: 700; 79 | } 80 | 81 | .quote span { 82 | font-weight: 700; 83 | } 84 | 85 | #feature-list { 86 | column-count: 2; 87 | column-width: 384px; /* 768px / 2 */ 88 | list-style: none; 89 | padding: 0; 90 | } 91 | 92 | #feature-list li::before { 93 | content: url(../img/icons/check.svg); 94 | margin-right: 8px; 95 | vertical-align: middle; 96 | } 97 | 98 | .partners { 99 | display: flex; 100 | flex-wrap: wrap; 101 | justify-content: space-around; 102 | } 103 | 104 | .partner-logo { 105 | height: 50px; 106 | margin: 10px; 107 | display: inline-block; 108 | text-align: center; 109 | flex: 1; 110 | } 111 | 112 | .partner-logo img { 113 | height: 100%; 114 | } 115 | -------------------------------------------------------------------------------- /static/css/projects.css: -------------------------------------------------------------------------------- 1 | .search-section { 2 | text-align: right; 3 | } 4 | 5 | .search-section input { 6 | padding-left: 32px; /* 8px padding + 16px image*/ 7 | background: #fff url(/img/icons/search.svg) no-repeat 8px center; 8 | } 9 | 10 | .card[hidden] { 11 | display: none; 12 | } -------------------------------------------------------------------------------- /static/css/settings.css: -------------------------------------------------------------------------------- 1 | /* Settings page */ 2 | 3 | .setting:not(:last-child) .form-control { 4 | margin-bottom: 2em; 5 | } 6 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/favicon.ico -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /static/img/bmo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /static/img/bors.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/img/chrome.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /static/img/cozy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /static/img/discourse.svg: -------------------------------------------------------------------------------- 1 | Discourse_logo -------------------------------------------------------------------------------- /static/img/dspace.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /static/img/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/img/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /static/img/favicons/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/img/favicons/favicon-196x196.png -------------------------------------------------------------------------------- /static/img/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/img/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /static/img/favicons/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/img/git.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 20 | 23 | 27 | 28 | -------------------------------------------------------------------------------- /static/img/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/img/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/icons/arrow-up.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/icons/check.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/icons/edit.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/icons/error.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/icons/link.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /static/img/icons/menu.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/icons/search.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/img/icons/warning.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/janitor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /static/img/kresus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/partners/datadog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/img/partners/irill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JanitorTechnology/janitor/d88bcf2718efd750f5e9a41a96e45e8cea1f3392/static/img/partners/irill.png -------------------------------------------------------------------------------- /static/img/partners/mozilla.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/img/peertube.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/img/privatebin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /static/img/rust.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/img/thefiletree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 43 | 52 | 53 | 55 | 56 | 58 | image/svg+xml 59 | 61 | 62 | 63 | 64 | 65 | 70 | 76 | 86 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /static/js/blog.js: -------------------------------------------------------------------------------- 1 | // Copyright © Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | function expandPost (hash) { 5 | // If hash is an empty string then select first blog 6 | const blogId = hash.slice(1) || document.querySelector('.article-wrapper h1').id; 7 | document.getElementById(blogId + '-cb').checked = true; 8 | } 9 | 10 | expandPost(window.location.hash); 11 | 12 | Array.forEach(document.querySelectorAll('.blog article p a'), function (element) { 13 | element.target = '_blank'; 14 | }); 15 | 16 | Array.forEach(document.querySelectorAll('.blog .icon.link'), function (element) { 17 | element.onclick = function () { 18 | expandPost(element.getAttribute('href')); 19 | }; 20 | }); 21 | -------------------------------------------------------------------------------- /static/js/graphs.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2016 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | // Format milliseconds into human readable text. 5 | window.formatTime = function (milliseconds) { 6 | const units = [ 7 | { code: 'ms', max: 1000 }, 8 | { code: 's', max: 60 }, 9 | { code: 'min', max: 60 }, 10 | { code: 'hours', max: 24 }, 11 | { code: 'days', max: 365.25 }, 12 | { code: 'years' } 13 | ]; 14 | let unit = units.shift(); 15 | let value = Number(milliseconds); 16 | 17 | while (unit.max && value >= unit.max) { 18 | value /= unit.max; 19 | unit = units.shift(); 20 | } 21 | 22 | return (Math.round(value * 10) / 10) + ' ' + unit.code; 23 | }; 24 | 25 | // Format bytes into human readable text. 26 | window.formatMemory = function (bytes) { 27 | const prefix = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']; 28 | let p = 0; 29 | let value = Number(bytes); 30 | 31 | while (value > 1024 && p < prefix.length) { 32 | value /= 1024; 33 | p++; 34 | } 35 | 36 | return (Math.round(value * 100) / 100) + ' ' + prefix[p] + 'B'; 37 | }; 38 | 39 | // Set-up all time series graphs. 40 | Array.map(document.querySelectorAll('*[data-data]'), function (div) { 41 | const data = JSON.parse(div.dataset.data); 42 | const title = div.dataset.title; 43 | 44 | data.forEach(function (row) { 45 | row[0] = new Date(row[0]); 46 | }); 47 | 48 | new window.Dygraph(div, data, { 49 | title: title, 50 | axes: { 51 | y: { 52 | valueFormatter: window.formatTime, 53 | axisLabelFormatter: window.formatTime, 54 | axisLabelWidth: 60, 55 | includeZero: true 56 | } 57 | }, 58 | labelsUTC: true 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /static/js/janitor-new.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2015 Team Janitor. All rights reserved. 2 | // The following code is covered by the AGPL-3.0 license. 3 | 4 | // Helpers 5 | const $ = function (selector, target) { 6 | if (!target) { 7 | target = document; 8 | } 9 | return target.querySelector(selector); 10 | }; 11 | 12 | const $$ = function (selector, target) { 13 | if (!target) { 14 | target = document; 15 | } 16 | return target.querySelectorAll(selector); 17 | }; 18 | 19 | // Polyfill a few basic things. 20 | ['filter', 'forEach', 'map', 'reduce', 'some'].forEach(function (name) { 21 | Array[name] = function (array, callback, init) { 22 | return [][name].call(array, callback, init); 23 | }; 24 | }); 25 | 26 | // Setup tabs 27 | Array.forEach($$('.tabs'), function (element) { 28 | const nav = $('.tab-nav', element); 29 | Array.forEach($$('.tab', nav), function (tab) { 30 | tab.addEventListener('click', function (event) { 31 | const newSelected = '[data-tab=' + tab.dataset.tab + ']'; 32 | const currentSelected = $$('.tab-panel.selected, .tab.selected', element); 33 | if (currentSelected.length > 0) { 34 | Array.forEach(currentSelected, function (panel) { 35 | panel.classList.remove('selected'); 36 | }); 37 | } 38 | Array.forEach($$('.tab' + newSelected + ', .tab-panel' + newSelected, element), function (selected) { 39 | selected.classList.add('selected'); 40 | }); 41 | event.preventDefault(); 42 | }); 43 | }); 44 | 45 | // Select first element 46 | nav.firstElementChild.click(); 47 | }); 48 | 49 | // Automatically set up asynchronous JSON forms (all with a 'method' attribute). 50 | Array.forEach(document.querySelectorAll('form[method]'), function (form) { 51 | setupAsyncForm(form); 52 | form.addEventListener('submit', function (event) { 53 | // Set form to pending status 54 | updateFormStatus(form, 'pending'); 55 | 56 | const elements = Array.filter(form.elements, function (element) { 57 | // Only consider `form.elements` that have a `name` attribute. 58 | return !!element.name; 59 | }).map(function (element) { 60 | // Extract values, properly handling elements with `type="checkbox"`. 61 | return { 62 | name: element.name, 63 | value: element.type === 'checkbox' ? element.checked : element.value 64 | }; 65 | }); 66 | 67 | // Build a JSON payload containing the submitted form data. 68 | let data = {}; 69 | const method = form.getAttribute('method').toUpperCase(); 70 | if (method === 'PATCH') { 71 | // Set up JSON Patch forms to submit an Array of JSON Patch operations. 72 | // See also: RFC 6902 - JSON Patch. 73 | data = elements.map(function (element) { 74 | return { op: 'add', path: element.name, value: element.value }; 75 | }); 76 | } else { 77 | // By default, submit a JSON Object that maps element names and values. 78 | elements.forEach(function (element) { 79 | data[element.name] = element.value; 80 | }); 81 | } 82 | 83 | // Submit the JSON payload to the specified `form.action` URL. 84 | fetchAPI(method, form.action, data, function (error, data) { 85 | if (error) { 86 | updateFormStatus(form, 'error', String(error)); 87 | return; 88 | } 89 | updateFormStatus(form, 'success', data ? data.message : null); 90 | }); 91 | }); 92 | }); 93 | 94 | // Use `window.fetch()` to make an asynchronous Janitor API request. 95 | function fetchAPI (method, url, data, callback) { 96 | let responseStatus = null; 97 | const options = { 98 | method: method.toUpperCase(), 99 | headers: new Headers({ 100 | Accept: 'application/json', 101 | 'Content-Type': 'application/json' 102 | }), 103 | credentials: 'same-origin' 104 | }; 105 | 106 | // Requests with method 'GET' or 'HEAD' cannot have `options.body`. 107 | if (data && ['GET', 'HEAD'].indexOf(options.method) < 0) { 108 | options.body = JSON.stringify(data, null, 2); 109 | } 110 | 111 | window.fetch(url, options).then(function (response) { 112 | // The server is responding! 113 | responseStatus = response.status; 114 | return responseStatus === 204 ? null : response.json(); 115 | }).then(function (data) { 116 | // The response body was successfully parsed as JSON! 117 | if (data && data.error) { 118 | // The parsed JSON contains an error message. 119 | throw new Error(data.error); 120 | } 121 | 122 | if (responseStatus < 200 || responseStatus >= 300) { 123 | // The response status indicates something went wrong. 124 | throw new Error('Response status: ' + responseStatus); 125 | } 126 | 127 | // The request was successful! 128 | callback(null, data); 129 | }).catch(function (error) { 130 | // The request failed! 131 | callback(error); 132 | }); 133 | } 134 | 135 | // Set up a
element that submits asynchronously. 136 | function setupAsyncForm (form) { 137 | if (!form) { 138 | return; 139 | } 140 | 141 | // Re-enable all fields and hide any previous feedback. 142 | function resetFormStatus () { 143 | updateFormStatus(form); 144 | } 145 | 146 | // Process all input elements (like , 24 | 25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 | 34 |
35 | {{if {{'oauth2client' in host}} then {{ 36 | }}}} 51 | {{ 52 | } 53 | }} 54 |
55 |
56 | 57 |
58 | 59 | 60 | 61 | 62 |
63 |
64 |
65 | 66 | -------------------------------------------------------------------------------- /templates/admin-integrations.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |

Azure

8 |
9 |
10 |
11 | 12 | 13 |
14 | 15 | 16 |
17 | 18 | 19 |
20 | 21 | 22 |
23 | 24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 | 32 |

33 |                   
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |

GitHub

42 |
43 |
44 |
45 | 46 | 47 |
48 | 49 | 50 |
51 | 52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | -------------------------------------------------------------------------------- /templates/admin-projects.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ 3 | for (let id in projects) { 4 | let project = projects[id]; 5 | }} 6 |
7 |
8 | {{= project.name in xmlattr}} Logo 9 |

{{= project.name in html}}

10 |
11 | 12 |
13 | 14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 | 29 |
30 |
31 | 32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 |
40 |
41 |
42 | 43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 |
51 | 52 |
{{= project.docker.logs in html}}
53 |
54 |
{{ 55 | } 56 | }} 57 |
58 |
59 |

New Project

60 |
61 | 62 |
63 |
64 |
65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 |
74 |
75 | 76 | 77 |
78 |
79 |
80 |
81 |
82 |
83 | -------------------------------------------------------------------------------- /templates/admin-users.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |
    {{ for (let email in users) { }} 7 |
  • {{= email in html}}
  • {{ } }} 8 |
9 |
10 |
11 | 12 |
    {{ for (let email in waitlist) { }} 13 |
  • {{= email in html}}
  • {{ } }} 14 |
    15 |
    16 | 17 | 18 | 19 | 20 |
    21 |
    22 |
23 |
24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /templates/blog.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
{{ 4 | for (const post of posts) { 5 | const { title, post_body_html, slug, comments_count, comments_url } = post; 6 | }} 7 |
8 | 9 | 10 |
11 |
12 |

{{= title in html}}

13 | {{= post_body_html}} 14 |
15 | 16 |
17 |
{{ 18 | } 19 | }} 20 |
21 |
22 | -------------------------------------------------------------------------------- /templates/configurations/.config/hub: -------------------------------------------------------------------------------- 1 | github.com: 2 | - user: {{= user.keys.github.username }} 3 | oauth_token: {{= user.keys.github.accessToken }} 4 | protocol: https 5 | -------------------------------------------------------------------------------- /templates/configurations/.gitconfig: -------------------------------------------------------------------------------- 1 | [user] 2 | name = {{= user.profile.name }} 3 | email = {{= user._primaryEmail }} 4 | [bz] 5 | username = {{= user._primaryEmail }} 6 | [core] 7 | editor = vim 8 | autocrlf = false 9 | whitespace = off 10 | excludesfile = ~/.gitignore 11 | [color] 12 | ui = auto 13 | [diff] 14 | context = 8 15 | indentHeuristic = true 16 | [help] 17 | autocorrect = 1 18 | [hub] 19 | protocol = https 20 | [push] 21 | default = current 22 | -------------------------------------------------------------------------------- /templates/configurations/.gitignore: -------------------------------------------------------------------------------- 1 | !Android.mk 2 | *.VC.db 3 | *.gypcmd 4 | *.mk 5 | *.ncb 6 | *.opensdf 7 | *.orig 8 | *.pdb 9 | *.props 10 | *.pyc 11 | *.pyproj 12 | *.rules 13 | *.sdf 14 | *.sln 15 | *.sublime-project 16 | *.sublime-workspace 17 | *.suo 18 | *.targets 19 | *.user 20 | *.vc.opendb 21 | *.vcproj 22 | *.vcxproj 23 | *.vcxproj.filters 24 | *.vpj 25 | *.vpw 26 | *.vpwhistu 27 | *.vtg 28 | *.xcodeproj 29 | *.xcworkspace 30 | *_proto.xml 31 | *_proto_cpp.xml 32 | *~ 33 | .*.sw? 34 | .DS_Store 35 | .c9 36 | .checkstyle 37 | .classpath 38 | .cproject 39 | .gdb_history 40 | .gdbinit 41 | .hgignore 42 | .landmines 43 | .metadata 44 | .project 45 | .pydevproject 46 | /.externalToolBuilders/ 47 | /.settings/ 48 | /.vs/ 49 | /.vscode/ 50 | /_out 51 | /.watchmanconfig 52 | GPATH 53 | GRTAGS 54 | GSYMS 55 | GTAGS 56 | Session.vim 57 | Thumbs.db 58 | cscope.* 59 | node_modules/ 60 | tags 61 | v8.log 62 | 63 | # C/C++ 64 | *.slo 65 | *.lo 66 | *.o 67 | *.so 68 | *.so.* 69 | *.dylib 70 | *.lai 71 | *.la 72 | *.a 73 | *.lib 74 | *.dll 75 | *.exe 76 | *.out 77 | install_manifest.txt 78 | CMakeCache.txt 79 | CMakeFiles 80 | cmake_install.cmake 81 | 82 | # Python 83 | *.py[cod] 84 | .installed.cfg 85 | __pycache__ 86 | pip-log.txt 87 | .coverage 88 | .tox 89 | nosetests.xml 90 | 91 | # Ruby 92 | *.rbc 93 | *.sassc 94 | .sass-cache 95 | capybara-*.html 96 | .rspec 97 | .rvmrc 98 | /.bundle 99 | rerun.txt 100 | pickle-email-*.html 101 | *.gem 102 | *.rbc 103 | .bundle 104 | .config 105 | InstalledFiles 106 | spec/reports 107 | test/tmp 108 | test/version_tmp 109 | .yardoc 110 | _yardoc 111 | -------------------------------------------------------------------------------- /templates/configurations/.hgrc: -------------------------------------------------------------------------------- 1 | [ui] 2 | username = {{= user.profile.name }} <{{= user._primaryEmail }}> 3 | interface = curses 4 | [bugzilla] 5 | username = {{= user._primaryEmail }} 6 | [diff] 7 | git = true 8 | showfunc = true 9 | [extensions] 10 | mq = 11 | color = 12 | pager = 13 | histedit = 14 | rebase = 15 | blackbox = 16 | fsmonitor = 17 | firefoxtree = /home/user/.mozbuild/version-control-tools/hgext/firefoxtree 18 | reviewboard = /home/user/.mozbuild/version-control-tools/hgext/reviewboard/client.py 19 | bzexport = /home/user/.mozbuild/version-control-tools/hgext/bzexport 20 | push-to-try = /home/user/.mozbuild/version-control-tools/hgext/push-to-try 21 | [defaults] 22 | qnew = -D -U 23 | qrefresh = -D 24 | qseries = -s 25 | qunapplied = -s 26 | [pager] 27 | pager = LESS=FRSXQ less 28 | attend-help = true 29 | attend-incoming = true 30 | attend-outgoing = true 31 | attend-status = true 32 | attend-wip = true 33 | [alias] 34 | wip = log --graph --rev=wip --template=wip 35 | [revsetalias] 36 | wip = (parents(not public()) or not public() or . or (head() and branch(default))) and (not obsolete() or unstable()^) and not closed() and not (fxheads() - date(-90)) 37 | [templates] 38 | wip = '{label("wip.branch", if(branches,"{branches} "))}{label(ifeq(graphnode,"x","wip.obsolete","wip.{phase}"),"{rev}:{node|short}")}{label("wip.user", " {author|user}")}{label("wip.tags", if(tags," {tags}"))}{label("wip.tags", if(fxheads," {fxheads}"))}{if(bookmarks," ")}{label("wip.bookmarks", if(bookmarks,bookmarks))}{label(ifcontains(rev, revset("parents()"), "wip.here"), " {desc|firstline}")}' 39 | [color] 40 | wip.bookmarks = yellow underline 41 | wip.branch = yellow 42 | wip.draft = green 43 | wip.here = red 44 | wip.obsolete = none 45 | wip.public = blue 46 | wip.tags = yellow 47 | wip.user = magenta 48 | [experimental] 49 | graphshorten = true 50 | [paths] 51 | review = https://reviewboard-hg.mozilla.org/autoreview 52 | -------------------------------------------------------------------------------- /templates/configurations/.netrc: -------------------------------------------------------------------------------- 1 | machine github.com 2 | login {{= user.keys.github.username }} 3 | password {{= user.keys.github.accessToken }} 4 | -------------------------------------------------------------------------------- /templates/configurations/.ssh/authorized_keys: -------------------------------------------------------------------------------- 1 | {{= user.keys.cloud9 }} 2 | {{ user.keys.github.authorizedKeys.forEach(({ key }) => { }}{{= key }} 3 | {{ }); }} 4 | -------------------------------------------------------------------------------- /templates/containers.html: -------------------------------------------------------------------------------- 1 |
{{ 2 | for (let projectId in user.machines) { 3 | const machines = user.machines[projectId]; 4 | const project = projects[projectId]; 5 | for (const id in machines) { 6 | const machine = machines[id]; 7 | if (machine.status === "new") { 8 | continue; 9 | } 10 | const name = machine.properties.name || `${project.name} #${id}`; 11 | }} 12 |
13 |
14 | 15 | {{= project.name in xmlattr}} Logo 16 |
17 |
18 |
19 | 20 |
21 |
22 |

{{= name in html}}

23 | 24 |
25 |
26 |
27 |
28 | {{ 29 | const updated = new Date(machine.data.updated); 30 | const c9sdkStart = new Date('2017-08-02'); 31 | const c9ioEnd = new Date('2017-09-02'); 32 | if (updated > c9sdkStart && updated < c9ioEnd) { }} 33 | c9.io 34 | {{ } }} 35 | VNC 36 | IDE 40 |
41 |
42 |
43 | 47 |
48 |
49 |

{{ 50 | // FIXME: Stop using `machine.data.updated` here once all containers have an expiration date. 51 | const machineExpires = machine.data.expires || (machine.data.updated + 1000 * 3600 * 24 * 30 * 6); 52 | 53 | // Show image build time until about 2 months before the container expires, then show expiration time. 54 | if (machineExpires - Date.now() > 1000 * 3600 * 24 * 30 * 2) { 55 | const machineUpdatedFuzzy = timeago().format(machine.data.updated); }} 56 | Image built 57 | .{{ 58 | } else { 59 | const machineExpiresPrefix = 'Expire' + (machineExpires < Date.now() ? 'd' : 's'); 60 | const machineExpiresFuzzy = timeago().format(machineExpires); }} 61 | {{= machineExpiresPrefix in html}} 62 | .{{ 63 | } }} 64 |

65 |
66 |
67 |
68 | Connect via SSH 69 |

Use the following command to connect to your container via SSH:

70 | ssh -p{{= machine.docker.ports['22'].port in integer}} user@{{= machine.docker.host in html}} 71 |
72 |
73 | Danger zone! 74 |

This action can't be undone, please make sure you've backed up everything valuable.

75 |
76 | 77 |
78 |
79 |
80 |
81 |
82 |
{{ 83 | } 84 | } 85 | }} 86 |
87 | -------------------------------------------------------------------------------- /templates/data.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Data

4 |
5 |
6 |

Users

7 |

8 | {{= data.users.users.length in integer}} 9 | confirmed 10 | {{= data.users.waitlist.length in integer}} 11 | waitlist 12 |

13 |
14 |
15 |

Containers

16 |

17 | {{= data.contributions.started in integer}} 18 | started 19 |

20 | View Running Containers 21 |
22 |
23 |

Projects

24 |

25 | {{= data.projects.length in integer}} 26 |

27 |
28 |
29 |

Cluster

30 |

31 | {{= data.hosts.docker.length in integer}} 32 | Docker host{{if {{data.hosts.docker.length !== 1}} then s}} 33 |

34 | View Server Metrics 35 |
36 |
37 |
38 |
39 | -------------------------------------------------------------------------------- /templates/design.html: -------------------------------------------------------------------------------- 1 |
2 |

Design guide

3 |

Buttons

4 |
5 | 6 | 7 | 8 |
9 | 10 |

Inputs

11 | 12 | 13 | 14 |

Icons

15 | 16 | 17 | 18 |

Tabs

19 |
20 | 25 |
26 |
27 | This is the info tab 28 |
29 |
30 | This is the terminal tab 31 |
32 |
33 | This is the advanced tab 34 |
35 |
36 |
37 | 38 |

Panels

39 |
40 |
41 |
42 | Hi! 43 |
44 |
45 | 46 | 47 |
48 |
49 |
50 |

51 | That's great news. I really hope this will evolve into a full-fledged TabGroups replacement so that I can switch to newer versions of Firefox. 52 |

53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /templates/footer-old.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | 22 | {{ for (let src of scripts) { }} 23 | {{ } }} 24 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /templates/footer.html: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | {{ for (let src of scripts) { }} 36 | {{ } }} 37 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /templates/header-insecure-old.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /templates/header-insecure.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /templates/header-old.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{= title in html}}{{if title then {{ • }}}}Janitor 9 | 10 | 11 | 12 | 13 | 14 | 15 | 37 | -------------------------------------------------------------------------------- /templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{= title in html}}{{if title then {{ - }}}}Janitor 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{ for (let sheet of stylesheets) { }} 18 | {{ } }} 19 | 20 | 21 | 22 | 23 | 40 | -------------------------------------------------------------------------------- /templates/landing.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ 4 | const tagline = taglines[Math.floor(Math.random() * taglines.length)] + ' faster'; 5 | }}

{{= tagline in html}}

6 |
7 |
8 | 9 |
10 |
11 |
12 |
13 |
Just wanted to say thanks for making Janitor. It just saved me a ton of time debugging a crash.
14 | ― Brian Grinstead 15 |
16 |
17 |
This will change the lives of a lot of contributors.
18 | ― Michael Kohler 19 |
20 |
21 |
Mind blown! This would have literally saved months of my life years ago when I hacked on Firefox.
22 | ― Brian King 23 |
24 |
25 |
26 |

In one click, spin up a complete development environment

27 |
    28 |
  • Runs Ubuntu 16.04
  • 29 |
  • Starts in 2.1 seconds
  • 30 |
  • Pre-configured tools & dependencies
  • 31 |
  • Pre-compiled source tree
  • 32 |
  • On powerful servers with fast Internet
  • 33 |
  • Login from anywhere (home; work; the library; a bus…)
  • 34 |
  • Save your laptop from overheating
  • 35 |
36 |
37 |
38 |

Hack on the coolest projects

39 |
{{ 40 | for (let id in projects) { 41 | const project = projects[id]; 42 | }} 43 |
44 | {{= project.name in xmlattr}} Logo 45 |
46 |
47 |

{{= project.name in html}}

48 |
49 |
50 |
{{ } }} 51 |
52 |
53 |
54 |

Get started

55 |
56 | 57 | 58 |
59 |
60 |
61 |

Brought to you by

62 |
63 | 64 | 65 | 66 | 67 |
68 |
69 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Authenticate

4 |

5 | To log in, please fill in your email address below: 6 |

7 |
8 |
9 | 10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /templates/notifications.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

My Notifications

4 |
5 |
6 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /templates/project.html: -------------------------------------------------------------------------------- 1 |
2 |

{{= project.name in html}}

3 |

{{= project.description in html}}

4 |

Built {{ 5 | const projectUpdatedFuzzy = project.data.updated 6 | ? timeago().format(project.data.updated) 7 | : 'never'; 8 | }}.

9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /templates/projects.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
{{ 6 | for (let id in projects) { 7 | const project = projects[id]; 8 | const searchText = (project.name + ' ' + project.description).toLowerCase(); 9 | }} 10 |
11 | {{= project.name in xmlattr}} Logo 12 |
13 |
14 |

{{= project.name in html}}

15 |

{{= project.description in html}}

16 |
17 |
18 |
19 | Built {{ 20 | const projectUpdatedFuzzy = project.data.updated 21 | ? timeago().format(project.data.updated) 22 | : 'never'; 23 | }}. 24 |
{{if user then {{ 25 |
26 | 27 |
}} else {{ 28 | }}}} 29 |
30 |
31 |
{{ } }} 32 |
33 |
34 | -------------------------------------------------------------------------------- /templates/reference-api.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{= htmlReference }} 4 |
5 |
6 | -------------------------------------------------------------------------------- /templates/settings-account.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 8 | 12 |
13 |
14 |
15 |
16 |
17 |
18 | 19 | 20 | ProTip! After setting up your account, you can start hacking on Projects. 21 | 22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /templates/settings-configurations.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Configure your containers from a single location, by editing and deploying these configuration files.

6 |
7 |
{{ for (let file of Object.keys(user.configurations).sort()) { }} 8 |
{{ if (defaultConfigurations.includes(file)) { }} 9 |
10 | 13 |
{{ } }} 14 | 20 | 25 |
{{ } }} 26 |
27 |
-------------------------------------------------------------------------------- /templates/settings-header.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /templates/settings-integrations.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Connect your external accounts to unlock powerful automations and tools for your workflows on Janitor.

6 |
7 |
8 |
9 |
10 |
    11 |
  • 12 | GitHub Logo 13 |
    14 | GitHub
    15 | Import basic information from your GitHub profile 16 |
    17 |
    {{if {{github.username}} then {{ 18 |
    19 | 20 |
    }} else {{ 21 | Connect}}}} 22 |
    23 |
  • 24 |
  • 25 | BMO Logo 26 |
    27 | Bugzilla
    28 | Easily send Firefox patches to code review 29 |
    30 |
    31 | Soon™ 32 |
    33 |
  • 34 |
35 |
36 |
37 |
38 |
39 | -------------------------------------------------------------------------------- /templates/settings-notifications.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Configure your preferences for receiving notifications.

6 |
7 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /templates/settings.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 |
8 |
9 |
10 |

Basic information

11 |
12 | Email 13 | 14 |
15 |
16 | Name 17 | 18 |
19 |
20 | 31 |
32 |

Integrations

33 |
34 |
35 |
36 | GitHub Logo 37 |
38 | GitHub
39 | Import basic information from your GitHub profile 40 |
{{if {{github.username}} then {{ 41 |
42 | 43 |
}} else {{ 44 | }}}} 45 |
46 |
47 |
48 |
49 | BMO Logo 50 |
51 | Bugzilla
52 | Easily send Firefox patches to code review 53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |

62 | Configure your containers from a single location, by editing and deploying these configuration files. 63 |

{{ for (let file of Object.keys(user.configurations).sort()) { }} 64 |
65 |
66 | ~/{{= file in html}}{{ if (defaultConfigurations.includes(file)) { }} 67 |
68 | 69 |
70 | {{ } }}
71 |
72 |
73 | 76 |
77 | 80 |
81 |
{{ } }} 82 |
83 |
84 |
85 |
86 | --------------------------------------------------------------------------------