diff --git a/bower.json b/bower.json index ecb34a89..2a90581e 100644 --- a/bower.json +++ b/bower.json @@ -21,7 +21,8 @@ "requirejs": "~2.1.11", "stf-graphics": "git@ghe.amb.ca.local:stf/stf-graphics.git", "angular-bootstrap": "~0.10.0", - "angular-dialog-service": "~3.1.0" + "angular-dialog-service": "~3.1.0", + "ng-file-upload": "~1.2.9" }, "private": true, "resolutions": { diff --git a/lib/cli.js b/lib/cli.js index ce3748c1..f5c8ccfa 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -374,6 +374,9 @@ program .option('-a, --auth-url ' , 'URL to auth client' , String) + .option('-r, --storage-url ' + , 'URL to storage client' + , String) .option('-u, --connect-sub ' , 'sub endpoint' , cliutil.list) @@ -389,6 +392,9 @@ program if (!options.authUrl) { this.missingArgument('--auth-url') } + if (!options.storageUrl) { + this.missingArgument('--storage-url') + } if (!options.connectSub) { this.missingArgument('--connect-sub') } @@ -401,6 +407,7 @@ program , secret: options.secret , ssid: options.ssid , authUrl: options.authUrl + , storageUrl: options.storageUrl , groupTimeout: 600 * 1000 , endpoints: { sub: options.connectSub @@ -410,6 +417,24 @@ program }) }) +program + .command('storage-temp') + .description('start temp storage') + .option('-p, --port ' + , 'port (or $PORT)' + , Number + , process.env.PORT || 7100) + .option('--public-ip ' + , 'public ip for global access' + , String + , ip()) + .action(function(options) { + require('./roles/storage/temp')({ + port: options.port + , publicIp: options.publicIp + }) + }) + program .command('local [serial..]') .description('start everything locally') @@ -449,6 +474,10 @@ program , 'app port' , Number , 7100) + .option('--storage-port ' + , 'storage port' + , Number + , 7102) .option('--provider ' , 'provider name (or os.hostname())' , String @@ -518,6 +547,8 @@ program , '--port', options.appPort , '--secret', options.authSecret , '--auth-url', util.format('http://localhost:%d/', options.authPort) + , '--storage-url' + , util.format('http://localhost:%d/', options.storagePort) , '--connect-sub', options.bindAppPub , '--connect-push', options.bindAppPull ].concat((function() { @@ -527,6 +558,12 @@ program } return extra })())) + + // storage + , procutil.fork(__filename, [ + 'storage-temp' + , '--port', options.storagePort + ]) ] function shutdown() { diff --git a/lib/roles/app.js b/lib/roles/app.js index 925938d4..b5cfa09f 100644 --- a/lib/roles/app.js +++ b/lib/roles/app.js @@ -7,6 +7,7 @@ var validator = require('express-validator') var socketio = require('socket.io') var zmq = require('zmq') var Promise = require('bluebird') +var httpProxy = require('http-proxy') var _ = require('lodash') var logger = require('../util/logger') @@ -20,14 +21,17 @@ var datautil = require('../util/datautil') var auth = require('../middleware/auth') var webpack = require('../middleware/webpack') -var cors = require('cors') - module.exports = function(options) { var log = logger.createLogger('app') , app = express() , server = http.createServer(app) , io = socketio.listen(server) , channelRouter = new events.EventEmitter() + , proxy = httpProxy.createProxyServer() + + proxy.on('error', function(err) { + log.error('Proxy had an error', err.stack) + }) app.set('view engine', 'jade') app.set('views', pathutil.resource('app/views')) @@ -38,8 +42,6 @@ module.exports = function(options) { io.set('log level', 1) io.set('browser client', false) - app.use(cors()) - app.use('/static/bower_components', express.static(pathutil.resource('bower_components'))) app.use('/static/data', express.static(pathutil.resource('data'))) @@ -181,6 +183,18 @@ module.exports = function(options) { }) }) + app.post('/api/v1/resources', function(req, res) { + proxy.web(req, res, { + target: options.storageUrl + }) + }) + + app.get('/api/v1/resources/:id', function(req, res) { + proxy.web(req, res, { + target: options.storageUrl + }) + }) + io.set('authorization', (function() { var parse = Promise.promisify(express.cookieParser(options.secret)) return function(handshake, accept) { @@ -387,6 +401,16 @@ module.exports = function(options) { , wireutil.envelope(new wire.ShellKeepAliveMessage(data)) ]) }) + .on('device.install', function(channel, responseChannel, data) { + joinChannel(responseChannel) + push.send([ + channel + , wireutil.transaction( + responseChannel + , new wire.InstallMessage(data) + ) + ]) + }) }) .finally(function() { // Clean up all listeners and subscriptions diff --git a/lib/roles/device.js b/lib/roles/device.js index 74f9beb0..3ad21d08 100644 --- a/lib/roles/device.js +++ b/lib/roles/device.js @@ -21,6 +21,7 @@ module.exports = function(options) { .dependency(require('./device/plugins/logcat')) .dependency(require('./device/plugins/shell')) .dependency(require('./device/plugins/touch')) + .dependency(require('./device/plugins/install')) .dependency(require('./device/plugins/owner')) .define(function(options, solo) { if (process.send) { diff --git a/lib/roles/device/plugins/install.js b/lib/roles/device/plugins/install.js new file mode 100644 index 00000000..9b0ba19b --- /dev/null +++ b/lib/roles/device/plugins/install.js @@ -0,0 +1,46 @@ +var stream = require('stream') + +var syrup = require('syrup') +var request = require('request') + +var logger = require('../../../util/logger') +var wire = require('../../../wire') +var wireutil = require('../../../wire/util') + +module.exports = syrup.serial() + .dependency(require('../support/adb')) + .dependency(require('../support/router')) + .dependency(require('../support/push')) + .define(function(options, adb, router, push) { + var log = logger.createLogger('device:plugins:install') + + router.on(wire.InstallMessage, function(channel, message) { + log.info('Installing "%s"', message.url) + var source = new stream.Readable().wrap(request(message.url)) + var seq = 0 + adb.install(options.serial, source) + .then(function() { + log.info('Installed "%s"', message.url) + push.send([ + channel + , wireutil.envelope(new wire.TransactionDoneMessage( + options.serial + , seq++ + , true + )) + ]) + }) + .catch(function(err) { + log.error('Installation failed', err.stack) + push.send([ + channel + , wireutil.envelope(new wire.TransactionDoneMessage( + options.serial + , seq++ + , false + , err.message + )) + ]) + }) + }) + }) diff --git a/lib/roles/storage/temp.js b/lib/roles/storage/temp.js new file mode 100644 index 00000000..0e70106c --- /dev/null +++ b/lib/roles/storage/temp.js @@ -0,0 +1,71 @@ +var http = require('http') +var util = require('util') + +var express = require('express') +var formidable = require('formidable') +var Promise = require('bluebird') + +var logger = require('../../util/logger') +var Storage = require('../../util/storage') + +module.exports = function(options) { + var log = logger.createLogger('storage-temp') + , app = express() + , server = http.createServer(app) + , storage = new Storage() + + app.set('strict routing', true) + app.set('case sensitive routing', true) + app.set('trust proxy', true) + + app.use(express.json()) + app.use(express.urlencoded()) + + storage.on('timeout', function(id) { + log.info('Cleaning up inactive resource "%s"', id) + }) + + app.post('/api/v1/resources', function(req, res) { + var form = Promise.promisifyAll(new formidable.IncomingForm()) + form.parseAsync(req) + .spread(function(fields, files) { + if (files.file) { + var id = storage.store(files.file) + res.json(201, { + success: true + , url: util.format( + 'http://%s:%s/api/v1/resources/%s' + , options.publicIp + , options.port + , id + ) + }) + } + else { + res.json(400, { + success: false + }) + } + }) + .catch(function(err) { + log.error('Failed to save resource: ', err.stack) + res.json(500, { + success: false + }) + }) + }) + + app.get('/api/v1/resources/:id', function(req, res) { + var file = storage.retrieve(req.params.id) + if (file) { + res.set('Content-Type', file.type) + res.sendfile(file.path) + } + else { + res.send(404) + } + }) + + server.listen(options.port) + log.info('Listening on port %d', options.port) +} diff --git a/lib/util/storage.js b/lib/util/storage.js new file mode 100644 index 00000000..3ea23ebf --- /dev/null +++ b/lib/util/storage.js @@ -0,0 +1,62 @@ +var events = require('events') +var util = require('util') +var fs = require('fs') + +var uuid = require('node-uuid') + +function Storage() { + events.EventEmitter.call(this) + this.files = Object.create(null) + this.timer = setInterval(this.check.bind(this), 60000) +} + +util.inherits(Storage, events.EventEmitter) + +Storage.prototype.store = function(file) { + var id = uuid.v4() + + this.files[id] = { + timeout: 600000 + , lastActivity: Date.now() + , data: file + } + + return id +} + +Storage.prototype.remove = function(id) { + var file = this.files[id] + if (file) { + delete this.files[id] + fs.unlink(file.data.path, function() {}) + } +} + +Storage.prototype.retrieve = function(id) { + var file = this.files[id] + if (file) { + file.lastActivity = Date.now() + return file.data + } + return null +} + +Storage.prototype.check = function() { + var now = Date.now() + + Object.keys(this.files).forEach(function(id) { + var file = this.files[id] + , inactivePeriod = now - file.lastActivity + + if (inactivePeriod >= file.timeout) { + this.remove(id) + this.emit('timeout', id, file.data) + } + }, this) +} + +Storage.prototype.stop = function() { + clearInterval(this.timer) +} + +module.exports = Storage diff --git a/lib/wire/wire.proto b/lib/wire/wire.proto index f430ca50..b82ef62f 100644 --- a/lib/wire/wire.proto +++ b/lib/wire/wire.proto @@ -12,6 +12,7 @@ enum MessageType { DeviceRegisteredMessage = 8; DeviceStatusMessage = 9; GroupMessage = 10; + InstallMessage = 30; PhysicalIdentifyMessage = 29; JoinGroupMessage = 11; KeyDownMessage = 12; @@ -289,3 +290,7 @@ message ShellCommandMessage { message ShellKeepAliveMessage { required uint32 timeout = 1; } + +message InstallMessage { + required string url = 1; +} diff --git a/res/app/app.js b/res/app/app.js index 1f8fee60..caed8e75 100644 --- a/res/app/app.js +++ b/res/app/app.js @@ -2,10 +2,12 @@ require('angular') require('angular-route') require('angular-gettext') +require('ng-file-upload') angular.module('app', [ 'ngRoute', 'gettext', + 'angularFileUpload', require('./layout').name, require('./device-list').name, require('./device-control').name, diff --git a/res/app/components/stf/control/control-service.js b/res/app/components/stf/control/control-service.js index e1145d2d..a10a159e 100644 --- a/res/app/components/stf/control/control-service.js +++ b/res/app/components/stf/control/control-service.js @@ -1,4 +1,4 @@ -module.exports = function ControlServiceFactory($rootScope, socket, TransactionService) { +module.exports = function ControlServiceFactory($rootScope, $upload, socket, TransactionService) { var controlService = { } @@ -87,6 +87,28 @@ module.exports = function ControlServiceFactory($rootScope, socket, TransactionS socket.emit('device.identify', channel, tx.channel) return tx } + + this.install = function(files) { + return $upload.upload({ + url: '/api/v1/resources' + , method: 'POST' + , file: files[0] + }) + .success(function(data) { + console.log('success', arguments) + var tx = TransactionService.create(devices) + socket.emit('device.install', channel, tx.channel, { + url: data.url + }) + return tx + }) + .error(function() { + console.log('error', arguments) + }) + .progress(function() { + console.log('progress', arguments) + }) + } } controlService.forOne = function(device, channel) { diff --git a/res/app/device-control/device-control.jade b/res/app/device-control/device-control.jade index 3f59ee54..e87e56a9 100644 --- a/res/app/device-control/device-control.jade +++ b/res/app/device-control/device-control.jade @@ -3,7 +3,7 @@ h1 {{ device.serial }} {{ device.present ? 'present' : 'absent' }} button(ng-click='showScreen = !showScreen') Show/Hide button(ng-click='control.identify()') Identify -div(ng-controller='DeviceScreenCtrl') +div(ng-controller='DeviceScreenCtrl', ng-file-drop='control.install($files)') device-screen(style='width: 400px; height: 600px; background: gray') button(ng-click='control.menu()') Menu diff --git a/webpack.config.js b/webpack.config.js index 990f5936..f0cc8be6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,7 +20,8 @@ module.exports = { 'angular-bootstrap': 'angular-bootstrap/ui-bootstrap-tpls', 'localforage': 'localforage/dist/localforage.js', 'socket.io': 'socket.io-client/dist/socket.io', - 'oboe': 'oboe/dist/oboe-browser' + 'oboe': 'oboe/dist/oboe-browser', + 'ng-file-upload': 'ng-file-upload/angular-file-upload' } }, module: {