diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 0000000..2e479a1 --- /dev/null +++ b/.bowerrc @@ -0,0 +1,8 @@ +{ + "directory": "public/lib", + "storage": { + "packages": ".bower-cache", + "registry": ".bower-registry" + }, + "tmp": ".bower-tmp" +} diff --git a/.gitignore b/.gitignore index 9d77abf..40d04da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ workspaces/* node_modules +config.js +.bower-*/ +public/lib +.c9revisions +.settings +public/css/* diff --git a/README.md b/README.md index b687da9..d3d8da8 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,69 @@ -#Cloud9Hub +# Cloud9Hub -##What's this? +## What's this? It's a simple interface for the Cloud 9 open source edition to easily create, use and manage multiple workspaces. [The Cloud9 service](https://c9.io) has a shiny and awesome dashboard interface where you can manage multiple workspaces, however the [open source edition](https://github.com/ajaxorg/cloud9) is a single workspace instance of Cloud9. As I like the possibility to easily start working on different workspaces, create or delete them, I created Cloud9Hub to do so. -##What's Cloud9? +## What's Cloud9? A full-blown IDE in your browser. It has a full terminal integration, can run and deploy code of different languages (e.g. Ruby, node.js, PHP) -and [lots more](https://c9.io/site/features/). +and [lots more](http://en.wikipedia.org/wiki/Cloud9_IDE#Features). -##Status Quo of Cloud9Hub +## Status Quo of Cloud9Hub Right now it can * Create new workspaces * Launch multiple workspace instances * Kill them automatically after 15 minutes * List available workspaces * Delete workspaces - -It **can't** * Manage multiple users * Do authentication/sessions -* Sense, that you're active and **not** kill your workspace after 15 minutes. +* Sense, that you're active and will kill your workspace after 15-20 minutes of inactivity. right now. These are the next steps for me to build (or you make a Pull Request with the features you want). -##Installation +## Installation First you will need [node.js](http://nodejs.org/), at least v0.8. -**Note, as of June, 12th 2013:** Cloud9 right now breaks when you try installing it with node > 0.8.x :( +Then you can try the quick install or the manual way: + +### Quick install + +```shell +curl https://raw.githubusercontent.com/AVGP/cloud9hub/master/install.sh | sh +``` + +This should install Cloud9 and Cloud9Hub into the current folder. If this succeeded, you can now go to the configuration section. + +### Manual installation +1. Install [Cloud9](https://github.com/ajaxorg/cloud9) into some folder, say ``/var/awesomeness/cloud9``. +**Note, the cloud9 is currently hardcoded to c9. when cloning cloud9, clone to c9 dir. If this isn't done, hub will crash. +2. Then install Cloud9Hub into the parent folder above your cloud9 installation, so in my example``/var/awesomeness/cloud9hub` and run ``npm install``. + +# Configuration + +First things first: You need a Github application to provide the "Login with Github" feature, which is currently the only login mechanism. + +Go to [https://github.com/settings/applications/new](https://github.com/settings/applications/new) and create a new application. Note down the client ID and secret, you'll need them later. + +Now copy the ``config.js.example`` to ``config.js`` and edit the contents: + +- Add your Github client ID and secret +- Change your BASE_URL to your server's address (do not include the port!) -Install [Cloud9](https://github.com/ajaxorg/cloud9) into some folder, say ``/var/awesomeness/cloud9``. -Then install Cloud9hub into the parent folder above your cloud9 installation, so in my example``/var/awesomeness/cloud9hub`. -Start Cloud9 hub with ``node server``. +## Firewall +You will need ports 3000 and 5000 to however many connections will be taking place concurrently (each session is given a different port) -##Running as a daemon +## Running as a daemon If you wish to, you can run it as a daemon, so that it stays alive. To do so, I recommend [forever](https://npmjs.org/package/forever). -##License -[MIT License](http://opensource.org/licenses/MIT), baby. +## License +**This project:** [MIT License](http://opensource.org/licenses/MIT), baby. +**Cloud9 itself:** [GPL](http://www.gnu.org/licenses/gpl.html) -##WARNING +## WARNING This is highly insecure, experimental and it may bite. diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..96b9fc9 --- /dev/null +++ b/bower.json @@ -0,0 +1,11 @@ +{ + "name": "cloud9hub", + "version": "0.0.1", + "dependencies": { + "angular": "~1.2.15", + "topcoat": "~0.8.0", + "angular-route": "~1.2.15", + "fontawesome": "~4.0.3", + "flat-ui-official": "~2.1.3" + } +} diff --git a/config.js.example b/config.js.example new file mode 100644 index 0000000..ee9ccab --- /dev/null +++ b/config.js.example @@ -0,0 +1,16 @@ +exports.GITHUB_CLIENT_ID = '3909fc1ee9f8ed9e87f2'; +exports.GITHUB_CLIENT_SECRET = 'e5b3802ef773d8d32a8b29fe958d04e591be850d'; + +// This allows everybody to sign up +exports.PERMITTED_USERS = false; +// This would only allow alice and bob to sign up +//exports.PERMITTED_USERS = ['alice', 'bob']; + +// Add SSL Certificates to use Cloud9Hub over SSL +// (Cloud9 Workspaces will still be unsecured standard HTTP) +//exports.SSL = { +// key: "/path/to/ssl.key", +// cert: "/path/to/ssl.pem" +//}; + +exports.BASE_URL = 'http://localhost'; diff --git a/controllers/index.js b/controllers/index.js new file mode 100644 index 0000000..f23046c --- /dev/null +++ b/controllers/index.js @@ -0,0 +1,8 @@ + +/* + * GET home page. + */ + +exports.index = function(req, res){ + res.render('index', { title: 'Express' }); +}; \ No newline at end of file diff --git a/controllers/login.js b/controllers/login.js new file mode 100644 index 0000000..7e76a0a --- /dev/null +++ b/controllers/login.js @@ -0,0 +1,8 @@ + +/* + * GET home page. + */ + +exports.login = function(req, res){ + res.render('login', {}); +}; \ No newline at end of file diff --git a/controllers/workspaces.js b/controllers/workspaces.js new file mode 100644 index 0000000..5faa5ad --- /dev/null +++ b/controllers/workspaces.js @@ -0,0 +1,181 @@ +var fs = require('fs'), + path = require('path'), + rimraf = require('rimraf'), + _ = require('lodash'), + spawn = require('child_process').spawn; + +var respondInvalidWorkspace = function(res) { + res.status(400); + res.json({msg: "Invalid workspace name"}); +}; + +var createWorkspace = function(params, req, res) { + var potentiallyBadPathName = params.name.split(path.sep); + var workspaceName = potentiallyBadPathName[potentiallyBadPathName.length-1]; + + if(workspaceName === '..') { + respondInvalidWorkspace(res); + return; + } + + fs.mkdir(__dirname + '/../workspaces/' + req.user + "/" + workspaceName, '0700', function(err) { + if(err) { + respondInvalidWorkspace(res); + return; + } + + res.json({msg: "Workspace " + workspaceName + " was created."}); + }); +} + +var createWorkspaceKillTimeout = function(req, workspaceProcess, workspaceName) { + var timeout = setTimeout(function() { + process.kill(-workspaceProcess.pid, 'SIGTERM'); + req.app.get('runningWorkspaces')[req.user + '/' + workspaceName] = undefined; + console.info("Killed workspace " + workspaceName); + }, 900000); //Workspaces have a lifetime of 15 minutes + + return timeout; +}; + +/* + * POST/GET create a new workspace + */ +exports.create = function(req, res) { + if(req.body.name) { + createWorkspace(req.body, req, res); + } else { + respondInvalidWorkspace(res); + } +} + +/* + * GET workspaces listing. + */ +exports.list = function(req, res){ + fs.readdir(__dirname + '/../workspaces/' + req.user, function(err, files) { + if(err) { + res.status(500); + res.json({error: err}); + } else { + var workspaces = []; + for(var i=0; i< files.length; i++) { + // Skip hidden files + if(files[i][0] === '.') continue; + + workspaces.push({name: files[i]}) + } + res.json({workspaces: workspaces}); + } + }); +}; + +/** + * DELETE destroys a workspace + */ +exports.destroy = function(req, res) { + var potentiallyBadPathName = req.params.name.split(path.sep); + var workspaceName = potentiallyBadPathName[potentiallyBadPathName.length-1]; + + if(workspaceName === '..') { + respondInvalidWorkspace(res); + return; + } + + rimraf(__dirname + "/../workspaces/" + req.user + "/" + workspaceName, function(err) { + if(err) { + res.status("500"); + res.json({msg: "Something went wrong :("}); + return; + } + res.json({msg: "Successfully deleted " + workspaceName}); + }) +}; + +/* + * GET run a workspace + */ + exports.run = function(req, res) { + var potentiallyBadPathName = req.params.name.split(path.sep); + var workspaceName = potentiallyBadPathName[potentiallyBadPathName.length-1]; + + var isPortTaken = function(port, fn) { + console.log('checking if port', port, 'is taken'); + var net = require('net') + var tester = net.createServer() + .once('error', function (err) { + if (err.code != 'EADDRINUSE') return fn(err) + console.log('port', port, 'seems to be taken'); + fn(null, true) + }) + .once('listening', function() { + tester.once('close', function() { + console.log('port', port, 'seems to be available'); + fn(null, false) + }) + .close() + }) + .listen(port) + }; + + var getNextAvailablePort = function(callback){ + var nextFreeWorkspacePort = req.app.get('nextFreeWorkspacePort'); + + if(nextFreeWorkspacePort > 10000) { + nextFreeWorkspacePort = 5000; + } + + nextFreeWorkspacePort = nextFreeWorkspacePort + 1; + console.log('setting nextFreeWorkspacePort to', nextFreeWorkspacePort); + req.app.set('nextFreeWorkspacePort', nextFreeWorkspacePort); + + isPortTaken(nextFreeWorkspacePort, function(err, taken){ + if(taken){ + getNextAvailablePort(callback); + } else { + req.app.set('nextFreeWorkspacePort', nextFreeWorkspacePort); + callback(nextFreeWorkspacePort); + } + }); + }; + + if(workspaceName === '..') { + respondInvalidWorkspace(res); + return; + } + + if(typeof req.app.get('runningWorkspaces')[req.user + '/' + workspaceName] === 'undefined'){ + getNextAvailablePort(function(nextFreePort){ + console.log("Starting " + __dirname + '/../../c9/bin/cloud9.sh for workspace ' + workspaceName + " on port " + nextFreePort); + + var workspace = spawn(__dirname + '/../../c9/bin/cloud9.sh', ['-w', __dirname + '/../workspaces/' + req.user + '/' + workspaceName, '-l', '0.0.0.0', '-p', nextFreePort], {detached: true}); + workspace.stderr.on('data', function (data) { + console.log('stdERR: ' + data); + }); + + req.app.get('runningWorkspaces')[req.user + '/' + workspaceName] = { + killTimeout: createWorkspaceKillTimeout(req, workspace, workspaceName), + process: workspace, + name: workspaceName, + url: req.app.settings.baseUrl + ":" + nextFreePort, + user: req.user + }; + + res.json({msg: "Attempted to start workspace", user: req.user, url: req.app.settings.baseUrl + ":" + nextFreePort}); + }); + } else { + console.log("Found running workspace", req.app.get('runningWorkspaces')[req.user + '/' + workspaceName].url); + res.json({msg: "Found running workspace", user: req.user, url: req.app.get('runningWorkspaces')[req.user + '/' + workspaceName].url}); + } + + } + +/* + * POST to keep the workspace alive +*/ + exports.keepAlive = function(req, res) { + var workspace = req.app.get('runningWorkspaces')[req.user + '/' + req.params.name]; + clearTimeout(workspace.killTimeout); + workspace.killTimeout = createWorkspaceKillTimeout(req, workspace.process, workspace.name); + res.send(); + } \ No newline at end of file diff --git a/gruntfile.js b/gruntfile.js new file mode 100644 index 0000000..9c9eb52 --- /dev/null +++ b/gruntfile.js @@ -0,0 +1,70 @@ +'use strict'; + +module.exports = function(grunt) { + // Project Configuration + grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), + watch: { + options: { + livereload: true, + }, + js: { + files: ['gruntfile.js', 'config.js', 'server.js', 'controllers/**', 'routes/**', 'public/js/**'] + }, + html: { + files: ['views/**', 'public/*.html', 'public/partials/**'] + }, + css: { + files: ['public/css/**'] + }, + sass: { + files: ['public/scss/**'], + tasks: ['sass'], + options: { + livereload: false + } + } + }, + sass: { + dist: { + files: { + 'public/css/style.css': 'public/scss/style.scss' + } + } + }, + nodemon: { + dev: { + script: 'server.js', + options: { + args: [], + ignore: ['public/**'], + ext: 'js', + nodeArgs: ['--debug'], + delayTime: 1, + env: { + PORT: 3105 + }, + cwd: __dirname + } + } + }, + concurrent: { + tasks: ['nodemon', 'watch'], + options: { + logConcurrentOutput: true + } + } + }); + + //Load NPM tasks + grunt.loadNpmTasks('grunt-contrib-watch'); + grunt.loadNpmTasks('grunt-nodemon'); + grunt.loadNpmTasks('grunt-concurrent'); + grunt.loadNpmTasks('grunt-sass'); + + //Making grunt default to force in order not to break the project. + grunt.option('force', true); + + //Default task(s). + grunt.registerTask('default', ['sass', 'concurrent']); +}; \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..f589e72 --- /dev/null +++ b/install.sh @@ -0,0 +1,29 @@ +#/bin/sh + +echo "Installing Cloud9..." +echo "-----------------------" + +git clone https://github.com/ajaxorg/cloud9.git c9 +cd c9 +npm install +npm install -g bower +bower install +cd .. + +echo "Success." +echo "" +echo "Installing Cloud9Hub..." +echo "-----------------------" + +git clone https://github.com/AVGP/cloud9hub.git cloud9hub +cd cloud9hub +npm install +echo "Success." + +echo "Last steps" +echo "-----------------------" +echo "1. Create a Github app." +echo "2. Copy cloud9hub/config.js.example to cloud9hub/config.js" +echo "3. Edit your cloud9hub/config.js" +echo "" +echo "Have a lot of fun!" diff --git a/package.json b/package.json index 1ccd0f7..2264b1d 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,24 @@ "start": "node server.js" }, "dependencies": { + "connect-flash": "^0.1.1", + "consolidate": "^0.10.0", "express": "3.2.6", + "forever": "~0.10.11", + "grunt": "~0.4.4", + "grunt-cli": "~0.1.13", + "grunt-concurrent": "^0.5.0", + "grunt-contrib-watch": "~0.6.1", + "grunt-env": "~0.4.1", + "grunt-nodemon": "~0.2.1", + "grunt-sass": "^0.11.0", + "jade": "*", + "lodash": "^2.4.1", "passport": "0.1.17", "passport-github": "0.1.5", - "jade": "*", + "rimraf": "2.1.4", "stylus": "*", - "rimraf": "2.1.4" + "swig": "^1.3.2", + "view-helpers": "^0.1.4" } -} \ No newline at end of file +} diff --git a/public/fonts/bello_script.eot b/public/fonts/bello_script.eot new file mode 100755 index 0000000..3aae051 Binary files /dev/null and b/public/fonts/bello_script.eot differ diff --git a/public/fonts/bello_script.svg b/public/fonts/bello_script.svg new file mode 100755 index 0000000..78223e9 --- /dev/null +++ b/public/fonts/bello_script.svg @@ -0,0 +1,767 @@ + + +!"#$%&'()*+,-./0123456789:;å<>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_` abcdefghijklmnopqrstuvwxyz|{}~ \ No newline at end of file diff --git a/public/fonts/bello_script.ttf b/public/fonts/bello_script.ttf new file mode 100755 index 0000000..cb5a43e Binary files /dev/null and b/public/fonts/bello_script.ttf differ diff --git a/public/fonts/bello_script.woff b/public/fonts/bello_script.woff new file mode 100755 index 0000000..5e0d0e5 Binary files /dev/null and b/public/fonts/bello_script.woff differ diff --git a/public/images/classy_fabric.png b/public/images/classy_fabric.png new file mode 100644 index 0000000..ca3d267 Binary files /dev/null and b/public/images/classy_fabric.png differ diff --git a/public/images/classy_fabric_@2X.png b/public/images/classy_fabric_@2X.png new file mode 100644 index 0000000..13db1c1 Binary files /dev/null and b/public/images/classy_fabric_@2X.png differ diff --git a/public/images/dark_leather.png b/public/images/dark_leather.png new file mode 100644 index 0000000..3ce4b73 Binary files /dev/null and b/public/images/dark_leather.png differ diff --git a/public/images/dark_leather_@2X.png b/public/images/dark_leather_@2X.png new file mode 100644 index 0000000..a4a8768 Binary files /dev/null and b/public/images/dark_leather_@2X.png differ diff --git a/public/images/debut_dark.png b/public/images/debut_dark.png new file mode 100644 index 0000000..17a4d6b Binary files /dev/null and b/public/images/debut_dark.png differ diff --git a/public/images/debut_dark_@2X.png b/public/images/debut_dark_@2X.png new file mode 100644 index 0000000..13565fb Binary files /dev/null and b/public/images/debut_dark_@2X.png differ diff --git a/public/images/denim.png b/public/images/denim.png new file mode 100644 index 0000000..3b0bb7e Binary files /dev/null and b/public/images/denim.png differ diff --git a/public/images/denim_@2X.png b/public/images/denim_@2X.png new file mode 100644 index 0000000..815a2c7 Binary files /dev/null and b/public/images/denim_@2X.png differ diff --git a/public/index.html b/public/index.html deleted file mode 100644 index a91de8d..0000000 --- a/public/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - Cloud9Hub - - - -
- - - - - - \ No newline at end of file diff --git a/public/javascripts/workspace/workspaceController.js b/public/javascripts/workspace/workspaceController.js deleted file mode 100644 index 30acb87..0000000 --- a/public/javascripts/workspace/workspaceController.js +++ /dev/null @@ -1,52 +0,0 @@ -var WorkspaceCtrl = function($scope, $http, $timeout) { - $scope.workspaces = []; - $scope.currentWorkspace = {name: null, url: '/welcome.html'}; - - $http.get('/workspace') - .success(function(data) { console.log(data); $scope.workspaces = data.workspaces; }) - .error(function(err) { alert(err.msg) }); - - $scope.createWorkspace = function() { - var wsName = window.prompt("Enter workspace name", ""); - $http.post('/workspace/', {name: wsName}) - .success(function(data) { - alert(data.msg); - $scope.workspaces.push({name: wsName}); - }) - .error(function(err) { - alert("Error: " + err.msg); - }); - } - - $scope.deleteWorkspace = function(name) { - if(!window.confirm("Do you really want to delete this workspace? All your files in that workspace will be gone forever!")) return; - $http.delete('/workspace/' + name) - .success(function(data){ - console.log(data); - alert(data.msg); - for(var i=0;i<$scope.workspaces.length;i++) { - if($scope.workspaces[i].name === name) { - $scope.workspaces.splice(i,1); - break; - } - } - }) - .error(function(err) { - console.log("ERR:", err); - }); - } - - $scope.runWorkspace = function(name) { - console.log(name); - $http.get('/workspace/' + name).success(function(data) { - console.log(data); - $timeout(function() { - $scope.currentWorkspace.url = data.url; - }, 2000); - }).error(function(err) { - alert("Error: " + err); - console.log(err); - }); - $scope.currentWorkspace.name = name; - } -} \ No newline at end of file diff --git a/public/javascripts/app.js b/public/js/app.js similarity index 68% rename from public/javascripts/app.js rename to public/js/app.js index e1b6967..74c68e6 100644 --- a/public/javascripts/app.js +++ b/public/js/app.js @@ -1,4 +1,4 @@ -angular.module('c9hub', ['workspace']).config(function($routeProvider) { +angular.module('c9hub', ['workspace', 'ngRoute']).config(function($routeProvider) { $routeProvider.when('/', {templateUrl: "/partials/login.html"}); $routeProvider.when('/dashboard', {controller: WorkspaceCtrl, templateUrl: "/partials/workspace.html"}); }); \ No newline at end of file diff --git a/public/js/workspace/workspaceController.js b/public/js/workspace/workspaceController.js new file mode 100644 index 0000000..bcc46e0 --- /dev/null +++ b/public/js/workspace/workspaceController.js @@ -0,0 +1,94 @@ +var WorkspaceCtrl = function($scope, $http, $timeout, $sce) { + $scope.workspaces = []; + $scope.currentWorkspace = false; // {name: null, url: null, editing: false}; + $scope.loadingWorkspace = false; + $scope.iframeSrc = ''; + + $http.get('/workspace') + .success(function(data) { console.log(data); $scope.workspaces = data.workspaces; }) + .error(function(err) { alert(err.msg) }); + + var _sendKeepAlive = function() { + $http.post('/workspace/' + $scope.currentWorkspace.name + '/keepalive').success(function() { + $timeout(_sendKeepAlive, 300000); + }); + }; + + $scope.startEditing = function() { + $scope.currentWorkspace.editing = true; + $scope.iframeSrc = $sce.trustAsResourceUrl($scope.currentWorkspace.url); + }; + + var createWorkspace = function() { + var wsName = $scope.currentWorkspace.name; + $scope.loadingWorkspace = true; + $http.post('/workspace/', {name: wsName}) + .success(function(data) { + // alert(data.msg); + $scope.loadingWorkspace = false; + $scope.workspaces.push({name: wsName}); + $scope.currentWorkspace = false; + }) + .error(function(err) { + alert("Error: " + err.msg); + }); + } + + $scope.saveWorkspace = function(){ + $scope.iframeSrc = ''; + if(!$scope.currentWorkspace.url){ + createWorkspace(); + } + }; + + $scope.blankWorkspace = function() { + $scope.iframeSrc = ''; + $scope.currentWorkspace = { + url: '', + name: '', + editing: false + } + }; + + $scope.deleteWorkspace = function(name) { + $scope.iframeSrc = ''; + if(!window.confirm("Do you really want to delete this workspace? All your files in that workspace will be gone forever!")) return; + $http.delete('/workspace/' + name) + .success(function(data){ + console.log(data); + alert(data.msg); + + for(var i=0;i<$scope.workspaces.length;i++) { + if($scope.workspaces[i].name === name) { + $scope.workspaces.splice(i,1); + break; + } + } + + $scope.currentWorkspace = false; + }) + .error(function(err) { + console.log("ERR:", err); + $scope.currentWorkspace = false; + }); + } + + $scope.runWorkspace = function(name) { + $scope.iframeSrc = ''; + $scope.loadingWorkspace = true; + $http.get('/workspace/' + name).success(function(data) { + console.log('data', data); + $scope.currentWorkspace = {}; + $scope.currentWorkspace.name = name; + $scope.currentWorkspace.url = data.url; + $scope.currentWorkspace.user = data.user; + $scope.currentWorkspace.editing = false; + _sendKeepAlive(); + $scope.loadingWorkspace = false; + }).error(function(err) { + $scope.loadingWorkspace = false; + alert("Error: " + err); + console.log(err); + }); + } +} \ No newline at end of file diff --git a/public/javascripts/workspace/workspaceModule.js b/public/js/workspace/workspaceModule.js similarity index 100% rename from public/javascripts/workspace/workspaceModule.js rename to public/js/workspace/workspaceModule.js diff --git a/public/partials/login.html b/public/partials/login.html index 84a9f1f..d8fdd92 100644 --- a/public/partials/login.html +++ b/public/partials/login.html @@ -1,4 +1,4 @@ -

Welcome to CloudHub!

-

- Please Log in with Github +

Welcome to Cloud9Hub!

+

+   Sign in with Github

\ No newline at end of file diff --git a/public/partials/workspace.html b/public/partials/workspace.html index 6e25400..cecfe91 100644 --- a/public/partials/workspace.html +++ b/public/partials/workspace.html @@ -1,10 +1,57 @@ - - \ No newline at end of file +
+ +
+
+
+ Welcome to Cloud9Hub.
+ This part of the screen is the editor panel.
+ When you run a workspace, it will be started here. +
+
+ On the left you see the workspace panel.
+ You can create, delete or run workspaces there.
+
+
+ To create a workspace, click the "Create workspace" button.
+ If you want to run a workspace, click on its name. The workspace will start automatically.
+ To delete a workspace, click the little "☠" to the right of a workspace item. +
+
+ +
+
+

New Workspace

+

{{currentWorkspace.user}} / {{currentWorkspace.name}}

+
+
+ Start Editing +
+
+
+ + +
+
+ +
+ +
+ +
+ +
Loading...
+
+
\ No newline at end of file diff --git a/public/scss/style.scss b/public/scss/style.scss new file mode 100644 index 0000000..a0c35d7 --- /dev/null +++ b/public/scss/style.scss @@ -0,0 +1,262 @@ +@font-face { + font-family: 'Bello Script'; + src: url('/fonts/bello_script.eot'); + src: url('/fonts/bello_script.eot?#iefix') format('embedded-opentype'), url('/fonts/bello_script.svg#Bello Script') format('svg'), url('/fonts/bello_script.woff') format('woff'), url('/fonts/bello_script.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +/* + * Element definitions + */ +body { + padding: 0; + // font-family: source-sans-pro, Helvetica, Arial, sans-serif; + height: 100%; + background-color: #ECF0F1; +} + +.container{ + width: 100%; +} + +.dashboard-row{ + height: 100%; +} + +aside{ + padding: 15px 15px 15px 15px; + background: #34495E; + color: #fff; + height: 100%; + + h1{ + font-weight: 300; + text-align:center; + margin-top: 0; + } +} + +.bello{ + font-family: 'Bello Script'; +} + +a { + color: #288edf; +} + +/*button { + background-color: #083; + border: outset 1px #0c8; + color: #fff; + border-radius: 6px; + padding: 2px; +}*/ + +/* + * Class definitions + */ +.active { + font-weight: bold; +} +.block { + margin-bottom: 1.2em; +} +.centered { + text-align: center; +} + +.delete { + color: #eee; + display: inline-block; + float: right; + cursor: pointer; + + i{ + position: relative; + left: 2px; + } + + &:hover i{ + opacity: 1; + } +} + +.frameWrapper { + height: 100%; +} +.hint { + font-style: oblique; + color: #27AE60; +} +.hScope { + height: 100%; +} + +.menu-list { + margin: 0; + padding: 0; + list-style-type: none; +} + +.menu-list li { + font-weight: 300; + font-size: 1em; + padding: 1.5rem 1.5rem; + cursor: pointer; + background: #95A5A6; + border-top: 2px solid #7F8C8D; + + transition: background-color 0.25s ease; + -moz-transition: background-color 0.25s ease; + -webkit-transition: background-color 0.25s ease; + + span{ + font-size: 1em; + } + + i{ + opacity: 0.2; + } + + &:first-child{ + border-top-left-radius: 5px; + border-top-right-radius: 5px; + border-top-width: 0px; + } + + &:last-child{ + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + border-bottom: 5px solid #7F8C8D; + } + + &:hover{ + background: #7F8C8D; + } + + &:hover i{ + opacity: 0.6; + } +} + +.workspace { + border: none; +} + +.workspace-wrapper { + height: 100%; + padding: 0 1em; + background: #FFF; +} + + +.workspace-bar{ + background: #2C3E50; + padding: 1em; + margin: 0em -1em 2em -1em; +} + +.workspace-form-footer{ + padding-top: 1rem; + margin-top: 2rem; + text-align: right; +} + +header{ + position: relative; + margin-top: -1em; + margin-left: -1em; + margin-right: -1em; + padding: 2em 1em; + background-color: #34495E; + + h1 { + font-weight: 100; + margin: 10px 0 0px; + font-size: 2em; + position: relative; + display: inline-block; + padding-right: 10px; + color: #fff; + + span{ + color: #aaa; + } + } +} + +.loading-overlay{ + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + background: rgb(44,62,80); + color: #fff; + font-size: 2em; + font-weight: 300; + text-align: center; + + span{ + position: absolute; + top: 50%; + left: 50%; + margin-left: -73px; + margin-top: -18px; + } +} + +.no-workspace{ + color: #BDC3C7; + padding: 2rem; + border-radius: 10px; + width: 41em; + position: absolute; + left: 50%; + top: 50%; + margin-top: -173px; + margin-left: -370px; + + h1{ + font-weight: normal; + margin: 0 0 1rem 0; + } + + .block{ + font-size: 1em; + } +} + +.login-row{ + position: absolute; + left: 50%; + top: 50%; + margin-top: -125px; + margin-left: -291.5px; +} + +.login-col{ + width: 583px; + height: 171px; +} + +.login-title{ + font-size: 6rem; + font-weight: normal; +} + +.login-button{ + //font-size: 1.25rem; + //padding: 1rem; +} + +.workspace-iframe{ + height: 100%; + padding: 0; + margin: 0 -1em; +} + +.panel{ + padding: 2rem; +} \ No newline at end of file diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css deleted file mode 100644 index 16e5187..0000000 --- a/public/stylesheets/style.css +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Element definitions - */ -body { - box-sizing: border-box; - padding: 0; - font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; - height: 100%; - background-color: #f0f0f0; -} -a { - color: #00b7ff; -} -aside { - float: left; - min-width: 100px; - width: 10%; -} -button { - background-color: #083; - border: outset 1px #0c8; - color: #fff; - border-radius: 6px; - padding: 2px; -} -/* - * Class definitions - */ -.active { - font-weight: bold; -} -.block { - margin-bottom: 1.2em; -} -.delete { - font-weight: bold; - color: #f00; - display: inline-block; - float: right; -} -.hint { - font-style: oblique; - color: #083; -} -.menu-list { - margin: 0; - padding: 0px 0; - list-style-type: none; -} -.menu-list li { - padding: 5px 0px 2px 0px; - border-bottom: 1px dashed #083; -} -.menu-list li:before { - content: "▹"; - padding-right: 4px; -} -.workspace { - position: relative; - border: none; - box-sizing: border-box; - min-width: 600px; - width: 90%; - height: 100%; - min-height: 100%; -} diff --git a/public/stylesheets/style.styl b/public/stylesheets/style.styl deleted file mode 100644 index d6b433d..0000000 --- a/public/stylesheets/style.styl +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Element definitions - */ -body - box-sizing: border-box; - padding: 0 - font: 14px "Lucida Grande", Helvetica, Arial, sans-serif - height: 100% - background-color: rgb(240, 240, 240); - -a - color: #00B7FF - -aside - float: left - min-width: 100px - width: 10% - -button - background-color: #083 - border: outset 1px #0c8 - color: #fff - border-radius: 6px - padding: 2px - -/* - * Class definitions - */ - -.active - font-weight: bold - -.block - margin-bottom: 1.2em - -.delete { - font-weight: bold; - color: red; - display: inline-block; - float: right; -} - -.hint - font-style: oblique - color: #083 - -.menu-list - margin: 0 - padding: 0px 0 - list-style-type: none - -.menu-list li - padding: 5px 0px 2px 0px - border-bottom: 1px dashed #083 -.menu-list li:before - content: "▹" - padding-right: 4px - -.workspace - position: relative - border: none - box-sizing: border-box - min-width: 600px - width: 90% - height: 100% - min-height: 100% diff --git a/routes/index.js b/routes/index.js index f296005..a1eae6f 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,8 +1,8 @@ +'use strict'; -/* - * GET home page. - */ +var index = require('../controllers/index'); +var authorization = require('./middlewares/authorization'); -exports.index = function(req, res){ - res.render('index', { title: 'Express' }); -}; \ No newline at end of file +module.exports = function(app) { + app.get('/', authorization.redirectToLogin, index.index); +} \ No newline at end of file diff --git a/routes/login.js b/routes/login.js new file mode 100644 index 0000000..de843a5 --- /dev/null +++ b/routes/login.js @@ -0,0 +1,8 @@ +'use strict'; + +var login = require('../controllers/login'); +var authorization = require('./middlewares/authorization'); + +module.exports = function(app) { + app.get('/login', login.login); +} \ No newline at end of file diff --git a/routes/middlewares/authorization.js b/routes/middlewares/authorization.js new file mode 100644 index 0000000..3f4c2c9 --- /dev/null +++ b/routes/middlewares/authorization.js @@ -0,0 +1,18 @@ +'use strict'; + +/** + * Generic require login routing middleware + */ +exports.requiresLogin = function(req, res, next) { + if (!req.isAuthenticated()) { + return res.send(401, 'User is not authorized'); + } + next(); +}; + +exports.redirectToLogin = function(req, res, next) { + if (!req.isAuthenticated()) { + return res.redirect('/login'); + } + next(); +} diff --git a/routes/workspace.js b/routes/workspace.js index d09c8f4..4807de5 100644 --- a/routes/workspace.js +++ b/routes/workspace.js @@ -1,115 +1,12 @@ -var fs = require('fs'), - path = require('path'), - rimraf = require('rimraf'), - spawn = require('child_process').spawn; +'use strict'; -var respondInvalidWorkspace = function(res) { - res.status(400); - res.json({msg: "Invalid workspace name"}); -}; +var workspaces = require('../controllers/workspaces'); +var authorization = require('./middlewares/authorization'); -var createWorkspace = function(params, req, res) { - var potentiallyBadPathName = params.name.split(path.sep); - var workspaceName = potentiallyBadPathName[potentiallyBadPathName.length-1]; - - if(workspaceName === '..') { - respondInvalidWorkspace(res); - return; - } - - fs.mkdir(__dirname + '/../workspaces/' + req.user + "/" + workspaceName, '0700', function(err) { - if(err) { - respondInvalidWorkspace(res); - return; - } - - res.json({msg: "Workspace " + workspaceName + " was created."}); - }); -} - -/* - * POST/GET create a new workspace - */ -exports.create = function(req, res) { - if(req.body.name) { - createWorkspace(req.body, req, res); - } else { - respondInvalidWorkspace(res); - } +module.exports = function(app) { + app.get('/workspace', authorization.requiresLogin, workspaces.list); + app.post('/workspace', authorization.requiresLogin, workspaces.create); + app.get('/workspace/:name', authorization.requiresLogin, workspaces.run); + app.post('/workspace/:name/keepalive', authorization.requiresLogin, workspaces.keepAlive); + app.delete('/workspace/:name', authorization.requiresLogin, workspaces.destroy); } - -/* - * GET workspaces listing. - */ -exports.list = function(req, res){ - fs.readdir(__dirname + '/../workspaces/' + req.user, function(err, files) { - if(err) { - res.status(500); - res.json({error: err}); - } else { - var workspaces = []; - for(var i=0; i< files.length; i++) { - // Skip hidden files - if(files[i][0] === '.') continue; - - workspaces.push({name: files[i]}) - } - res.json({workspaces: workspaces}); - } - }); -}; - -/** - * DELETE destroys a workspace - */ -exports.destroy = function(req, res) { - console.log(req.params.name); - - var potentiallyBadPathName = req.params.name.split(path.sep); - var workspaceName = potentiallyBadPathName[potentiallyBadPathName.length-1]; - - if(workspaceName === '..') { - respondInvalidWorkspace(res); - return; - } - - rimraf(__dirname + "/../workspaces/" + req.user + "/" + workspaceName, function(err) { - console.log(err); - if(err) { - res.status("500"); - res.json({msg: "Something went wrong :("}); - return; - } - res.json({msg: "Successfully deleted " + workspaceName}); - }) -}; - -/* - * GET run a workspace - */ - exports.run = function(req, res) { - var potentiallyBadPathName = req.params.name.split(path.sep); - var workspaceName = potentiallyBadPathName[potentiallyBadPathName.length-1]; - - if(workspaceName === '..') { - respondInvalidWorkspace(res); - return; - } - - console.log("Starting " + __dirname + '/../../c9/bin/cloud9.sh for workspace ' + workspaceName + " on port " + req.nextFreePort); - - var workspace = spawn(__dirname + '/../../c9/bin/cloud9.sh', ['-w', __dirname + '/../workspaces/' + req.user + '/' + workspaceName, '-l', '0.0.0.0', '-p', req.nextFreePort], {detached: true}); - workspace.stdout.on('data', function (data) { - console.log('stdout: ' + data); - }); - workspace.stderr.on('data', function (data) { - console.log('stdERR: ' + data); - }); - - setTimeout(function() { - process.kill(-workspace.pid, 'SIGTERM'); - console.log("Killed workspace " + workspaceName); - }, 900000); //Workspaces have a lifetime of 15 minutes - - res.json({msg: "Successfully started workspace", url: req.app.settings.baseUrl + ":" + req.nextFreePort}); - } \ No newline at end of file diff --git a/server.js b/server.js index 7563fe5..44b5ffc 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,3 @@ - /** * Module dependencies. */ @@ -6,64 +5,90 @@ var express = require('express') , routes = require('./routes') , workspace = require('./routes/workspace') + , index = require('./routes/index') , fs = require('fs') , path = require('path') , http = require('http') + , https = require('https') , path = require('path') , passport = require('passport') + , flash = require('connect-flash') + , helpers = require('view-helpers') + , consolidate = require('consolidate') , GithubStrategy = require('passport-github').Strategy; +try { + var config = require(__dirname + '/config.js'); +} catch(e) { + console.error("No config.js found! Copy and edit config.example.js to config.js!"); + process.exit(1); +} +// Load configurations +// Set the node enviornment variable if not set before +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; var app = express(); -var nextFreeWorkspacePort = 5000; +app.set('showStackError', true); +// cache=memory or swig dies in NODE_ENV=production +app.locals.cache = 'memory'; +// Prettify HTML +app.locals.pretty = true; + +app.set('nextFreeWorkspacePort', 5000); + +app.engine('html', consolidate.swig); + +// Start the app by listening on +var port = process.env.PORT || 3105; // all environments -app.set('port', 3000); +app.set('port', port); +app.set('view engine', 'html'); app.set('views', __dirname + '/views'); -app.set('view engine', 'jade'); -app.set('baseUrl', 'http://82.196.2.177'); +app.set('baseUrl', config.BASE_URL); +app.set('runningWorkspaces', {}); //Auth passport.use(new GithubStrategy({ - clientID: '3909fc1ee9f8ed9e87f2', - clientSecret: 'e5b3802ef773d8d32a8b29fe958d04e591be850d', + clientID: config.GITHUB_CLIENT_ID, + clientSecret: config.GITHUB_CLIENT_SECRET, callbackURL: app.get('baseUrl') + ':' + app.get('port') + '/auth/github/callback' }, function(accessToken, refreshToken, profile, done) { - console.log(profile); - if(!fs.existsSync(__dirname + '/workspaces/' + path.basename(profile.id))) { - if(!fs.mkdirSync(__dirname + '/workspaces/' + path.basename(profile.id), '0700')) { - console.log("Okay, shit"); - return done(err, null); + var username = path.basename(profile.username.toLowerCase()); + if(!fs.existsSync(__dirname + '/workspaces/' + path.basename(username))) { + if(config.PERMITTED_USERS !== false && config.PERMITTED_USERS.indexOf(username)) return done('Sorry, not allowed :(', null); + + //Okay, that is slightly unintuitive: fs.mkdirSync returns "undefined", when successful.. + if(fs.mkdirSync(__dirname + '/workspaces/' + path.basename(username), '0700') !== undefined) { + return done("Cannot create user", null); } else { - return done(null, path.basename(profile.id)); + return done(null, username); } } - return done(null, path.basename(profile.id)); + return done(null, username); } )); //Middlewares app.use(express.favicon()); -app.use(express.logger('dev')); +// Only use logger for development environment +if (process.env.NODE_ENV === 'development') { + app.use(express.logger('dev')); +} app.use(express.bodyParser()); app.use(express.methodOverride()); -app.use(express.cookieParser('your secret here')); +app.use(express.cookieParser('cloud9hub secret')); app.use(express.session()); // Initialize Passport! Also use passport.session() middleware, to support // persistent login sessions (recommended). app.use(passport.initialize()); app.use(passport.session()); -app.use(function(req, res, next) { - console.log(req.path); - if(/\/workspace\/.+/.test(req.path)) { - req.nextFreePort = (nextFreeWorkspacePort++); - } - next(); -}); +app.use(flash()); +// Dynamic helpers +app.use(helpers('Cloud9Hub')); app.use(app.router); -app.use(require('stylus').middleware(__dirname + '/public')); app.use(express.static(path.join(__dirname, 'public'))); // development only @@ -83,30 +108,50 @@ app.get('/logout', function(req, res){ req.logout(); res.json('OK'); }); -// API -app.get('/workspace', ensureAuthenticated, workspace.list); -app.post('/workspace', ensureAuthenticated, workspace.create); -app.get('/workspace/:name', ensureAuthenticated, workspace.run); -app.delete('/workspace/:name', ensureAuthenticated, workspace.destroy); -http.createServer(app).listen(app.get('port'), function(){ +// Bootstrap routes +var routes_path = __dirname + '/routes'; +var walk = function(path) { + fs.readdirSync(path).forEach(function(file) { + var newPath = path + '/' + file; + var stat = fs.statSync(newPath); + if (stat.isFile()) { + if (/(.*)\.(js$|coffee$)/.test(file)) { + require(newPath)(app, passport); + } + // We skip the app/routes/middlewares directory as it is meant to be + // used and shared by routes as further middlewares and is not a + // route by itself + } else if (stat.isDirectory() && file !== 'middlewares') { + walk(newPath); + } + }); +}; +walk(routes_path); + +var server; + +if (config.SSL && config.SSL.key && config.SSL.cert) { + var sslOpts = { + key: fs.readFileSync(config.SSL.key), + cert: fs.readFileSync(config.SSL.cert) + }; + + server = https.createServer(sslOpts, app); +} else { + server = http.createServer(app); +} + +server.listen(app.get('port'), function(){ console.log('Express server listening on port ' + app.get('port')); }); //Helpers passport.serializeUser(function(user, done) { - console.log("SERIALIZE"); done(null, user); }); passport.deserializeUser(function(obj, done) { done(null, obj); }); - - -function ensureAuthenticated(req, res, next) { - if (req.isAuthenticated()) { return next(); } - res.status(401); - res.json({msg: "Please login first!"}); -} diff --git a/views/includes/footer.html b/views/includes/footer.html new file mode 100644 index 0000000..5f859e3 --- /dev/null +++ b/views/includes/footer.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + +{% if (process.env.NODE_ENV == 'development') %} + + +{% endif %} \ No newline at end of file diff --git a/views/includes/header.html b/views/includes/header.html new file mode 100644 index 0000000..d122e82 --- /dev/null +++ b/views/includes/header.html @@ -0,0 +1,12 @@ + + Cloud9Hub + + + + + + + + + + \ No newline at end of file diff --git a/views/index.html b/views/index.html new file mode 100644 index 0000000..b03f625 --- /dev/null +++ b/views/index.html @@ -0,0 +1,4 @@ +{% extends 'layouts/default.html' %} +{% block content %} +
+{% endblock %} \ No newline at end of file diff --git a/views/layouts/default.html b/views/layouts/default.html new file mode 100644 index 0000000..a06929a --- /dev/null +++ b/views/layouts/default.html @@ -0,0 +1,8 @@ + + + {% include '../includes/header.html' %} + + {% block content %}{% endblock %} + {% include '../includes/footer.html' %} + + \ No newline at end of file diff --git a/views/login.html b/views/login.html new file mode 100644 index 0000000..1fea85b --- /dev/null +++ b/views/login.html @@ -0,0 +1,11 @@ +{% extends 'layouts/default.html' %} +{% block content %} +
+ +
+{% endblock %}