ares-ide
Version:
A browser-based code editor and UI designer for Enyo 2 projects
498 lines (459 loc) • 14.9 kB
JavaScript
/*jshint node: true, strict: false, globalstrict: false */
/**
* PhoneGap build service
*/
// nodejs version checking is done in parent process ide.js
var fs = require("graceful-fs"),
path = require("path"),
express = require("express"),
util = require("util"),
log = require('npmlog'),
temp = require("temp"),
request = require('request'),
async = require("async"),
http = require("http"),
client = require("phonegap-build-api"),
copyFile = require('./lib/copyFile'),
BdBase = require("./lib/bdBase"),
HttpError = require("./lib/httpError");
var basename = path.basename(__filename, '.js');
log.heading = basename;
var PGB_URL = 'https://build.phonegap.com',
PGB_TIMEOUT = 7000;
function BdPhoneGap(config, next) {
config.pathname = config.pathname || '/phonegap';
config.minifyScript = config.minifyScript || path.join(config.enyoDir, 'tools', 'deploy.js');
try {
var stat = fs.statSync(config.minifyScript);
if (!stat.isFile()) {
throw "Not a file";
}
} catch(e) {
// Build a more usable exception
setImmediate(next, new Error("Not a suitable Enyo: it does not contain a usable 'tools/deploy.js'"));
}
BdBase.call(this, config, next);
log.verbose('BdPhoneGap()', "config:", this.config);
}
util.inherits(BdPhoneGap, BdBase);
BdPhoneGap.prototype.use = function() {
BdBase.prototype.use.call(this);
log.verbose('BdPhoneGap#use()');
this.app.use(express.cookieParser());
this.app.use(this.makeExpressRoute('/op'), authorize.bind(this));
this.app.use(this.makeExpressRoute('/api'), authorize.bind(this));
function authorize(req, res, next) {
log.verbose("BdPhoneGap#authorize()", "req.url:", req.url);
log.verbose("BdPhoneGap#authorize()", "req.query:", req.query);
log.verbose("BdPhoneGap#authorize()", "req.cookies:", req.cookies);
var token = req.cookies.token || req.param('token');
if (token) {
req.token = token;
setImmediate(next);
} else {
setImmediate(next, new HttpError('Missing authentication token', 401));
}
}
};
BdPhoneGap.prototype.route = function() {
BdBase.prototype.route.call(this);
log.verbose('BdPhoneGap#route()');
// '/token' & '/api' -- Wrapped public Phonegap API
this.app.post(this.makeExpressRoute('/token'), this.getToken.bind(this));
this.app.get(this.makeExpressRoute('/api/v1/me'), this.getUserData.bind(this));
this.app.get(this.makeExpressRoute('/api/v1/apps/:appId'), this.getAppStatus.bind(this));
this.app.get(this.makeExpressRoute('/api/v1/apps/:appId/:platform/:title/:version'), this.downloadApp.bind(this));
};
// it is not possible to reduce the number of parameters of
// this function, otherwise is not recognized as the error-handler by
// express... (jshint)
BdPhoneGap.prototype.errorHandler = function(err, req, res, next){
var self = this;
log.info("BdPhoneGap#errorHandler()", "err:", err);
if (err instanceof HttpError) {
_respond(err);
} else if (err instanceof Error) {
var msg;
try {
msg = JSON.parse(err.message).error;
} catch(e) {
msg = err.message;
}
if (("Invalid authentication token." === msg) ||
("Missing authentication token" === msg)){
_respond(new HttpError(msg, 401));
} else {
_respond(new HttpError(msg, 500));
}
} else {
_respond(new Error(err.toString()));
}
function _respond(err) {
log.warn("BdPhoneGap#errorHandler#_respond():", err.stack);
var statusCode = (err && err.statusCode) || 500;
res.status(statusCode);
if (statusCode === 401) {
// invalidate token cookie
self.setCookie(res, 'token', null);
}
if (err.contentType) {
res.contentType(err.contentType);
res.send(err.message);
} else {
res.contentType('txt'); // direct usage of 'text/plain' does not work
res.send(err.toString());
}
}
};
BdPhoneGap.prototype.getToken = function(req, res, next) {
// XXX !!! leave this log commented-out to not log password !!!
//log.silly("BdPhoneGap#getToken()", "req.body:", req.body);
var timeout = req.param('timeout') || this.config.timeout || PGB_TIMEOUT;
var auth, options;
auth = "Basic " + new Buffer(req.body.username + ':' +req.body.password).toString("base64");
options = {
url : PGB_URL + "/token",
headers : { "Authorization" : auth },
proxy: this.config.proxyUrl,
timeout: timeout
};
log.http("BdPhoneGap#getToken()", "POST /token");
request.post(options, (function(err1, response, body) {
try {
var statusCode = (response && response.statusCode) || 0;
log.verbose("BdPhoneGap#getToken()", "statusCode:", statusCode);
if (err1 || statusCode != 200) {
var msg = (err1 && err1.toString()) || http.STATUS_CODES[statusCode] || "Error";
log.warn("BdPhoneGap#getToken()", msg);
setImmediate(next, new HttpError(msg, statusCode));
} else {
log.verbose("BdPhoneGap#getToken()", "response body:", body);
var data = JSON.parse(body);
if (data.error) {
setImmediate(next, new HttpError(data.error, 401));
} else {
this.setCookie(res, 'token', data.token);
res.status(200).send(data).end();
}
}
} catch(err0) {
setImmediate(next, err0);
}
}).bind(this));
};
BdPhoneGap.prototype.getUserData = function(req, res, next) {
var timeout = req.param('timeout') || this.config.timeout || PGB_TIMEOUT;
client.auth({
token: req.token,
proxy: this.config.proxyUrl
}, function(err1, api) {
if (err1) {
setImmediate(next, err1);
} else {
log.http("BdPhoneGap#getUserData()", "GET /apps/me");
api.get('/me', {
timeout: timeout
}, function(err2, userData) {
if (err2) {
setImmediate(next, err2);
} else {
log.info("BdPhoneGap#getUserData()", "userData:", userData);
res.status(200).send({user: userData}).end();
}
});
}
});
};
BdPhoneGap.prototype.getAppStatus = function(req, res, next) {
var timeout = req.param('timeout') || this.config.timeout || PGB_TIMEOUT;
client.auth({
token: req.token,
proxy: this.config.proxyUrl
}, function(err1, api) {
if (err1) {
setImmediate(next, err1);
} else {
var appId = req.params.appId;
log.http("BdPhoneGap#getAppStatus()", "GET /apps/" + appId);
api.get('/apps/' + appId, {
timeout: timeout
}, function(err2, userData) {
if (err2) {
setImmediate(next, err2);
} else {
log.info("BdPhoneGap#getAppStatus()", "appStatus:", userData);
res.status(200).send({user: userData}).end();
}
});
}
});
};
/**
* When a download request is received from "Build.js",
* this function is called to do the following actions :
* - Create the appropriate file name
* - Download the built project from Phonegap build using the
* API-Phonegap-Build
* - When the download is done, the file is piped to "Ares client"
* using a multipart/form Post request
*
* @param {Object} req Contain the request attributes
* @param {Object} res Contain the response attributes
* @param {Function} next a CommonJs callback
*
*/
BdPhoneGap.prototype.downloadApp = function(req, res, next){
var timeout = req.param('timeout') || this.config.timeout || PGB_TIMEOUT;
var appId = req.param("appId"),
platform = req.param("platform"),
title = req.param("title"),
version = req.param("version");
log.info("BdPhoneGap#downloadApp()", "appId:", appId, "platform:", platform, "version:", version, "(" + title + ")");
var extensions = {
"android": "apk",
"ios": "ipa",
"webos": "ipk",
"symbian": "wgz",
"winphone": "xap",
"blackberry": "jad"
};
var fileName = title + "_" + version + "." + (extensions[platform] || "bin"),
url = "/apps/" + appId + "/" + platform;
log.info("BdPhoneGap#downloadApp()", "packageName:", fileName, "<<< url:", url);
/* FIXME: broken streams on node-0.8.x
async.waterfall([
client.auth.bind(client, {
token: req.token,
proxy: this.config.proxyUrl
}),
(function _pipeFormData(api, next) {
log.http("BdPhoneGap#downloadApp#_pipeFormData()", "GET", url);
var stream = api.get(url, { timeout: timeout });
stream.pause();
this.returnFormData([{
name: fileName,
stream: stream
}], res, next);
}).bind(this)
], function(err) {
if (err) {
setImmediate(next, err);
return;
}
log.verbose("BdPhoneGap#downloadApp()", "completed");
// do not call next() here as the HTTP header was
// already sent back.
});
*/
var tempFileName = temp.path({prefix: 'com.enyojs.ares.services.' + this.config.basename + "." + platform + "."});
async.waterfall([
client.auth.bind(client, {
token: req.token,
proxy: this.config.proxyUrl
}),
function _fetchPackage(api, next) {
log.http("BdPhoneGap#downloadApp#_fetchPackage()", "GET", url);
var os = fs.createWriteStream(tempFileName);
// FIXME: node-0.8 has no 'finish' event...
os.on('close', next);
api.get(url, { timeout: timeout*3 }).pipe(os);
},
// FIXME: broken streams on node-0.8.x: we need to
// load packages in memory Buffer...
fs.readFile.bind(fs, tempFileName),
(function _returnFormData(buffer, next) {
fs.unlink(tempFileName);
log.http("BdPhoneGap#downloadApp#_returnFormData()", "streaming down:", fileName);
this.returnFormData([{
name: fileName,
buffer: buffer
}], res, next);
}).bind(this)
], function(err) {
if (err) {
setImmediate(next, err);
return;
}
// FIXME: this is never called, as neither
// CombinedStream nor express#res emit an 'end' when
// streaming is over...
log.verbose("BdPhoneGap#downloadApp()", "completed");
// do not call next() here as the HTTP header was
// already sent back.
});
};
BdPhoneGap.prototype.build = function(req, res, next) {
var appData = {}, query = req.query;
log.info("BdPhoneGap#build()", "title:", query.title,"platforms:", query.platforms, ", appId:", query.appId);
var timeout = req.param('timeout') || this.config.timeout || PGB_TIMEOUT;
async.series([
this.prepare.bind(this, req, res),
this.store.bind(this, req, res),
_parse.bind(this),
this.minify.bind(this, req, res),
_postMinify.bind(this, req),
this.zip.bind(this, req, res),
_upload.bind(this),
this.returnBody.bind(this, req, res),
this.cleanSession.bind(this, req, res)
], function (err) {
if (err) {
// run express's next() : the errorHandler (which calls cleanSession)
setImmediate(next, err);
}
// we do not invoke error-less next() here
// because that would try to return 200 with
// an empty body, while we have already sent
// back the response.
});
function _parse(next) {
// check mandatory parameters
if (!req.token) {
setImmediate(next, new HttpError("Missing account token", 401));
return;
}
if (!query.title) {
setImmediate(next, new HttpError("Missing application: title", 400));
return;
}
// pass other query parameters as 1st-level
// property of the build request object.
for (var p in query) {
if (!appData[p] && (typeof p === 'string')) {
try {
appData[p] = JSON.parse(query[p]);
} catch(e) {
appData[p] = query[p];
}
}
}
if (typeof appData.keys !== 'object') {
log.info("BdPhoneGap#build#_parse()", "un-signed build requested (did not find a valid signing key)");
}
//WARNING: enabling this trace shows-up the signing keys passwords
log.silly("BdPhoneGap#build#_parse(): appData:", appData);
setImmediate(next);
}
function _postMinify(req, next) {
log.info("BdPhoneGap#build#_postMinify()");
if (req.appDir.zipRoot !== req.appDir.source) {
copyFile(path.join(req.appDir.source, "config.xml"), path.join(req.appDir.zipRoot, "config.xml"), next);
} else {
setImmediate(next);
}
}
function _upload(next) {
log.info("BdPhoneGap#build#_upload()");
async.waterfall([
client.auth.bind(this, {
token: req.token,
proxy: this.config.proxyUrl
}),
_uploadApp.bind(this),
_success.bind(this)
], function(err) {
if (err) {
_fail(err);
} else {
setImmediate(next);
}
});
function _uploadApp(api, next) {
var options = {
form: {
data: appData,
file: req.zip.path
},
timeout: timeout*3
};
if (appData.appId) {
log.http("BdPhoneGap#build#_upload#_uploadApp()", "PUT /apps/" + appData.appId + " (title='" + appData.title + "')");
api.put('/apps/' + appData.appId, options, next);
} else {
log.http("BdPhoneGap#build#_upload#_uploadApp()", "POST /apps (title='" + appData.title + "')");
options.form.data.create_method = 'file';
api.post('/apps', options, next);
}
}
function _success(data, next) {
try {
log.verbose("BdPhoneGap#build#_upload#_success()", "data", data);
if (typeof data === 'string') {
data = JSON.parse(data);
}
if (typeof data.error === 'string') {
_fail(data.error);
return;
}
res.body = data;
setImmediate(next);
} catch(e) {
_fail(e);
}
}
function _fail(err) {
var msg;
try {
msg = JSON.parse(err.message).error;
} catch(e) {
msg = err.message;
}
log.warn("BdPhoneGap#build#_upload#_fail()", "error:", msg);
setImmediate(next, new HttpError(msg, 400 /*Bad Request*/));
}
}
};
if (path.basename(process.argv[1], '.js') === basename) {
// We are main.js: create & run the object...
var knownOpts = {
"port": Number,
"timeout": Number,
"pathname": String,
"level": ['silly', 'verbose', 'info', 'http', 'warn', 'error'],
"help": Boolean
};
var shortHands = {
"p": "--port",
"t": "--timeout",
"P": "--pathname",
"l": "--level",
"v": "--level verbose",
"h": "--help"
};
var helpString = [
"Usage: node " + basename,
" -p, --port port (o) local IP port of the express server (0: dynamic) [default: '0']",
" -t, --timeout milliseconds of inactivity before a server socket is presumed to have timed out [default: '120000']",
" -P, --pathname URL pathname prefix (before /minify and /build [default: '/phonegap']",
" -l, --level debug level ('silly', 'verbose', 'info', 'http', 'warn', 'error') [default: 'http']",
" -h, --help This message"
];
var argv = require('nopt')(knownOpts, shortHands, process.argv, 2 /*drop 'node' & basename*/);
log.level = argv.level || "http";
if (argv.help) {
helpString.forEach(function(line) {
console.log(line);
});
process.exit(0);
}
new BdPhoneGap({
pathname: argv.pathname,
port: argv.port,
timeout: argv.timeout,
basename: basename,
enyoDir: path.resolve(__dirname, '..', 'enyo'),
level: argv.level
}, function(err, service){
if(err) {
process.exit(err);
}
// process.send() is only available if the
// parent-process is also node
if (process.send) {
process.send(service);
}
});
} else {
// ... otherwise hook into commonJS module systems
module.exports = BdPhoneGap;
}