1
0
mirror of https://github.com/moparisthebest/mail synced 2024-12-21 06:48:50 -05:00

[WO-592] implement webmail using socket.io

* Relax CSP to allow iframe assets to load
* Integrate socket.io proxy
* go to /# when controllers not initiated
* Add offline caching using AppCache manifest
This commit is contained in:
Tankred Hase 2014-08-13 16:16:28 +02:00
parent e29f083c60
commit 487bb31d45
20 changed files with 296 additions and 234 deletions

View File

@ -213,6 +213,20 @@ module.exports = function(grunt) {
}
},
manifest: {
generate: {
options: {
basePath: 'dist/',
timestamp: true,
hash: true,
cache: ['socket.io/socket.io.js'],
master: ['index.html']
},
src: ['**/*.*'],
dest: 'dist/appcache.manifest'
}
},
nodewebkit: {
options: {
version: '0.9.2', // node-webkit version
@ -238,12 +252,13 @@ module.exports = function(grunt) {
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-contrib-compress');
grunt.loadNpmTasks('grunt-node-webkit-builder');
grunt.loadNpmTasks('grunt-manifest');
// Build tasks
grunt.registerTask('dist-npm', ['copy:npm', 'copy:npmDev', 'copy:cryptoLib']);
grunt.registerTask('dist-css', ['sass', 'autoprefixer', 'csso']);
grunt.registerTask('dist-copy', ['copy']);
grunt.registerTask('dist', ['clean', 'dist-npm', 'dist-css', 'dist-copy']);
grunt.registerTask('dist', ['clean', 'dist-npm', 'dist-css', 'dist-copy', 'manifest']);
// Test/Dev tasks
grunt.registerTask('dev', ['connect:dev']);

View File

@ -50,11 +50,21 @@ Then visit [http://localhost:8580/dist/#/desktop?dev=true](http://localhost:8580
grunt watch
## Releasing
## Releasing Chrome App
grunt release-test --release=0.0.0.x
grunt release-stable --release=0.x.0
## Deploying Web App
First build and generate the `dist/` directory:
grunt
Then deploy that directoy by adding it to a local git branch. Push that branch to your node.js server and then start the server:
npm start
## License
Copyright © 2014, Whiteout Networks GmbH. All rights reserved.

12
config/default.js Normal file
View File

@ -0,0 +1,12 @@
'use strict';
module.exports = {
server: {
port: process.env.PORT || 8889,
host: "0.0.0.0"
},
log: {
level: "silly",
http: ':remote-addr [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer"'
}
};

10
config/integration.js Normal file
View File

@ -0,0 +1,10 @@
'use strict';
module.exports = {
server: {
port: 8889
},
log: {
level: "error"
}
};

View File

@ -23,7 +23,6 @@
"node": ">=0.10"
},
"scripts": {
"postinstall": "grunt",
"test": "grunt && grunt test",
"start": "node server.js"
},
@ -39,26 +38,31 @@
"ng-infinite-scroll": "~1.1.2",
"pgpbuilder": "~0.4.0",
"pgpmailer": "~0.4.0",
"requirejs": "~2.1.14"
"requirejs": "~2.1.14",
"config": "^1.0.2",
"morgan": "^1.2.3",
"npmlog": "^0.1.1",
"socket.io": "^1.0.6"
},
"devDependencies": {
"angularjs": "https://github.com/whiteout-io/angular.js/tarball/npm-version",
"browsercrow": "https://github.com/whiteout-io/browsercrow/tarball/master",
"browsersmtp": "https://github.com/whiteout-io/browsersmtp/tarball/master",
"grunt": "~0.4.1",
"mocha": "~1.13.0",
"chai": "~1.7.2",
"sinon": "~1.7.3",
"grunt": "~0.4.1",
"grunt-contrib-clean": "~0.5.0",
"grunt-contrib-copy": "~0.4.1",
"grunt-manifest": "^0.4.0",
"grunt-autoprefixer": "~0.7.2",
"grunt-contrib-compress": "~0.5.2",
"grunt-contrib-connect": "~0.5.0",
"grunt-contrib-jshint": "~0.6.4",
"grunt-mocha": "~0.4.1",
"grunt-contrib-clean": "~0.5.0",
"grunt-csso": "~0.6.1",
"grunt-contrib-sass": "~0.7.3",
"grunt-autoprefixer": "~0.7.2",
"grunt-contrib-watch": "~0.5.3",
"grunt-contrib-copy": "~0.4.1",
"grunt-contrib-compress": "~0.5.2",
"grunt-node-webkit-builder": "~0.1.17"
"grunt-csso": "~0.6.1",
"grunt-mocha": "~0.4.1",
"grunt-node-webkit-builder": "~0.1.17",
"mocha": "~1.13.0",
"sinon": "~1.7.3"
}
}

146
server.js
View File

@ -1,8 +1,70 @@
'use strict';
var express = require('express'),
compression = require('compression'),
app = express();
process.chdir(__dirname);
var cluster = require('cluster');
var config = require('config');
var log = require('npmlog');
log.level = config.log.level;
// Handle error conditions
process.on('SIGTERM', function() {
log.warn('exit', 'Exited on SIGTERM');
process.exit(0);
});
process.on('SIGINT', function() {
log.warn('exit', 'Exited on SIGINT');
process.exit(0);
});
process.on('uncaughtException', function(err) {
log.error('uncaughtException ', err);
process.exit(1);
});
if (cluster.isMaster) {
// MASTER process
cluster.on('fork', function(worker) {
log.info('cluster', 'Forked worker #%s [pid:%s]', worker.id, worker.process.pid);
});
cluster.on('exit', function(worker) {
log.warn('cluster', 'Worker #%s [pid:%s] died', worker.id, worker.process.pid);
setTimeout(function() {
cluster.fork();
}, 1000);
});
// Fork a single worker
cluster.fork();
return;
}
// WORKER process
var express = require('express');
var compression = require('compression');
var app = express();
var server = require('http').Server(app);
var io = require('socket.io')(server);
var net = require('net');
// Setup logger. Stream all http logs to general logger
app.use(require('morgan')(config.log.http, {
'stream': {
'write': function(line) {
if ((line = (line || '').trim())) {
log.http('express', line);
}
}
}
}));
// Do not advertise Express
app.disable('x-powered-by');
//
// web server config
@ -17,7 +79,8 @@ app.use(function(req, res, next) {
// HSTS
res.set('Strict-Transport-Security', 'max-age=16070400; includeSubDomains');
// CSP
res.set('Content-Security-Policy', "default-src 'self'; object-src 'none'; connect-src *; style-src 'self' 'unsafe-inline'; img-src 'self' data:");
var iframe = development ? "http://" + req.hostname + ":" + port : "https://" + req.hostname; // allow iframe to load assets
res.set('Content-Security-Policy', "default-src 'self' " + iframe + "; object-src 'none'; connect-src *; style-src 'self' 'unsafe-inline' " + iframe + "; img-src 'self' data:");
return next();
});
@ -38,11 +101,84 @@ app.use(express.static(__dirname + '/dist', {
maxAge: oneDay
}));
//
// Socket.io proxy
//
io.on('connection', function(socket) {
log.info('io', 'New connection [%s]', socket.conn.id);
var idCounter = 0;
socket.on('open', function(data, fn) {
var socketId = ++idCounter;
var tcp;
log.verbose('io', 'Open request to %s:%s [%s:%s]', data.host, data.port, socket.conn.id, socketId);
tcp = net.connect(data.port, data.host, function() {
log.verbose('io', 'Opened tcp connection to %s:%s [%s:%s]', data.host, data.port, socket.conn.id, socketId);
tcp.on('data', function(chunk) {
log.silly('io', 'Received %s bytes from %s:%s [%s:%s]', chunk.length, data.host, data.port, socket.conn.id, socketId);
socket.emit('data-' + socketId, chunk);
});
tcp.on('error', function(err) {
log.verbose('io', 'Error for %s:%s [%s:%s]: %s', data.host, data.port, socket.conn.id, socketId, err.message);
socket.emit('error-' + socketId, err.message);
});
tcp.on('end', function() {
socket.emit('end-' + socketId);
});
tcp.on('close', function() {
log.verbose('io', 'Closed tcp connection to %s:%s [%s:%s]', data.host, data.port, socket.conn.id, socketId);
socket.emit('close-' + socketId);
socket.removeAllListeners('data-' + socketId);
socket.removeAllListeners('end-' + socketId);
});
socket.on('data-' + socketId, function(chunk, fn) {
if (!chunk || !chunk.length) {
if (typeof fn === 'function') {
fn();
}
return;
}
log.silly('io', 'Sending %s bytes to %s:%s [%s:%s]', chunk.length, data.host, data.port, socket.conn.id, socketId);
tcp.write(chunk, function() {
if (typeof fn === 'function') {
fn();
}
});
});
socket.on('end-' + socketId, function() {
log.verbose('io', 'Received request to close connection to %s:%s [%s:%s]', data.host, data.port, socket.conn.id, socketId);
tcp.end();
});
if (typeof fn === 'function') {
fn(socketId);
}
});
});
socket.on('disconnect', function() {
log.info('io', 'Closed connection [%s]', socket.conn.id);
socket.removeAllListeners();
});
});
//
// start server
//
app.listen(port);
server.listen(port);
if (development) {
console.log(' > starting in development mode');
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html ng-app="mail" ng-csp>
<html ng-app="mail" ng-csp manifest="appcache.manifest">
<head>
<meta charset="utf-8">
<title>Whiteout Mail</title>
@ -29,6 +29,7 @@
<link rel="stylesheet" media="all" href="css/all.min.css" type="text/css">
<!-- The Scripts -->
<script src="socket.io/socket.io.js"></script>
<script src="lib/require.js"></script>
<script src="require-config.js"></script>
<script src="js/app.js"></script>

View File

@ -3,7 +3,12 @@ define(function(require) {
var appCtrl = require('js/app-controller');
var AddAccountCtrl = function($scope, $location) {
var AddAccountCtrl = function($scope, $location, $routeParams) {
if (!appCtrl._auth && !$routeParams.dev) {
$location.path('/'); // init app
return;
}
$scope.connectToGoogle = function() {
// test for oauth support
if (appCtrl._auth._oauth.isSupported()) {

View File

@ -3,7 +3,12 @@ define(function(require) {
var appController = require('js/app-controller');
var LoginExistingCtrl = function($scope, $location) {
var LoginExistingCtrl = function($scope, $location, $routeParams) {
if (!appController._emailDao && !$routeParams.dev) {
$location.path('/'); // init app
return;
}
var emailDao = appController._emailDao;
$scope.buttonEnabled = true;

View File

@ -3,7 +3,12 @@ define(function(require) {
var appController = require('js/app-controller');
var LoginInitialCtrl = function($scope, $location) {
var LoginInitialCtrl = function($scope, $location, $routeParams) {
if (!appController._emailDao && !$routeParams.dev) {
$location.path('/'); // init app
return;
}
var emailDao = appController._emailDao,
states, termsMsg = 'You must accept the Terms of Service to continue.';

View File

@ -4,7 +4,12 @@ define(function(require) {
var angular = require('angular'),
appController = require('js/app-controller');
var LoginExistingCtrl = function($scope, $location) {
var LoginExistingCtrl = function($scope, $location, $routeParams) {
if (!appController._emailDao && !$routeParams.dev) {
$location.path('/'); // init app
return;
}
var emailDao = appController._emailDao,
pgp = appController._pgp;

View File

@ -3,7 +3,12 @@ define(function(require) {
var appController = require('js/app-controller');
var LoginPrivateKeyDownloadCtrl = function($scope, $location) {
var LoginPrivateKeyDownloadCtrl = function($scope, $location, $routeParams) {
if (!appController._emailDao && !$routeParams.dev) {
$location.path('/'); // init app
return;
}
var keychain = appController._keychain,
emailDao = appController._emailDao,
userId = emailDao._account.emailAddress;

View File

@ -10,7 +10,12 @@ define(function(require) {
ImapClient = require('imap-client'),
SmtpClient = require('smtpclient');
var SetCredentialsCtrl = function($scope, $location) {
var SetCredentialsCtrl = function($scope, $location, $routeParams) {
if (!appCtrl._emailDao && !$routeParams.dev) {
$location.path('/'); // init app
return;
}
var auth = appCtrl._auth;
var provider;

View File

@ -13,7 +13,12 @@ define(function(require) {
// Controller
//
var NavigationCtrl = function($scope, $routeParams) {
var NavigationCtrl = function($scope, $routeParams, $location) {
if (!appController._emailDao && !$routeParams.dev) {
$location.path('/'); // init app
return;
}
emailDao = appController._emailDao;
outboxBo = appController._outboxBo;

View File

@ -9,22 +9,13 @@ define(function(require) {
appController = require('js/app-controller');
describe('Add Account Controller unit test', function() {
var scope, location, ctrl, authStub;
var scope, location, ctrl, authStub, origAuth;
describe('connectToGoogle', function() {
var origAuth;
beforeEach(function() {
// remember original module to restore later, then replace it
origAuth = appController._auth;
appController._auth = authStub = sinon.createStubInstance(Auth);
});
afterEach(function() {
// restore the app controller module
appController._auth = origAuth;
});
it('should forward to login', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
@ -38,10 +29,24 @@ define(function(require) {
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
$scope: scope,
$routeParams: {}
});
});
});
afterEach(function() {
// restore the app controller module
appController._auth = origAuth;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
describe('connectToGoogle', function() {
it('should forward to login', function() {
authStub._oauth = {
isSupported: function() {
return true;
@ -57,30 +62,9 @@ define(function(require) {
provider: 'gmail'
})).to.be.true;
expect(authStub.getOAuthToken.calledOnce).to.be.true;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
it('should not use oauth for gmail', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
authStub._oauth = {
isSupported: function() {
return false;
@ -94,30 +78,9 @@ define(function(require) {
provider: 'gmail'
})).to.be.true;
expect(authStub.getOAuthToken.called).to.be.false;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
it('should not forward to login when oauth fails', function(done) {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
authStub._oauth = {
isSupported: function() {
return true;
@ -131,10 +94,6 @@ define(function(require) {
expect(location.path.called).to.be.false;
expect(location.search.called).to.be.false;
location.path.restore();
location.search.restore();
scope.$apply.restore();
done();
};
@ -144,193 +103,67 @@ define(function(require) {
describe('connectToYahoo', function() {
it('should forward to login', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
scope.connectToYahoo();
expect(location.path.calledWith('/login-set-credentials')).to.be.true;
expect(location.search.calledWith({
provider: 'yahoo'
})).to.be.true;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
});
describe('connectToTonline', function() {
it('should forward to login', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
scope.connectToTonline();
expect(location.path.calledWith('/login-set-credentials')).to.be.true;
expect(location.search.calledWith({
provider: 'tonline'
})).to.be.true;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
});
describe('connectToOutlook', function() {
it('should forward to login', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
scope.connectToOutlook();
expect(location.path.calledWith('/login-set-credentials')).to.be.true;
expect(location.search.calledWith({
provider: 'outlook'
})).to.be.true;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
});
describe('connectToGmx', function() {
it('should forward to login', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
scope.connectToGmx();
expect(location.path.calledWith('/login-set-credentials')).to.be.true;
expect(location.search.calledWith({
provider: 'gmx'
})).to.be.true;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
});
describe('connectToWebde', function() {
it('should forward to login', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
scope.connectToWebde();
expect(location.path.calledWith('/login-set-credentials')).to.be.true;
expect(location.search.calledWith({
provider: 'webde'
})).to.be.true;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
});
describe('connectOther', function() {
it('should forward to login', function() {
angular.module('addaccounttest', []);
mocks.module('addaccounttest');
mocks.inject(function($controller, $rootScope, $location) {
location = $location;
scope = $rootScope.$new();
scope.state = {};
sinon.stub(location, 'path').returns(location);
sinon.stub(location, 'search').returns(location);
sinon.stub(scope, '$apply', function() {});
ctrl = $controller(AddAccountCtrl, {
$location: location,
$scope: scope
});
});
scope.connectOther();
expect(location.path.calledWith('/login-set-credentials')).to.be.true;
expect(location.search.calledWith({
provider: 'custom'
})).to.be.true;
location.path.restore();
location.search.restore();
scope.$apply.restore();
});
});
});

View File

@ -39,7 +39,8 @@ define(function(require) {
scope = $rootScope.$new();
scope.state = {};
ctrl = $controller(LoginExistingCtrl, {
$scope: scope
$scope: scope,
$routeParams: {}
});
});
});

View File

@ -43,7 +43,8 @@ define(function(require) {
ui: {}
};
ctrl = $controller(LoginInitialCtrl, {
$scope: scope
$scope: scope,
$routeParams: {}
});
});
});

View File

@ -41,7 +41,8 @@ define(function(require) {
ui: {}
};
ctrl = $controller(LoginNewDeviceCtrl, {
$scope: scope
$scope: scope,
$routeParams: {}
});
});
});

View File

@ -38,7 +38,8 @@ define(function(require) {
scope.state = {};
ctrl = $controller(LoginPrivateKeyDownloadCtrl, {
$location: location,
$scope: scope
$scope: scope,
$routeParams: {}
});
done();
});
@ -251,7 +252,8 @@ define(function(require) {
scope.state = {};
ctrl = $controller(LoginPrivateKeyDownloadCtrl, {
$location: location,
$scope: scope
$scope: scope,
$routeParams: {}
});
});

View File

@ -33,7 +33,8 @@ define(function(require) {
scope.state = {};
setCredentialsCtrl = $controller(SetCredentialsCtrl, {
$scope: scope
$scope: scope,
$routeParams: {}
});
});
});