// URL parsing and validation // RPC to server (endpoint, arguments) // see if RPC requires password // prompt for password // send RPC with or without password as required var qs = require('querystring'); var files = require('./files.js'); var httpHelpers = require('./http-helpers.js'); var buildmessage = require('./buildmessage.js'); var config = require('./config.js'); var auth = require('./auth.js'); var utils = require('./utils.js'); var _ = require('underscore'); var Future = require('fibers/future'); var stats = require('./stats.js'); var Console = require('./console.js').Console; // Make a synchronous RPC to the "classic" MDG deploy API. The deploy // API has the following contract: // // - Parameters are always sent in the query string. // - A tarball can be sent in the body (when deploying an app). // - On success, all calls return HTTP 200. Those that return a value // either return a JSON payload or a plaintext payload and the // Content-Type header is set appropriately. // - On failure, calls return some non-200 HTTP status code and // provide a human-readable error message in the body. // - URLs are of the form "/[operation]/[site]". // - Body encodings are always utf8. // - Meteor Accounts auth is possible using first-party MDG cookies // (rather than OAuth). // // Options include: // - method: GET, POST, or DELETE. default GET // - operation: "info", "logs", "mongo", "deploy", "authorized-apps" // - site: site name // - expectPayload: an array of key names. if present, then we expect // the server to return JSON content on success and to return an // object with all of these key names. // - expectMessage: if true, then we expect the server to return text // content on success. // - bodyStream: if provided, a stream to use as the request body // - any other parameters accepted by the node 'request' module, for example // 'qs' to set query string parameters // // Waits until server responds, then returns an object with the // following keys: // // - statusCode: HTTP status code, or null if the server couldn't be // contacted // - payload: if successful, and the server returned a JSON body, the // parsed JSON body // - message: if successful, and the server returned a text body, the // body as a string // - errorMessage: if unsuccessful, a human-readable error message, // derived from either a transport-level exception, the response // body, or a generic 'try again later' message, as appropriate var deployRpc = function (options) { var genericError = "Server error (please try again later)"; options = _.clone(options); options.headers = _.clone(options.headers || {}); if (options.headers.cookie) throw new Error("sorry, can't combine cookie headers yet"); // XXX: Reintroduce progress for upload try { var result = httpHelpers.request(_.extend(options, { url: config.getDeployUrl() + '/' + options.operation + (options.site ? ('/' + options.site) : ''), method: options.method || 'GET', bodyStream: options.bodyStream, useAuthHeader: true, encoding: 'utf8' // Hack, but good enough for the deploy server.. })); } catch (e) { return { statusCode: null, errorMessage: "Connection error (" + e.message + ")" }; } var response = result.response; var body = result.body; var ret = { statusCode: response.statusCode }; if (response.statusCode !== 200) { ret.errorMessage = body.length > 0 ? body : genericError; return ret; } var contentType = response.headers["content-type"] || ''; if (contentType === "application/json; charset=utf-8") { try { ret.payload = JSON.parse(body); } catch (e) { ret.errorMessage = genericError; return ret; } } else if (contentType === "text/plain; charset=utf-8") { ret.message = body; } var hasAllExpectedKeys = _.all(_.map( options.expectPayload || [], function (key) { return ret.payload && _.has(ret.payload, key); })); if ((options.expectPayload && ! _.has(ret, 'payload')) || (options.expectMessage && ! _.has(ret, 'message')) || ! hasAllExpectedKeys) { delete ret.payload; delete ret.message; ret.errorMessage = genericError; } return ret; }; // Just like deployRpc, but also presents authentication. It will // prompt the user for a password, or use a Meteor Accounts // credential, as necessary. // // Additional options (beyond deployRpc): // // - preflight: if true, do everything but the actual RPC. The only // other necessary option is 'site'. On failure, returns an object // with errorMessage (just like deployRpc). On success, returns an // object without an errorMessage key and with possible keys // 'protection' (value either 'password' or 'account') and // 'authorized' (true if the current user is an authorized user on // this app). // - promptIfAuthFails: if true, then we think we are logged in with the // accounts server but our authentication actually fails, then prompt // the user to log in with a username and password and then resend the // RPC. var authedRpc = function (options) { var rpcOptions = _.clone(options); var preflight = rpcOptions.preflight; delete rpcOptions.preflight; // Fetch auth info var infoResult = deployRpc({ operation: 'info', site: rpcOptions.site, expectPayload: [] }); if (infoResult.statusCode === 401 && rpcOptions.promptIfAuthFails) { // Our authentication didn't validate, so prompt the user to log in // again, and resend the RPC if the login succeeds. var username = Console.readLine({ prompt: "Username: ", stream: process.stderr }); var loginOptions = { username: username, suppressErrorMessage: true }; if (auth.doInteractivePasswordLogin(loginOptions)) { return authedRpc(options); } else { return { statusCode: 403, errorMessage: "login failed." }; } } if (infoResult.statusCode === 404) { // Doesn't exist, therefore not protected. return preflight ? { } : deployRpc(rpcOptions); } if (infoResult.errorMessage) return infoResult; var info = infoResult.payload; if (! _.has(info, 'protection')) { // Not protected. // // XXX should prompt the user to claim the app (only if deploying?) return preflight ? { } : deployRpc(rpcOptions); } if (info.protection === "password") { if (preflight) { return { protection: info.protection }; } // Password protected. Read a password, hash it, and include the // hashed password as a query parameter when doing the RPC. var password; password = Console.readLine({ echo: false, prompt: "Password: ", stream: process.stderr }); // Hash the password so we never send plaintext over the // wire. Doesn't actually make us more secure, but it means we // won't leak a user's password, which they might use on other // sites too. var crypto = require('crypto'); var hash = crypto.createHash('sha1'); hash.update('S3krit Salt!'); hash.update(password); password = hash.digest('hex'); rpcOptions = _.clone(rpcOptions); rpcOptions.qs = _.clone(rpcOptions.qs || {}); rpcOptions.qs.password = password; return deployRpc(rpcOptions); } if (info.protection === "account") { if (! _.has(info, 'authorized')) { // Absence of this implies that we are not an authorized user on // this app if (preflight) { return { protection: info.protection }; } else { return { statusCode: null, errorMessage: auth.isLoggedIn() ? // XXX better error message (probably need to break out of // the 'errorMessage printed with brief prefix' pattern) "Not an authorized user on this site" : "Not logged in" }; } } // Sweet, we're an authorized user. if (preflight) { return { protection: info.protection, authorized: info.authorized }; } else { return deployRpc(rpcOptions); } } return { statusCode: null, errorMessage: "You need a newer version of Meteor to work with this site" }; }; // When the user is trying to do something with a legacy // password-protected app, instruct them to claim it with 'meteor // claim'. var printLegacyPasswordMessage = function (site) { Console.error( "\nThis site was deployed with an old version of Meteor that used " + "site passwords instead of user accounts. Now we have a much better " + "system, Meteor developer accounts."); Console.error(); Console.error("If this is your site, please claim it into your account with"); Console.error( Console.command("meteor claim " + site), Console.options({ indent: 2 })); }; // When the user is trying to do something with an app that they are not // authorized for, instruct them to get added via 'meteor authorized // --add' or switch accounts. var printUnauthorizedMessage = function () { var username = auth.loggedInUsername(); Console.error("Sorry, that site belongs to a different user."); if (username) { Console.error("You are currently logged in as " + username + "."); } Console.error(); Console.error( "Either have the site owner use " + Console.command("'meteor authorized --add'") + " to add you as an " + "authorized developer for the site, or switch to an authorized account " + "with " + Console.command("'meteor login'") + "."); }; // Take a proposed sitename for deploying to. If it looks // syntactically good, canonicalize it (this essentially means // stripping 'http://' or a trailing '/' if present) and return it. If // not, print an error message to stderr and return null. var canonicalizeSite = function (site) { // There are actually two different bugs here. One is that the meteor deploy // server does not support apps whose total site length is greater than 63 // (because of how it generates Mongo database names); that can be fixed on // the server. After that, this check will be too strong, but we still will // want to check that each *component* of the hostname is at most 63 // characters (url.parse will do something very strange if a component is // larger than 63, which is the maximum legal length). if (site.length > 63) { Console.error( "The maximum hostname length currently supported is 63 characters: " + site + " is too long. " + "Please try again with a shorter URL for your site."); return false; } var url = site; if (!url.match(':\/\/')) url = 'http://' + url; var parsed = require('url').parse(url); if (! parsed.hostname) { Console.info( "Please specify a domain to connect to, such as www.example.com or " + "http://www.example.com/"); return false; } if (parsed.pathname != '/' || parsed.hash || parsed.query) { Console.info( "Sorry, Meteor does not yet support specific path URLs, such as " + Console.url("http://www.example.com/blog") + " . Please specify the root of a domain."); return false; } return parsed.hostname; }; // Run the bundler and deploy the result. Print progress // messages. Return a command exit code. // // Options: // - projectContext: the ProjectContext for the app // - site: site to deploy as // - settingsFile: file from which to read deploy settings (undefined // to leave unchanged from previous deploy of the app, if any) // - recordPackageUsage: (defaults to true) if set to false, don't // send information about packages used by this app to the package // stats server. // - buildOptions: the 'buildOptions' argument to the bundler var bundleAndDeploy = function (options) { if (options.recordPackageUsage === undefined) options.recordPackageUsage = true; var site = canonicalizeSite(options.site); if (! site) return 1; // We should give a username/password prompt if the user was logged in // but the credentials are expired, unless the user is logged in but // doesn't have a username (in which case they should hit the email // prompt -- a user without a username shouldn't be given a username // prompt). There's an edge case where things happen in the following // order: user creates account, user sets username, credential expires // or is revoked, user comes back to deploy again. In that case, // they'll get an email prompt instead of a username prompt because // the command-line tool didn't have time to learn about their // username before the credential was expired. auth.pollForRegistrationCompletion({ noLogout: true }); var promptIfAuthFails = (auth.loggedInUsername() !== null); // Check auth up front, rather than after the (potentially lengthy) // bundling process. var preflight = authedRpc({ site: site, preflight: true, promptIfAuthFails: promptIfAuthFails }); if (preflight.errorMessage) { Console.error("Error deploying application: " + preflight.errorMessage); return 1; } if (preflight.protection === "password") { printLegacyPasswordMessage(site); Console.error("If it's not your site, please try a different name!"); return 1; } else if (preflight.protection === "account" && ! preflight.authorized) { printUnauthorizedMessage(); return 1; } var buildDir = files.mkdtemp('build_tar'); var bundlePath = files.pathJoin(buildDir, 'bundle'); Console.info('Deploying to ' + site + '.'); var settings = null; var messages = buildmessage.capture({ title: "preparing to deploy", rootPath: process.cwd() }, function () { if (options.settingsFile) settings = files.getSettings(options.settingsFile); }); if (! messages.hasMessages()) { var bundler = require('./bundler.js'); var bundleResult = bundler.bundle({ projectContext: options.projectContext, outputPath: bundlePath, buildOptions: options.buildOptions }); if (bundleResult.errors) messages = bundleResult.errors; } if (messages.hasMessages()) { Console.info("\nErrors prevented deploying:"); Console.info(messages.formatMessages()); return 1; } if (options.recordPackageUsage) { stats.recordPackages({ what: "sdk.deploy", projectContext: options.projectContext, site: site }); } var result = buildmessage.enterJob({ title: "uploading" }, function () { return authedRpc({ method: 'POST', operation: 'deploy', site: site, qs: settings !== null ? {settings: settings} : {}, bodyStream: files.createTarGzStream(files.pathJoin(buildDir, 'bundle')), expectPayload: ['url'], preflightPassword: preflight.preflightPassword }); }); if (result.errorMessage) { Console.error("\nError deploying application: " + result.errorMessage); return 1; } var deployedAt = require('url').parse(result.payload.url); var hostname = deployedAt.hostname; Console.info('Now serving at http://' + hostname); if (! hostname.match(/meteor\.com$/)) { var dns = require('dns'); dns.resolve(hostname, 'CNAME', function (err, cnames) { if (err || cnames[0] !== 'origin.meteor.com') { dns.resolve(hostname, 'A', function (err, addresses) { if (err || addresses[0] !== '107.22.210.133') { Console.info('-------------'); Console.info( "You've deployed to a custom domain.", "Please be sure to CNAME your hostname", "to origin.meteor.com, or set an A record to 107.22.210.133."); Console.info('-------------'); } }); } }); } return 0; }; var deleteApp = function (site) { site = canonicalizeSite(site); if (! site) return 1; var result = authedRpc({ method: 'DELETE', operation: 'deploy', site: site, promptIfAuthFails: true }); if (result.errorMessage) { Console.error("Couldn't delete application: " + result.errorMessage); return 1; } Console.info("Deleted."); return 0; }; // Helper that does a preflight request to check auth, and prints the // appropriate error message if auth fails or if this is a legacy // password-protected app. If auth succeeds, then it runs the actual // RPC. 'site' and 'operation' are the site and operation for the // RPC. 'what' is a string describing the operation, for use in error // messages. Returns the result of the RPC if successful, or null // otherwise (including if auth failed or if the user is not authorized // for this site). var checkAuthThenSendRpc = function (site, operation, what) { var preflight = authedRpc({ operation: operation, site: site, preflight: true, promptIfAuthFails: true }); if (preflight.errorMessage) { Console.error("Couldn't " + what + ": " + preflight.errorMessage); return null; } if (preflight.protection === "password") { printLegacyPasswordMessage(site); return null; } else if (preflight.protection === "account" && ! preflight.authorized) { if (! auth.isLoggedIn()) { // Maybe the user is authorized for this app but not logged in // yet, so give them a login prompt. var loginResult = auth.doUsernamePasswordLogin({ retry: true }); if (loginResult) { // Once we've logged in, retry the whole operation. We need to // do the preflight request again instead of immediately moving // on to the real RPC because we don't yet know if the newly // logged-in user is authorized for this app, and if they // aren't, then we want to print the nice unauthorized error // message. return checkAuthThenSendRpc(site, operation, what); } else { // Shouldn't ever get here because we set the retry flag on the // login, but just in case. Console.error( "\nYou must be logged in to " + what + " for this app. Use " + Console.command("'meteor login'") + "to log in."); Console.error(); Console.error( "If you don't have a Meteor developer account yet, you can quickly " + "create one at www.meteor.com."); return null; } } else { // User is logged in but not authorized for this app Console.error(); printUnauthorizedMessage(); return null; } } // User is authorized for the app; go ahead and do the actual RPC. var result = authedRpc({ operation: operation, site: site, expectMessage: true, promptIfAuthFails: true }); if (result.errorMessage) { Console.error("Couldn't " + what + ": " + result.errorMessage); return null; } return result; }; // On failure, prints a message to stderr and returns null. Otherwise, // returns a temporary authenticated Mongo URL allowing access to this // site's database. var temporaryMongoUrl = function (site) { site = canonicalizeSite(site); if (! site) // canonicalizeSite printed an error return null; var result = checkAuthThenSendRpc(site, 'mongo', 'open a mongo connection'); if (result !== null) { return result.message; } else { return null; } }; var logs = function (site) { site = canonicalizeSite(site); if (! site) return 1; var result = checkAuthThenSendRpc(site, 'logs', 'view logs'); if (result === null) { return 1; } else { Console.info(result.message); auth.maybePrintRegistrationLink({ leadingNewline: true }); return 0; } }; var listAuthorized = function (site) { site = canonicalizeSite(site); if (! site) return 1; var result = deployRpc({ operation: 'info', site: site, expectPayload: [] }); if (result.errorMessage) { Console.error("Couldn't get authorized users list: " + result.errorMessage); return 1; } var info = result.payload; if (! _.has(info, 'protection')) { Console.info(""); return 0; } if (info.protection === "password") { Console.info(""); return 0; } if (info.protection === "account") { if (! _.has(info, 'authorized')) { Console.error("Couldn't get authorized users list: " + "You are not authorized"); return 1; } Console.info((auth.loggedInUsername() || "")); _.each(info.authorized, function (username) { if (username) // Current username rules don't let you register anything that we might // want to split over multiple lines (ex: containing a space), but we // don't want confusion if we ever change some implementation detail. Console.rawInfo(username + "\n"); }); return 0; } }; // action is "add" or "remove" var changeAuthorized = function (site, action, username) { site = canonicalizeSite(site); if (! site) // canonicalizeSite will have already printed an error return 1; var result = authedRpc({ method: 'POST', operation: 'authorized', site: site, qs: action === "add" ? { add: username } : { remove: username }, promptIfAuthFails: true }); if (result.errorMessage) { Console.error("Couldn't change authorized users: " + result.errorMessage); return 1; } Console.info(site + ": " + (action === "add" ? "added " : "removed ") + username); return 0; }; var claim = function (site) { site = canonicalizeSite(site); if (! site) // canonicalizeSite will have already printed an error return 1; // Check to see if it's even a claimable site, so that we can print // a more appropriate message than we'd get if we called authedRpc // straight away (at a cost of an extra REST call) var infoResult = deployRpc({ operation: 'info', site: site }); if (infoResult.statusCode === 404) { Console.error( "There isn't a site deployed at that address. Use " + Console.command("'meteor deploy'") + " " + "if you'd like to deploy your app here."); return 1; } if (infoResult.payload && infoResult.payload.protection === "account") { if (infoResult.payload.authorized) Console.error("That site already belongs to you.\n"); else Console.error("Sorry, that site belongs to someone else.\n"); return 1; } if (infoResult.payload && infoResult.payload.protection === "password") { Console.info( "To claim this site and transfer it to your account, enter the", "site password one last time."); Console.info(); } var result = authedRpc({ method: 'POST', operation: 'claim', site: site, promptIfAuthFails: true }); if (result.errorMessage) { auth.pollForRegistrationCompletion(); if (! auth.loggedInUsername() && auth.registrationUrl()) { Console.error( "You need to set a password on your Meteor developer account before", "you can claim sites. You can do that here in under a minute:"); Console.error(Console.url(auth.registrationUrl())); Console.error(); } else { Console.error("Couldn't claim site: " + result.errorMessage); } return 1; } Console.info(site + ": " + "successfully transferred to your account."); Console.info(); Console.info("Show authorized users with:"); Console.info( Console.command("meteor authorized " + site), Console.options({ indent: 2 })); Console.info(); Console.info("Add authorized users with:"); Console.info( Console.command("meteor authorized " + site + " --add "), Console.options({ indent: 2 })); Console.info(); Console.info("Remove authorized users with:"); Console.info( Console.command("meteor authorized " + site + " --remove "), Console.options({ indent: 2 })); Console.info(); return 0; }; var listSites = function () { var result = deployRpc({ method: "GET", operation: "authorized-apps", promptIfAuthFails: true, expectPayload: ["sites"] }); if (result.errorMessage) { Console.error("Couldn't list sites: " + result.errorMessage); return 1; } if (! result.payload || ! result.payload.sites || ! result.payload.sites.length) { Console.info("You don't have any sites yet."); } else { result.payload.sites.sort(); _.each(result.payload.sites, function (site) { Console.info(site); }); } return 0; }; exports.bundleAndDeploy = bundleAndDeploy; exports.deleteApp = deleteApp; exports.temporaryMongoUrl = temporaryMongoUrl; exports.logs = logs; exports.listAuthorized = listAuthorized; exports.changeAuthorized = changeAuthorized; exports.claim = claim; exports.listSites = listSites;