diff --git a/.mailmap b/.mailmap index 42f74158ab..efd15a1605 100644 --- a/.mailmap +++ b/.mailmap @@ -12,6 +12,7 @@ GITHUB: aldeed GITHUB: AlexeyMK GITHUB: apendua GITHUB: arbesfeld +GITHUB: Cangit GITHUB: DenisGorbachev GITHUB: EOT GITHUB: FooBarWidget @@ -20,15 +21,21 @@ GITHUB: OyoKooN GITHUB: RobertLowe GITHUB: ansman GITHUB: awwx +GITHUB: babenzele GITHUB: cmather GITHUB: codeinthehole GITHUB: dandv GITHUB: davegonzalez +GITHUB: ducdigital GITHUB: emgee3 +GITHUB: felixrabe +GITHUB: FredericoC GITHUB: icellan GITHUB: jacott GITHUB: jfhamlin +GITHUB: jbruni GITHUB: justinsb +GITHUB: kentonv GITHUB: marcandre GITHUB: mart-jansink GITHUB: meawoppl @@ -47,7 +54,10 @@ GITHUB: rgould GITHUB: ryw GITHUB: rzymek GITHUB: sdarnell +GITHUB: subhog +GITHUB: tbjers GITHUB: timhaines +GITHUB: tmeasday GITHUB: yeputons GITHUB: zol @@ -65,4 +75,3 @@ METEOR: sixolet METEOR: Slava METEOR: stubailo METEOR: ekatek - diff --git a/History.md b/History.md index 70bbae0490..5b08a51be2 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,192 @@ ## v.NEXT +## v0.8.2 + +#### Meteor Accounts + +* Switch `accounts-password` to use bcrypt to store passwords on the + server. (Previous versions of Meteor used a protocol called SRP.) + Users will be transparently transitioned when they log in. This + transition is one-way, so you cannot downgrade a production app once + you upgrade to 0.8.2. If you are maintaining an authenticating DDP + client: + - Clients that use the plaintext password login handler (i.e. call + the `login` method with argument `{ password: }`) will continue to work, but users will not be + transitioned from SRP to bcrypt when logging in with this login + handler. + - Clients that use SRP will no longer work. These clients should + instead directly call the `login` method, as in + `Meteor.loginWithPassword`. The argument to the `login` method + can be either: + - `{ password: <plaintext password> }`, or + - `{ password: { digest: <password hash>, algorithm: "sha-256" } }`, + where the password hash is the hex-encoded SHA256 hash of the + plaintext password. + +* Show the display name of the currently logged-in user after following + an email verification link or a password reset link in `accounts-ui`. + +* Add a `userEmail` option to `Meteor.loginWithMeteorDeveloperAccount` + to pre-fill the user's email address in the OAuth popup. + +* Ensure that the user object has updated token information before + it is passed to email template functions. #2210 + +* Export the function that serves the HTTP response at the end of an + OAuth flow as `OAuth._endOfLoginResponse`. This function can be + overridden to make the OAuth popup flow work in certain mobile + environments where `window.opener` is not supported. + +* Remove support for OAuth redirect URLs with a `redirect` query + parameter. This OAuth flow was never documented and never fully + worked. + + +#### Blaze + +* Blaze now tracks individual CSS rules in `style` attributes and won't + overwrite changes to them made by other JavaScript libraries. + +* Add {{> UI.dynamic}} to make it easier to dynamically render a + template with a data context. + +* Add `UI._templateInstance()` for accessing the current template + instance from within a block helper. + +* Add `UI._parentData(n)` for accessing parent data contexts from + within a block helper. + +* Add preliminary API for registering hooks to run when Blaze intends to + insert, move, or remove DOM elements. For example, you can use these + hooks to animate nodes as they are inserted, moved, or removed. To use + them, you can set the `_uihooks` property on a container DOM + element. `_uihooks` is an object that can have any subset of the + following three properties: + + - `insertElement: function (node, next)`: called when Blaze intends + to insert the DOM element `node` before the element `next` + - `moveElement: function (node, next)`: called when Blaze intends to + move the DOM element `node` before the element `next` + - `removeElement: function (node)`: called when Blaze intends to + remove the DOM element `node` + + Note that when you set one of these functions on a container + element, Blaze will not do the actual operation; it's your + responsibility to actually insert, move, or remove the node (by + calling `$(node).remove()`, for example). + +* The `findAll` method on template instances now returns a vanilla + array, not a jQuery object. The `$` method continues to + return a jQuery object. #2039 + +* Fix a Blaze memory leak by cleaning up event handlers when a template + instance is destroyed. #1997 + +* Fix a bug where helpers used by {{#with}} were still re-running when + their reactive data sources changed after they had been removed from + the DOM. + +* Stop not updating form controls if they're focused. If a field is + edited by one user while another user is focused on it, it will just + lose its value but maintain its focus. #1965 + +* Add `_nestInCurrentComputation` option to `UI.render`, fixing a bug in + {{#each}} when an item is added inside a computation that subsequently + gets invalidated. #2156 + +* Fix bug where "=" was not allowed in helper arguments. #2157 + +* Fix bug when a template tag immediately follows a Spacebars block + comment. #2175 + + +#### Command-line tool + +* Add --directory flag to `meteor bundle`. Setting this flag outputs a + directory rather than a tarball. + +* Speed up updates of NPM modules by upgrading Node to include our fix for + https://github.com/npm/npm/issues/3265 instead of passing `--force` to + `npm install`. + +* Always rebuild on changes to npm-shrinkwrap.json files. #1648 + +* Fix uninformative error message when deploying to long hostnames. #1208 + +* Increase a buffer size to avoid failing when running MongoDB due to a + large number of processes running on the machine, and fix the error + message when the failure does occur. #2158 + +* Clarify a `meteor mongo` error message when using the MONGO_URL + environment variable. #1256 + + +#### Testing + +* Run server tests from multiple clients serially instead of in + parallel. This allows testing features that modify global server + state. #2088 + + +#### Security + +* Add Content-Type headers on JavaScript and CSS resources. + +* Add `X-Content-Type-Options: nosniff` header to + `browser-policy-content`'s default policy. If you are using + `browser-policy-content` and you don't want your app to send this + header, then call `BrowserPolicy.content.allowContentTypeSniffing()`. + +* Use `Meteor.absoluteUrl()` to compute the redirect URL in the `force-ssl` + package (instead of the host header). + + +#### Miscellaneous + +* Allow `check` to work on the server outside of a Fiber. #2136 + +* EJSON custom type conversion functions should not be permitted to yield. #2136 + +* The legacy polling observe driver handles errors communicating with MongoDB + better and no longer gets "stuck" in some circumstances. + +* Automatically rewind cursors before calls to `fetch`, `forEach`, or `map`. On + the client, don't cache the return value of `cursor.count()` (consistently + with the server behavior). `cursor.rewind()` is now a no-op. #2114 + +* Remove an obsolete hack in reporting line numbers for LESS errors. #2216 + +* Avoid exceptions when accessing localStorage in certain Internet + Explorer configurations. #1291, #1688. + +* Make `handle.ready()` reactively stop, where `handle` is a + subscription handle. + +* Fix an error message from `audit-argument-checks` after login. + +* Make the DDP server send an error if the client sends a connect + message with a missing or malformed `support` field. #2125 + +* Fix missing `jquery` dependency in the `amplify` package. #2113 + +* Ban inserting EJSON custom types as documents. #2095 + +* Fix incorrect URL rewrites in stylesheets. #2106 + +* Upgraded dependencies: + - node: 0.10.28 (from 0.10.26) + - uglify-js: 2.4.13 (from 2.4.7) + - sockjs server: 0.3.9 (from 0.3.8) + - websocket-driver: 0.3.4 (from 0.3.2) + - stylus: 0.46.3 (from 0.42.3) + +Patches contributed by GitHub users awwx, babenzele, Cangit, dandv, +ducdigital, emgee3, felixrabe, FredericoC, jbruni, kentonv, mizzao, +mquandalle, subhog, tbjers, tmeasday. + + ## v.0.8.1.3 * Fix a security issue in the `spiderable` package. `spiderable` now @@ -19,7 +205,8 @@ * Add missing `underscore` dependency in the `oauth-encryption` package. #2165 -* Fix minification bug that caused some apps to fail to render in IE8. #2037. +* Work around IE8 bug that caused some apps to fail to render when + minified. #2037. ## v.0.8.1.2 @@ -99,6 +286,8 @@ * Clean up autoruns when calling `UI.toHTML`. +* Properly clean up event listeners when removing templates. + * Add support for `{{!-- block comments --}}` in Spacebars. Block comments may contain `}}`, so they are more useful than `{{! normal comments}}` for commenting out sections of Spacebars templates. @@ -123,6 +312,12 @@ get one with `DDP.randomStream`. https://trello.com/c/moiiS2rP/57-pattern-for-creating-multiple-database-records-from-a-method +* The document passed to the `insert` callback of `allow` and `deny` now only + has a `_id` field if the client explicitly specified one; this allows you to + use `allow`/`deny` rules to prevent clients from specifying their own + `_id`. As an exception, `allow`/`deny` rules with a `transform` always have an + `_id`. + * DDP now has an implementation of bidirectional heartbeats which is consistent across SockJS and websocket transports. This enables connection keepalive and allows servers and clients to more consistently and efficiently detect diff --git a/docs/.meteor/release b/docs/.meteor/release index db5f2c74b7..100435be13 100644 --- a/docs/.meteor/release +++ b/docs/.meteor/release @@ -1 +1 @@ -0.8.1.3 +0.8.2 diff --git a/docs/client/api.html b/docs/client/api.html index f0e7f543dd..adc97f191e 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -962,6 +962,10 @@ The available callbacks are: {{#dtdd "insert(userId, doc)"}} The user `userId` wants to insert the document `doc` into the collection. Return `true` if this should be allowed. + +`doc` will contain the `_id` field if one was explicitly set by the client, or +if there is an active `transform`. You can use this to prevent users from +specifying arbitrary `_id` fields. {{/dtdd}} {{#dtdd "update(userId, doc, fieldNames, modifier)"}} @@ -1113,12 +1117,6 @@ Unlike the other functions, `count` registers a dependency only on the number of matching documents. (Updates that just change or reorder the documents in the result set will not trigger a recomputation.) -{{> api_box cursor_rewind}} - -The `forEach`, `map`, or `fetch` methods can only be called once on a -cursor. To access the data in a cursor more than once, use `rewind` to -reset the cursor. - {{> api_box cursor_observe}} Establishes a *live query* that invokes callbacks when the result of @@ -2150,11 +2148,11 @@ in the DOM. {{> api_box template_findAll}} -Returns a [jQuery object](http://api.jquery.com/Types/#jQuery) of DOM elements -matching `selector`. This object is similar to an array but has other methods -defined by the jQuery library. +`this.findAll` returns an array of DOM elements matching `selector`. -You can also call this function as `this.$(selector)`. +`this.$` returns a [jQuery object](http://api.jquery.com/Types/#jQuery) of +those same elements. jQuery objects are similar to arrays, with +additional methods defined by the jQuery library. The template instance serves as the document root for the selector. Only elements inside the template and its sub-templates can match parts of @@ -3165,8 +3163,3 @@ code can read `data.txt` by running: {{/each}} </dl> </template> - - -<template name="api_section"> -<h2 id="{{id}}"><a href="#{{id}}" class="selflink"><span>{{name}}</span></a></h2> -</template> diff --git a/docs/client/api.js b/docs/client/api.js index 9d751c233f..e1d5d24746 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -404,7 +404,7 @@ Template.api.method_invocation_connection = { Template.api.error = { id: "meteor_error", - name: "new Meteor.Error(error, reason, details)", + name: "new Meteor.Error(error [, reason] [, details])", locus: "Anywhere", descr: ["This class represents a symbolic error thrown by a method."], args: [ @@ -779,14 +779,6 @@ Template.api.cursor_map = { ] }; -Template.api.cursor_rewind = { - id: "rewind", - name: "<em>cursor</em>.rewind()", - locus: "Anywhere", - descr: ["Resets the query cursor."], - args: [ ] -}; - Template.api.cursor_observe = { id: "observe", name: "<em>cursor</em>.observe(callbacks)", @@ -1133,6 +1125,11 @@ Template.api.loginWithExternalService = { name: "forceApprovalPrompt", type: "Boolean", descr: "If true, forces the user to approve the app's permissions, even if previously approved. Currently only supported with Google." + }, + { + name: "userEmail", + type: "String", + descr: "An email address that the external service will use to pre-fill the login prompt. Currently only supported with Meteor developer accounts." } ] }; @@ -1805,7 +1802,7 @@ Template.api.template_helpers = { Template.api.template_findAll = { id: "template_findAll", - name: "<em>this</em>.findAll(selector)", + name: "<em>this</em>.findAll(selector) and <em>this</em>.$(selector)", locus: "Client", descr: ["Find all elements matching `selector` in this template instance."], args: [ diff --git a/docs/client/commandline.html b/docs/client/commandline.html index fb642598e6..bc36e6fef5 100644 --- a/docs/client/commandline.html +++ b/docs/client/commandline.html @@ -2,7 +2,7 @@ <div> {{#markdown}} -{{#api_section "commandline"}}Command line{{/api_section}} +<h1 id="commandline">Command line</h1> <!-- XXX some intro text? --> @@ -55,6 +55,15 @@ then you'll need to make sure the DNS for that domain is configured to point at `origin.meteor.com`. +The first time you deploy an app you'll be prompted for an email address &mdash; +follow the link in your email to finish setting up your account. + + +Once you have your account you can log in and log out from the command line, +check your username with `meteor whoami`, and run `meteor authorized` to give +other Meteor developers permissions to deploy your app and access its database +and logs. + You can deploy in debug mode by passing `--debug`. This will leave your source code readable by your favorite in-browser @@ -67,24 +76,6 @@ the `--delete` option along with the site. -To add an administrative password to your deployment, include -the `--password` option. Meteor will prompt -for a password. Once set, any future `meteor deploy` to -the same domain will require that you provide the password. The same -password protects access to `meteor mongo` -and `meteor logs`. You can change the password by -running `meteor deploy --password` again, -which will first prompt for the current password, then for a new -password. - - -{{#warning}} -Password protection only applies to administrative actions with the -Meteor command. It does not prevent access to your deployed -website. Also, this all is a temporary hack until we have -full-featured Meteor accounts. -{{/warning}} - {{#warning}} If you use a domain name other than `meteor.com` you must ensure that the name resolves diff --git a/docs/client/concepts.html b/docs/client/concepts.html index 6edef8e354..0ed41a18b3 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -140,9 +140,8 @@ functions, available under the `Template` namespace. It's a really convenient way to ship HTML templates to the client. See the [templates](#livehtmltemplates) section for more. -Lastly, the Meteor server will serve any files under the `public` -directory, just like in a Rails or Django project. This is the place -for images, `favicon.ico`, `robots.txt`, and anything else. +Lastly, the Meteor server will serve any files under the `public` directory. +This is the place for images, `favicon.ico`, `robots.txt`, and anything else. It is best to write your application in such a way that it is insensitive to the order in which files are loaded, for example by @@ -448,7 +447,7 @@ extension. In the file, make a `<template>` tag and give it a will precompile the template, ship it down to the client, and make it available as on the global `Template` object. -When you app is loaded, it automatically renders the special template called +When your app is loaded, it automatically renders the special template called `<body>`, which is written using the `<body>` element instead of a `<template>`. You insert a template inside another template by using the `{{dstache}}> inclusion}}` operator. @@ -741,7 +740,7 @@ To get started, run This command will generate a fully-contained Node.js application in the form of a tarball. To run this application, you need to provide Node.js 0.10 and a MongoDB server. (The current release of Meteor has been tested with Node -0.10.26; older versions contain a serious bug that can cause production servers +0.10.28; older versions contain a serious bug that can cause production servers to stall.) You can then run the application by invoking node, specifying the HTTP port for the application to listen on, and the MongoDB endpoint. If you don't already have a MongoDB server, we can recommend our friends at diff --git a/docs/client/docs.js b/docs/client/docs.js index ebab3d0cd7..fdcdf640d8 100644 --- a/docs/client/docs.js +++ b/docs/client/docs.js @@ -181,7 +181,6 @@ var toc = [ {instance: "cursor", name: "map"}, {instance: "cursor", name: "fetch"}, {instance: "cursor", name: "count"}, - {instance: "cursor", name: "rewind"}, {instance: "cursor", name: "observe"}, {instance: "cursor", name: "observeChanges", id: "observe_changes"} ], @@ -250,6 +249,7 @@ var toc = [ ], {name: "Template instances", id: "template_inst"}, [ {instance: "this", name: "findAll", id: "template_findAll"}, + {instance: "this", name: "$", id: "template_findAll"}, {instance: "this", name: "find", id: "template_find"}, {instance: "this", name: "firstNode", id: "template_firstNode"}, {instance: "this", name: "lastNode", id: "template_lastNode"}, diff --git a/docs/client/packages/browser-policy.html b/docs/client/packages/browser-policy.html index 8499e891c9..94c90a4616 100644 --- a/docs/client/packages/browser-policy.html +++ b/docs/client/packages/browser-policy.html @@ -169,6 +169,12 @@ sites can frame your site, while `BrowserPolicy.content.allowFrameOrigin` allows you to control which sites can be loaded inside frames on your site. +Adding `browser-policy-content` to your app also tells certain +browsers to avoid sniffing content types away from the declared type +(for example, interpreting a text file as JavaScript), using the +[X-Content-Type-Options](http://msdn.microsoft.com/en-us/library/ie/gg622941(v=vs.85).aspx) +header. To re-enable content sniffing, you can call +`BrowserPolicy.content.allowContentTypeSniffing()`. {{/markdown}} </template> diff --git a/docs/lib/release-override.js b/docs/lib/release-override.js index 2b39cfa5e4..d0fc420710 100644 --- a/docs/lib/release-override.js +++ b/docs/lib/release-override.js @@ -1,5 +1,5 @@ // While galaxy apps are on their own special meteor releases, override // Meteor.release here. if (Meteor.isClient) { - Meteor.release = Meteor.release ? "0.8.1.3" : undefined; + Meteor.release = Meteor.release ? "0.8.2" : undefined; } diff --git a/examples/clock/.meteor/release b/examples/clock/.meteor/release index db5f2c74b7..100435be13 100644 --- a/examples/clock/.meteor/release +++ b/examples/clock/.meteor/release @@ -1 +1 @@ -0.8.1.3 +0.8.2 diff --git a/examples/leaderboard/.meteor/release b/examples/leaderboard/.meteor/release index db5f2c74b7..100435be13 100644 --- a/examples/leaderboard/.meteor/release +++ b/examples/leaderboard/.meteor/release @@ -1 +1 @@ -0.8.1.3 +0.8.2 diff --git a/examples/parties/.meteor/release b/examples/parties/.meteor/release index db5f2c74b7..100435be13 100644 --- a/examples/parties/.meteor/release +++ b/examples/parties/.meteor/release @@ -1 +1 @@ -0.8.1.3 +0.8.2 diff --git a/examples/todos/.meteor/release b/examples/todos/.meteor/release index db5f2c74b7..100435be13 100644 --- a/examples/todos/.meteor/release +++ b/examples/todos/.meteor/release @@ -1 +1 @@ -0.8.1.3 +0.8.2 diff --git a/examples/wordplay/.meteor/release b/examples/wordplay/.meteor/release index db5f2c74b7..100435be13 100644 --- a/examples/wordplay/.meteor/release +++ b/examples/wordplay/.meteor/release @@ -1 +1 @@ -0.8.1.3 +0.8.2 diff --git a/meteor b/meteor index a89420b584..0e8875133c 100755 --- a/meteor +++ b/meteor @@ -1,6 +1,6 @@ #!/bin/bash -BUNDLE_VERSION=0.3.34 +BUNDLE_VERSION=0.3.37 # OS Check. Put here because here is where we download the precompiled # bundles that are arch specific. diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 4bb346a578..46d904ec08 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -544,7 +544,7 @@ Meteor.methods({ /// ACCOUNT DATA /// -// connectionId -> {connection, loginToken, srpChallenge} +// connectionId -> {connection, loginToken} var accountData = {}; // HACK: This is used by 'meteor-accounts' to get the loginToken for a diff --git a/packages/accounts-meteor-developer/meteor-developer.js b/packages/accounts-meteor-developer/meteor-developer.js index 55e131b798..79ac4e4c2c 100644 --- a/packages/accounts-meteor-developer/meteor-developer.js +++ b/packages/accounts-meteor-developer/meteor-developer.js @@ -10,7 +10,7 @@ if (Meteor.isClient) { var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); - MeteorDeveloperAccounts.requestCredential(credentialRequestCompleteCallback); + MeteorDeveloperAccounts.requestCredential(options, credentialRequestCompleteCallback); }; } else { Accounts.addAutopublishFields({ diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index f53f8c2d10..95f5804885 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -7,6 +7,7 @@ Package.on_use(function(api) { // Export Accounts (etc) to packages using this one. api.imply('accounts-base', ['client', 'server']); api.use('srp', ['client', 'server']); + api.use('sha', ['client', 'server']); api.use('email', ['server']); api.use('random', ['server']); api.use('check', ['server']); diff --git a/packages/accounts-password/password_client.js b/packages/accounts-password/password_client.js index 96be0b1092..bc04cc8754 100644 --- a/packages/accounts-password/password_client.js +++ b/packages/accounts-password/password_client.js @@ -8,41 +8,80 @@ // @param password {String} // @param callback {Function(error|undefined)} Meteor.loginWithPassword = function (selector, password, callback) { - var srp = new SRP.Client(password); - var request = srp.startExchange(); - if (typeof selector === 'string') if (selector.indexOf('@') === -1) selector = {username: selector}; else selector = {email: selector}; - request.user = selector; + Accounts.callLoginMethod({ + methodArguments: [{ + user: selector, + password: hashPassword(password) + }], + userCallback: function (error, result) { + if (error && error.error === 400 && + error.reason === 'old password format') { + // The "reason" string should match the error thrown in the + // password login handler in password_server.js. - // Normally, we only set Meteor.loggingIn() to true within - // Accounts.callLoginMethod, but we'd also like it to be true during the - // password exchange. So we set it to true here, and clear it on error; in - // the non-error case, it gets cleared by callLoginMethod. - Accounts._setLoggingIn(true); - Accounts.connection.apply( - 'beginPasswordExchange', [request], function (error, result) { - if (error || !result) { - Accounts._setLoggingIn(false); - error = error || - new Error("No result from call to beginPasswordExchange"); - callback && callback(error); - return; + // XXX COMPAT WITH 0.8.1.3 + // If this user's last login was with a previous version of + // Meteor that used SRP, then the server throws this error to + // indicate that we should try again. The error includes the + // user's SRP identity. We provide a value derived from the + // identity and the password to prove to the server that we know + // the password without requiring a full SRP flow, as well as + // SHA256(password), which the server bcrypts and stores in + // place of the old SRP information for this user. + srpUpgradePath({ + upgradeError: error, + userSelector: selector, + plaintextPassword: password + }, callback); } + else if (error) { + callback(error); + } else { + callback(); + } + } + }); +}; - var response = srp.respondToChallenge(result); - Accounts.callLoginMethod({ - methodArguments: [{srp: response}], - validateResult: function (result) { - if (!srp.verifyConfirmation({HAMK: result.HAMK})) - throw new Error("Server is cheating!"); - }, - userCallback: callback}); +var hashPassword = function (password) { + return { + digest: SHA256(password), + algorithm: "sha-256" + }; +}; + +// XXX COMPAT WITH 0.8.1.3 +// The server requested an upgrade from the old SRP password format, +// so supply the needed SRP identity to login. Options: +// - upgradeError: the error object that the server returned to tell +// us to upgrade from SRP to bcrypt. +// - userSelector: selector to retrieve the user object +// - plaintextPassword: the password as a string +var srpUpgradePath = function (options, callback) { + var details; + try { + details = EJSON.parse(options.upgradeError.details); + } catch (e) {} + if (!(details && details.format === 'srp')) { + callback(new Meteor.Error(400, + "Password is old. Please reset your " + + "password.")); + } else { + Accounts.callLoginMethod({ + methodArguments: [{ + user: options.userSelector, + srp: SHA256(details.identity + ":" + options.plaintextPassword), + password: hashPassword(options.plaintextPassword) + }], + userCallback: callback }); + } }; @@ -52,10 +91,9 @@ Accounts.createUser = function (options, callback) { if (!options.password) throw new Error("Must set options.password"); - var verifier = SRP.generateVerifier(options.password); - // strip old password, replacing with the verifier object - delete options.password; - options.srp = verifier; + + // Replace password with the hashed password. + options.password = hashPassword(options.password); Accounts.callLoginMethod({ methodName: 'createUser', @@ -79,49 +117,39 @@ Accounts.changePassword = function (oldPassword, newPassword, callback) { return; } - var verifier = SRP.generateVerifier(newPassword); - - if (!oldPassword) { - Accounts.connection.apply( - 'changePassword', [{srp: verifier}], function (error, result) { - if (error || !result) { - callback && callback( - error || new Error("No result from changePassword.")); - } else { - callback && callback(); - } - }); - } else { // oldPassword - var srp = new SRP.Client(oldPassword); - var request = srp.startExchange(); - request.user = {id: Meteor.user()._id}; - Accounts.connection.apply( - 'beginPasswordExchange', [request], function (error, result) { - if (error || !result) { - callback && callback( - error || new Error("No result from call to beginPasswordExchange")); - return; - } - - var response = srp.respondToChallenge(result); - response.srp = verifier; - Accounts.connection.apply( - 'changePassword', [response],function (error, result) { - if (error || !result) { - callback && callback( - error || new Error("No result from changePassword.")); + Accounts.connection.apply( + 'changePassword', + [oldPassword ? hashPassword(oldPassword) : null, hashPassword(newPassword)], + function (error, result) { + if (error || !result) { + if (error && error.error === 400 && + error.reason === 'old password format') { + // XXX COMPAT WITH 0.8.1.3 + // The server is telling us to upgrade from SRP to bcrypt, as + // in Meteor.loginWithPassword. + srpUpgradePath({ + upgradeError: error, + userSelector: { id: Meteor.userId() }, + plaintextPassword: oldPassword + }, function (err) { + if (err) { + callback(err); } else { - if (!srp.verifyConfirmation(result)) { - // Monkey business! - callback && - callback(new Error("Old password verification failed.")); - } else { - callback && callback(); - } + // Now that we've successfully migrated from srp to + // bcrypt, try changing the password again. + Accounts.changePassword(oldPassword, newPassword, callback); } }); - }); - } + } else { + // A normal error, not an error telling us to upgrade to bcrypt + callback && callback( + error || new Error("No result from changePassword.")); + } + } else { + callback && callback(); + } + } + ); }; // Sends an email to a user with a link that can be used to reset @@ -148,10 +176,9 @@ Accounts.resetPassword = function(token, newPassword, callback) { if (!newPassword) throw new Error("Need to pass newPassword"); - var verifier = SRP.generateVerifier(newPassword); Accounts.callLoginMethod({ methodName: 'resetPassword', - methodArguments: [token, verifier], + methodArguments: [token, hashPassword(newPassword)], userCallback: callback}); }; diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index a758da4a1c..39d84518c0 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -1,3 +1,78 @@ +/// BCRYPT + +var bcrypt = Npm.require('bcrypt'); +var bcryptHash = Meteor._wrapAsync(bcrypt.hash); +var bcryptCompare = Meteor._wrapAsync(bcrypt.compare); + +// User records have a 'services.password.bcrypt' field on them to hold +// their hashed passwords (unless they have a 'services.password.srp' +// field, in which case they will be upgraded to bcrypt the next time +// they log in). +// +// When the client sends a password to the server, it can either be a +// string (the plaintext password) or an object with keys 'digest' and +// 'algorithm' (must be "sha-256" for now). The Meteor client always sends +// password objects { digest: *, algorithm: "sha-256" }, but DDP clients +// that don't have access to SHA can just send plaintext passwords as +// strings. +// +// When the server receives a plaintext password as a string, it always +// hashes it with SHA256 before passing it into bcrypt. When the server +// receives a password as an object, it asserts that the algorithm is +// "sha-256" and then passes the digest to bcrypt. + + +Accounts._bcryptRounds = 10; + +// Given a 'password' from the client, extract the string that we should +// bcrypt. 'password' can be one of: +// - String (the plaintext password) +// - Object with 'digest' and 'algorithm' keys. 'algorithm' must be "sha-256". +// +var getPasswordString = function (password) { + if (typeof password === "string") { + password = SHA256(password); + } else { // 'password' is an object + if (password.algorithm !== "sha-256") { + throw new Error("Invalid password hash algorithm. " + + "Only 'sha-256' is allowed."); + } + password = password.digest; + } + return password; +}; + +// Use bcrypt to hash the password for storage in the database. +// `password` can be a string (in which case it will be run through +// SHA256 before bcrypt) or an object with properties `digest` and +// `algorithm` (in which case we bcrypt `password.digest`). +// +var hashPassword = function (password) { + password = getPasswordString(password); + return bcryptHash(password, Accounts._bcryptRounds); +}; + +// Check whether the provided password matches the bcrypt'ed password in +// the database user record. `password` can be a string (in which case +// it will be run through SHA256 before bcrypt) or an object with +// properties `digest` and `algorithm` (in which case we bcrypt +// `password.digest`). +// +Accounts._checkPassword = function (user, password) { + var result = { + userId: user._id + }; + + password = getPasswordString(password); + + if (! bcryptCompare(password, user.services.password.bcrypt)) { + result.error = new Meteor.Error(403, "Incorrect password"); + } + + return result; +}; +var checkPassword = Accounts._checkPassword; + /// /// LOGIN /// @@ -16,6 +91,16 @@ var selectorFromUserQuery = function (user) { throw new Error("shouldn't happen (validation missed something)"); }; +var findUserFromUserQuery = function (user) { + var selector = selectorFromUserQuery(user); + + var user = Meteor.users.findOne(selector); + if (!user) + throw new Meteor.Error(403, "User not found"); + + return user; +}; + // XXX maybe this belongs in the check package var NonEmptyString = Match.Where(function (x) { check(x, String); @@ -33,134 +118,133 @@ var userQueryValidator = Match.Where(function (user) { return true; }); -// Step 1 of SRP password exchange. This puts an `M` value in the -// session data for this connection. If a client later sends the same -// `M` value to a method on this connection, it proves they know the -// password for this user. We can then prove we know the password to -// them by sending our `HAMK` value. +var passwordValidator = Match.OneOf( + String, + { digest: String, algorithm: String } +); + +// Handler to login with a password. +// +// The Meteor client sets options.password to an object with keys +// 'digest' (set to SHA256(password)) and 'algorithm' ("sha-256"). +// +// For other DDP clients which don't have access to SHA, the handler +// also accepts the plaintext password in options.password as a string. +// +// (It might be nice if servers could turn the plaintext password +// option off. Or maybe it should be opt-in, not opt-out? +// Accounts.config option?) +// +// Note that neither password option is secure without SSL. // -// @param request {Object} with fields: -// user: either {username: (username)}, {email: (email)}, or {id: (userId)} -// A: hex encoded int. the client's public key for this exchange -// @returns {Object} with fields: -// identity: random string ID -// salt: random string ID -// B: hex encoded int. server's public key for this exchange -Meteor.methods({beginPasswordExchange: function (request) { - var self = this; - try { - check(request, { - user: userQueryValidator, - A: String - }); - var selector = selectorFromUserQuery(request.user); - - var user = Meteor.users.findOne(selector); - if (!user) - throw new Meteor.Error(403, "User not found"); - - if (!user.services || !user.services.password || - !user.services.password.srp) - throw new Meteor.Error(403, "User has no password set"); - - var verifier = user.services.password.srp; - var srp = new SRP.Server(verifier); - var challenge = srp.issueChallenge({A: request.A}); - - } catch (err) { - // Report login failure if the method fails, so that login hooks are - // called. If the method succeeds, login hooks will be called when - // the second step method ('login') is called. If a user calls - // 'beginPasswordExchange' but then never calls the second step - // 'login' method, no login hook will fire. - // The validate login hooks can mutate the exception to be thrown. - var attempt = Accounts._reportLoginFailure(self, 'beginPasswordExchange', arguments, { - type: 'password', - error: err, - userId: user && user._id - }); - throw attempt.error; - } - - // Save results so we can verify them later. - Accounts._setAccountData(this.connection.id, 'srpChallenge', - { userId: user._id, M: srp.M, HAMK: srp.HAMK } - ); - return challenge; -}}); - -// Handler to login with password via SRP. Checks the `M` value set by -// beginPasswordExchange. Accounts.registerLoginHandler("password", function (options) { - if (!options.srp) - return undefined; // don't handle - check(options.srp, {M: String}); - - // we're always called from within a 'login' method, so this should - // be safe. - var currentInvocation = DDP._CurrentInvocation.get(); - var serialized = Accounts._getAccountData(currentInvocation.connection.id, 'srpChallenge'); - if (!serialized || serialized.M !== options.srp.M) - return { - userId: serialized && serialized.userId, - error: new Meteor.Error(403, "Incorrect password") - }; - // Only can use challenges once. - Accounts._setAccountData(currentInvocation.connection.id, 'srpChallenge', undefined); - - var userId = serialized.userId; - var user = Meteor.users.findOne(userId); - // Was the user deleted since the start of this challenge? - if (!user) - return { - userId: userId, - error: new Meteor.Error(403, "User not found") - }; - - return { - userId: userId, - options: {HAMK: serialized.HAMK} - }; -}); - -// Handler to login with plaintext password. -// -// The meteor client doesn't use this, it is for other DDP clients who -// haven't implemented SRP. Since it sends the password in plaintext -// over the wire, it should only be run over SSL! -// -// Also, it might be nice if servers could turn this off. Or maybe it -// should be opt-in, not opt-out? Accounts.config option? -Accounts.registerLoginHandler("password", function (options) { - if (!options.password || !options.user) + if (! options.password || options.srp) return undefined; // don't handle - check(options, {user: userQueryValidator, password: String}); + check(options, { + user: userQueryValidator, + password: passwordValidator + }); - var selector = selectorFromUserQuery(options.user); - var user = Meteor.users.findOne(selector); - if (!user) - throw new Meteor.Error(403, "User not found"); + + var user = findUserFromUserQuery(options.user); if (!user.services || !user.services.password || - !user.services.password.srp) - return { - userId: user._id, - error: new Meteor.Error(403, "User has no password set") - }; + !(user.services.password.bcrypt || user.services.password.srp)) + throw new Meteor.Error(403, "User has no password set"); - // Just check the verifier output when the same identity and salt - // are passed. Don't bother with a full exchange. - var verifier = user.services.password.srp; - var newVerifier = SRP.generateVerifier(options.password, { - identity: verifier.identity, salt: verifier.salt}); + if (!user.services.password.bcrypt) { + if (typeof options.password === "string") { + // The client has presented a plaintext password, and the user is + // not upgraded to bcrypt yet. We don't attempt to tell the client + // to upgrade to bcrypt, because it might be a standalone DDP + // client doesn't know how to do such a thing. + var verifier = user.services.password.srp; + var newVerifier = SRP.generateVerifier(options.password, { + identity: verifier.identity, salt: verifier.salt}); - if (verifier.verifier !== newVerifier.verifier) + if (verifier.verifier !== newVerifier.verifier) { + return { + userId: user._id, + error: new Meteor.Error(403, "Incorrect password") + }; + } + + return {userId: user._id}; + } else { + // Tell the client to use the SRP upgrade process. + throw new Meteor.Error(400, "old password format", EJSON.stringify({ + format: 'srp', + identity: user.services.password.srp.identity + })); + } + } + + return checkPassword( + user, + options.password + ); +}); + +// Handler to login using the SRP upgrade path. To use this login +// handler, the client must provide: +// - srp: H(identity + ":" + password) +// - password: a string or an object with properties 'digest' and 'algorithm' +// +// We use `options.srp` to verify that the client knows the correct +// password without doing a full SRP flow. Once we've checked that, we +// upgrade the user to bcrypt and remove the SRP information from the +// user document. +// +// The client ends up using this login handler after trying the normal +// login handler (above), which throws an error telling the client to +// try the SRP upgrade path. +// +// XXX COMPAT WITH 0.8.1.3 +Accounts.registerLoginHandler("password", function (options) { + if (!options.srp || !options.password) + return undefined; // don't handle + + check(options, { + user: userQueryValidator, + srp: String, + password: passwordValidator + }); + + var user = findUserFromUserQuery(options.user); + + // Check to see if another simultaneous login has already upgraded + // the user record to bcrypt. + if (user.services && user.services.password && user.services.password.bcrypt) + return checkPassword(user, options.password); + + if (!(user.services && user.services.password && user.services.password.srp)) + throw new Meteor.Error(403, "User has no password set"); + + var v1 = user.services.password.srp.verifier; + var v2 = SRP.generateVerifier( + null, + { + hashedIdentityAndPassword: options.srp, + salt: user.services.password.srp.salt + } + ).verifier; + if (v1 !== v2) return { userId: user._id, error: new Meteor.Error(403, "Incorrect password") }; + // Upgrade to bcrypt on successful login. + var salted = hashPassword(options.password); + Meteor.users.update( + user._id, + { + $unset: { 'services.password.srp': 1 }, + $set: { 'services.password.bcrypt': salted } + } + ); + return {userId: user._id}; }); @@ -170,34 +254,47 @@ Accounts.registerLoginHandler("password", function (options) { /// // Let the user change their own password if they know the old -// password. Checks the `M` value set by beginPasswordExchange. -Meteor.methods({changePassword: function (options) { +// password. `oldPassword` and `newPassword` should be objects with keys +// `digest` and `algorithm` (representing the SHA256 of the password). +// +// XXX COMPAT WITH 0.8.1.3 +// Like the login method, if the user hasn't been upgraded from SRP to +// bcrypt yet, then this method will throw an 'old password format' +// error. The client should call the SRP upgrade login handler and then +// retry this method again. +// +// UNLIKE the login method, there is no way to avoid getting SRP upgrade +// errors thrown. The reasoning for this is that clients using this +// method directly will need to be updated anyway because we no longer +// support the SRP flow that they would have been doing to use this +// method previously. +Meteor.methods({changePassword: function (oldPassword, newPassword) { + check(oldPassword, passwordValidator); + check(newPassword, passwordValidator); + if (!this.userId) throw new Meteor.Error(401, "Must be logged in"); - check(options, { - // If options.M is set, it means we went through a challenge with the old - // password. For now, we don't allow changePassword without knowing the old - // password. - M: String, - srp: Match.Optional(SRP.matchVerifier), - password: Match.Optional(String) - }); - var serialized = Accounts._getAccountData(this.connection.id, 'srpChallenge'); - if (!serialized || serialized.M !== options.M) - throw new Meteor.Error(403, "Incorrect password"); - if (serialized.userId !== this.userId) - // No monkey business! - throw new Meteor.Error(403, "Incorrect password"); - // Only can use challenges once. - Accounts._setAccountData(this.connection.id, 'srpChallenge', undefined); + var user = Meteor.users.findOne(this.userId); + if (!user) + throw new Meteor.Error(403, "User not found"); - var verifier = options.srp; - if (!verifier && options.password) { - verifier = SRP.generateVerifier(options.password); + if (!user.services || !user.services.password || + (!user.services.password.bcrypt && !user.services.password.srp)) + throw new Meteor.Error(403, "User has no password set"); + + if (! user.services.password.bcrypt) { + throw new Meteor.Error(400, "old password format", EJSON.stringify({ + format: 'srp', + identity: user.services.password.srp.identity + })); } - if (!verifier) - throw new Meteor.Error(400, "Invalid verifier"); + + var result = checkPassword(user, oldPassword); + if (result.error) + throw result.error; + + var hashed = hashPassword(newPassword); // It would be better if this removed ALL existing tokens and replaced // the token for the current connection with a new one, but that would @@ -207,29 +304,28 @@ Meteor.methods({changePassword: function (options) { Meteor.users.update( { _id: this.userId }, { - $set: { 'services.password.srp': verifier }, + $set: { 'services.password.bcrypt': hashed }, $pull: { 'services.resume.loginTokens': { hashedToken: { $ne: currentToken } } } } ); - var ret = {passwordChanged: true}; - if (serialized) - ret.HAMK = serialized.HAMK; - return ret; + return {passwordChanged: true}; }}); // Force change the users password. -Accounts.setPassword = function (userId, newPassword) { +Accounts.setPassword = function (userId, newPlaintextPassword) { var user = Meteor.users.findOne(userId); if (!user) throw new Meteor.Error(403, "User not found"); - var newVerifier = SRP.generateVerifier(newPassword); - Meteor.users.update({_id: user._id}, { - $set: {'services.password.srp': newVerifier}}); + Meteor.users.update( + {_id: user._id}, + { $unset: {'services.password.srp': 1}, // XXX COMPAT WITH 0.8.1.3 + $set: {'services.password.bcrypt': hashPassword(newPlaintextPassword)} } + ); }; @@ -266,13 +362,16 @@ Accounts.sendResetPasswordEmail = function (userId, email) { var token = Random.secret(); var when = new Date(); + var tokenRecord = { + token: token, + email: email, + when: when + }; Meteor.users.update(userId, {$set: { - "services.password.reset": { - token: token, - email: email, - when: when - } + "services.password.reset": tokenRecord }}); + // before passing to template, update user object with new token + user.services.password.reset = tokenRecord; var resetPasswordUrl = Accounts.urls.resetPassword(token); @@ -312,17 +411,21 @@ Accounts.sendEnrollmentEmail = function (userId, email) { if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) throw new Error("No such email for user."); - var token = Random.secret(); var when = new Date(); + var tokenRecord = { + token: token, + email: email, + when: when + }; Meteor.users.update(userId, {$set: { - "services.password.reset": { - token: token, - email: email, - when: when - } + "services.password.reset": tokenRecord }}); + // before passing to template, update user object with new token + Meteor._ensure(user, "services", "password"); + user.services.password.reset = tokenRecord; + var enrollAccountUrl = Accounts.urls.enrollAccount(token); var options = { @@ -342,7 +445,7 @@ Accounts.sendEnrollmentEmail = function (userId, email) { // Take token from sendResetPasswordEmail or sendEnrollmentEmail, change // the users password, and log them in. -Meteor.methods({resetPassword: function (token, newVerifier) { +Meteor.methods({resetPassword: function (token, newPassword) { var self = this; return Accounts._loginMethod( self, @@ -351,10 +454,10 @@ Meteor.methods({resetPassword: function (token, newVerifier) { "password", function () { check(token, String); - check(newVerifier, SRP.matchVerifier); + check(newPassword, passwordValidator); var user = Meteor.users.findOne({ - "services.password.reset.token": ""+token}); + "services.password.reset.token": token}); if (!user) throw new Meteor.Error(403, "Token expired"); var email = user.services.password.reset.email; @@ -364,6 +467,8 @@ Meteor.methods({resetPassword: function (token, newVerifier) { error: new Meteor.Error(403, "Token has invalid email address") }; + var hashed = hashPassword(newPassword); + // NOTE: We're about to invalidate tokens on the user, who we might be // logged in as. Make sure to avoid logging ourselves out if this // happens. But also make sure not to leave the connection in a state @@ -376,7 +481,7 @@ Meteor.methods({resetPassword: function (token, newVerifier) { try { // Update the user record by: - // - Changing the password verifier to the new one + // - Changing the password to the new one // - Forgetting about the reset token that was just used // - Verifying their email, since they got the password reset via email. var affectedRecords = Meteor.users.update( @@ -385,9 +490,10 @@ Meteor.methods({resetPassword: function (token, newVerifier) { 'emails.address': email, 'services.password.reset.token': token }, - {$set: {'services.password.srp': newVerifier, + {$set: {'services.password.bcrypt': hashed, 'emails.$.verified': true}, - $unset: {'services.password.reset': 1}}); + $unset: {'services.password.reset': 1, + 'services.password.srp': 1}}); if (affectedRecords !== 1) return { userId: user._id, @@ -443,6 +549,13 @@ Accounts.sendVerificationEmail = function (userId, address) { {_id: userId}, {$push: {'services.email.verificationTokens': tokenRecord}}); + // before passing to template, update user object with new token + Meteor._ensure(user, "services", "email"); + if (! user.services.email.verificationTokens) { + user.services.email.verificationTokens = []; + } + user.services.email.verificationTokens.push(tokenRecord); + var verifyEmailUrl = Accounts.urls.verifyEmail(tokenRecord.token); var options = { @@ -528,8 +641,7 @@ var createUser = function (options) { check(options, Match.ObjectIncluding({ username: Match.Optional(String), email: Match.Optional(String), - password: Match.Optional(String), - srp: Match.Optional(SRP.matchVerifier) + password: Match.Optional(passwordValidator) })); var username = options.username; @@ -537,18 +649,12 @@ var createUser = function (options) { if (!username && !email) throw new Meteor.Error(400, "Need to set a username or email"); - // Raw password. The meteor client doesn't send this, but a DDP - // client that didn't implement SRP could send this. This should - // only be done over SSL. + var user = {services: {}}; if (options.password) { - if (options.srp) - throw new Meteor.Error(400, "Don't pass both password and srp in options"); - options.srp = SRP.generateVerifier(options.password); + var hashed = hashPassword(options.password); + user.services.password = { bcrypt: hashed }; } - var user = {services: {}}; - if (options.srp) - user.services.password = {srp: options.srp}; // XXX validate verifier if (username) user.username = username; if (email) diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index bd1ce9a6d1..e1e51d4aac 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -732,6 +732,86 @@ if (Meteor.isClient) (function () { } ]); + testAsyncMulti("passwords - srp to bcrypt upgrade", [ + logoutStep, + // Create user with old SRP credentials in the database. + function (test, expect) { + var self = this; + Meteor.call("testCreateSRPUser", expect(function (error, result) { + test.isFalse(error); + self.username = result; + })); + }, + // We are able to login with the old style credentials in the database. + function (test, expect) { + Meteor.loginWithPassword(this.username, 'abcdef', expect(function (error) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.call("testSRPUpgrade", this.username, expect(function (error) { + test.isFalse(error); + })); + }, + logoutStep, + // After the upgrade to bcrypt we're still able to login. + function (test, expect) { + Meteor.loginWithPassword(this.username, 'abcdef', expect(function (error) { + test.isFalse(error); + })); + }, + logoutStep, + function (test, expect) { + Meteor.call("removeUser", this.username, expect(function (error) { + test.isFalse(error); + })); + } + ]); + + testAsyncMulti("passwords - srp to bcrypt upgrade via password change", [ + logoutStep, + // Create user with old SRP credentials in the database. + function (test, expect) { + var self = this; + Meteor.call("testCreateSRPUser", expect(function (error, result) { + test.isFalse(error); + self.username = result; + })); + }, + // Log in with the plaintext password handler, which should NOT upgrade us to bcrypt. + function (test, expect) { + Accounts.callLoginMethod({ + methodName: "login", + methodArguments: [ { user: { username: this.username }, password: "abcdef" } ], + userCallback: expect(function (err) { + test.isFalse(err); + }) + }); + }, + function (test, expect) { + Meteor.call("testNoSRPUpgrade", this.username, expect(function (error) { + test.isFalse(error); + })); + }, + // Changing our password should upgrade us to bcrypt. + function (test, expect) { + Accounts.changePassword("abcdef", "abcdefg", expect(function (error) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.call("testSRPUpgrade", this.username, expect(function (error) { + test.isFalse(error); + })); + }, + // And after the upgrade we should be able to change our password again. + function (test, expect) { + Accounts.changePassword("abcdefg", "abcdef", expect(function (error) { + test.isFalse(error); + })); + }, + logoutStep + ]); }) (); @@ -778,16 +858,15 @@ if (Meteor.isServer) (function () { // set a new password. Accounts.setPassword(userId, 'new password'); user = Meteor.users.findOne(userId); - var oldVerifier = user.services.password.srp; - test.isTrue(user.services.password.srp); + var oldSaltedHash = user.services.password.bcrypt; + test.isTrue(oldSaltedHash); - // reset with the same password, see we get a different verifier + // reset with the same password, see we get a different salted hash Accounts.setPassword(userId, 'new password'); user = Meteor.users.findOne(userId); - var newVerifier = user.services.password.srp; - test.notEqual(oldVerifier.salt, newVerifier.salt); - test.notEqual(oldVerifier.identity, newVerifier.identity); - test.notEqual(oldVerifier.verifier, newVerifier.verifier); + var newSaltedHash = user.services.password.bcrypt; + test.isTrue(newSaltedHash); + test.notEqual(oldSaltedHash, newSaltedHash); // cleanup Meteor.users.remove(userId); diff --git a/packages/accounts-password/password_tests_setup.js b/packages/accounts-password/password_tests_setup.js index e9d110c936..fa4432f097 100644 --- a/packages/accounts-password/password_tests_setup.js +++ b/packages/accounts-password/password_tests_setup.js @@ -115,3 +115,39 @@ Meteor.methods({ Meteor.users.remove({ "username": username }); } }); + + +// Create a user that had previously logged in with SRP. + +Meteor.methods({ + testCreateSRPUser: function () { + var username = Random.id(); + Meteor.users.remove({username: username}); + var userId = Accounts.createUser({username: username}); + Meteor.users.update( + userId, + { '$set': { 'services.password.srp': { + "identity" : "iPNrshUEcpOSO5fRDu7o4RRDc9OJBCGGljYpcXCuyg9", + "salt" : "Dk3lFggdEtcHU3aKm6Odx7sdcaIrMskQxBbqtBtFzt6", + "verifier" : "2e8bce266b1357edf6952cc56d979db19f699ced97edfb2854b95972f820b0c7006c1a18e98aad40edf3fe111b87c52ef7dd06b320ce452d01376df2d560fdc4d8e74f7a97bca1f67b3cfaef34dee34dd6c76571c247d762624dc166dab5499da06bc9358528efa75bf74e2e7f5a80d09e60acf8856069ae5cfb080f2239ee76" + } } } + ); + return username; + }, + + testSRPUpgrade: function (username) { + var user = Meteor.users.findOne({username: username}); + if (user.services && user.services.password && user.services.password.srp) + throw new Error("srp wasn't removed"); + if (!(user.services && user.services.password && user.services.password.bcrypt)) + throw new Error("bcrypt wasn't added"); + }, + + testNoSRPUpgrade: function (username) { + var user = Meteor.users.findOne({username: username}); + if (user.services && user.services.password && user.services.password.bcrypt) + throw new Error("bcrypt was added"); + if (user.services && user.services.password && ! user.services.password.srp) + throw new Error("srp was removed"); + } +}); diff --git a/packages/accounts-ui-unstyled/login_buttons_dialogs.html b/packages/accounts-ui-unstyled/login_buttons_dialogs.html index f7ebdbffe6..85708f673b 100644 --- a/packages/accounts-ui-unstyled/login_buttons_dialogs.html +++ b/packages/accounts-ui-unstyled/login_buttons_dialogs.html @@ -1,5 +1,6 @@ <body> {{> _resetPasswordDialog}} + {{> _justResetPasswordDialog}} {{> _enrollAccountDialog}} {{> _justVerifiedEmailDialog}} {{> _configureLoginServiceDialog}} @@ -32,6 +33,16 @@ {{/if}} </template> +<template name="_justResetPasswordDialog"> + {{#if visible}} + <div class="accounts-dialog accounts-centered-dialog"> + Password reset. + You are now logged in as {{displayName}}. + <div class="login-button" id="just-verified-dismiss-button">Dismiss</div> + </div> + {{/if}} +</template> + <template name="_enrollAccountDialog"> {{#if inEnrollAccountFlow}} <div class="hide-background"></div> @@ -59,7 +70,8 @@ <template name="_justVerifiedEmailDialog"> {{#if visible}} <div class="accounts-dialog accounts-centered-dialog"> - Email verified + Email verified. + You are now logged in as {{displayName}}. <div class="login-button" id="just-verified-dismiss-button">Dismiss</div> </div> {{/if}} @@ -114,5 +126,3 @@ </div> {{/if}} </template> - - diff --git a/packages/accounts-ui-unstyled/login_buttons_dialogs.js b/packages/accounts-ui-unstyled/login_buttons_dialogs.js index cea8e9947d..e73aa15625 100644 --- a/packages/accounts-ui-unstyled/login_buttons_dialogs.js +++ b/packages/accounts-ui-unstyled/login_buttons_dialogs.js @@ -4,8 +4,8 @@ var loginButtonsSession = Accounts._loginButtonsSession; // // populate the session so that the appropriate dialogs are -// displayed by reading variables set by accounts-urls, which parses -// special URLs. since accounts-ui depends on accounts-urls, we are +// displayed by reading variables set by accounts-base, which parses +// special URLs. since accounts-ui depends on accounts-base, we are // guaranteed to have these set at this point. // @@ -63,6 +63,7 @@ var resetPassword = function () { loginButtonsSession.errorMessage(error.reason || "Unknown error"); } else { loginButtonsSession.set('resetPasswordToken', null); + loginButtonsSession.set('justResetPassword', true); Accounts._enableAutoLogin(); } }); @@ -72,6 +73,23 @@ Template._resetPasswordDialog.inResetPasswordFlow = function () { return loginButtonsSession.get('resetPasswordToken'); }; +// +// justResetPasswordDialog template +// + +Template._justResetPasswordDialog.events({ + 'click #just-verified-dismiss-button': function () { + loginButtonsSession.set('justResetPassword', false); + } +}); + +Template._justResetPasswordDialog.visible = function () { + return loginButtonsSession.get('justResetPassword'); +}; + +Template._justResetPasswordDialog.displayName = displayName; + + // // enrollAccountDialog template @@ -128,6 +146,8 @@ Template._justVerifiedEmailDialog.visible = function () { return loginButtonsSession.get('justVerifiedEmail'); }; +Template._justVerifiedEmailDialog.displayName = displayName; + // // loginButtonsMessagesDialog template diff --git a/packages/accounts-ui-unstyled/login_buttons_session.js b/packages/accounts-ui-unstyled/login_buttons_session.js index 78324d0e07..10c23a4905 100644 --- a/packages/accounts-ui-unstyled/login_buttons_session.js +++ b/packages/accounts-ui-unstyled/login_buttons_session.js @@ -14,6 +14,7 @@ var VALID_KEYS = [ 'resetPasswordToken', 'enrollAccountToken', 'justVerifiedEmail', + 'justResetPassword', 'configureLoginServiceDialogVisible', 'configureLoginServiceDialogServiceName', diff --git a/packages/accounts-ui/login_buttons.less b/packages/accounts-ui/login_buttons.less index 4d52183cf4..aeb1104564 100644 --- a/packages/accounts-ui/login_buttons.less +++ b/packages/accounts-ui/login_buttons.less @@ -48,7 +48,7 @@ //////////////////// LOGIN BUTTONS -@login-buttons-accounts-dialog-width: 198px; +@login-buttons-accounts-dialog-width: 250px; @login-buttons-color: #596595; @login-buttons-color-border: darken(@login-buttons-color, 10%); @login-buttons-color-active: lighten(@login-buttons-color, 10%); @@ -381,7 +381,7 @@ } #just-verified-dismiss-button, #messages-dialog-dismiss-button { - margin-top: 4px; + margin-top: 8px; } .hide-background { diff --git a/packages/amplify/package.js b/packages/amplify/package.js index 012777f8b1..8595bd8db6 100644 --- a/packages/amplify/package.js +++ b/packages/amplify/package.js @@ -3,5 +3,6 @@ Package.describe({ }); Package.on_use(function (api) { + api.use('jquery', 'client'); api.add_files('amplify.js', 'client'); }); diff --git a/packages/browser-policy-common/browser-policy-common.js b/packages/browser-policy-common/browser-policy-common.js index 9dd18f8556..f4c2afd4ec 100644 --- a/packages/browser-policy-common/browser-policy-common.js +++ b/packages/browser-policy-common/browser-policy-common.js @@ -19,9 +19,29 @@ WebApp.connectHandlers.use(function (req, res, next) { BrowserPolicy.framing._constructXFrameOptions(); var csp = BrowserPolicy.content && BrowserPolicy.content._constructCsp(); - if (xFrameOptions) + if (xFrameOptions) { res.setHeader("X-Frame-Options", xFrameOptions); - if (csp) + } + if (csp) { res.setHeader("Content-Security-Policy", csp); + } + next(); +}); + +// We use `rawConnectHandlers` to set X-Content-Type-Options on all +// requests, including static files. +// XXX We should probably use `rawConnectHandlers` for X-Frame-Options +// and Content-Security-Policy too, but let's make sure that doesn't +// break anything first (e.g. the OAuth popup flow won't work well with +// a CSP that disallows inline scripts). +WebApp.rawConnectHandlers.use(function (req, res, next) { + if (BrowserPolicy._runningTest()) + return next(); + + var contentTypeOptions = BrowserPolicy.content && + BrowserPolicy.content._xContentTypeOptions(); + if (contentTypeOptions) { + res.setHeader("X-Content-Type-Options", contentTypeOptions); + } next(); }); diff --git a/packages/browser-policy-content/browser-policy-content.js b/packages/browser-policy-content/browser-policy-content.js index 569bf9c4d0..157b164218 100644 --- a/packages/browser-policy-content/browser-policy-content.js +++ b/packages/browser-policy-content/browser-policy-content.js @@ -1,7 +1,8 @@ // By adding this package, you get the following default policy: // No eval or other string-to-code, and content can only be loaded from the // same origin as the app (except for XHRs and websocket connections, which can -// go to any origin). +// go to any origin). Browsers will also be told not to sniff content types +// away from declared content types (X-Content-Type-Options: nosniff). // // Apps should call BrowserPolicy.content.disallowInlineScripts() if they are // not using any inline script tags and are willing to accept an extra round @@ -32,6 +33,8 @@ // allowAllContentSameOrigin() // disallowAllContent() // +// You can allow content type sniffing by calling +// `BrowserPolicy.content.allowContentTypeSniffing()`. var cspSrcs; var cachedCsp; // Avoid constructing the header out of cspSrcs when possible. @@ -44,6 +47,9 @@ var keywords = { none: "'none'" }; +// If false, we set the X-Content-Type-Options header to 'nosniff'. +var contentSniffingAllowed = false; + BrowserPolicy.content = {}; var parseCsp = function (csp) { @@ -126,6 +132,7 @@ var setDefaultPolicy = function () { "connect-src *; " + "img-src data: 'self'; " + "style-src 'self' 'unsafe-inline';"); + contentSniffingAllowed = false; }; var setWebAppInlineScripts = function (value) { @@ -134,6 +141,9 @@ var setWebAppInlineScripts = function (value) { }; _.extend(BrowserPolicy.content, { + allowContentTypeSniffing: function () { + contentSniffingAllowed = true; + }, // Exported for tests and browser-policy-common. _constructCsp: function () { if (! cspSrcs || _.isEmpty(cspSrcs)) @@ -220,6 +230,12 @@ _.extend(BrowserPolicy.content, { "default-src": [] }; setWebAppInlineScripts(false); + }, + + _xContentTypeOptions: function () { + if (! contentSniffingAllowed) { + return "nosniff"; + } } }); diff --git a/packages/browser-policy/browser-policy-test.js b/packages/browser-policy/browser-policy-test.js index 48f999c9ba..1a2ed512bd 100644 --- a/packages/browser-policy/browser-policy-test.js +++ b/packages/browser-policy/browser-policy-test.js @@ -151,3 +151,10 @@ Tinytest.add("browser-policy - x-frame-options", function (test) { BrowserPolicy.framing.restrictToOrigin("bar.com"); }); }); + +Tinytest.add("browser-policy - X-Content-Type-Options", function (test) { + BrowserPolicy.content._reset(); + test.equal(BrowserPolicy.content._xContentTypeOptions(), "nosniff"); + BrowserPolicy.content.allowContentTypeSniffing(); + test.equal(BrowserPolicy.content._xContentTypeOptions(), undefined); +}); diff --git a/packages/check/match.js b/packages/check/match.js index 60783c6c97..73c7246883 100644 --- a/packages/check/match.js +++ b/packages/check/match.js @@ -7,7 +7,14 @@ var currentArgumentChecker = new Meteor.EnvironmentVariable; check = function (value, pattern) { // Record that check got called, if somebody cared. - var argChecker = currentArgumentChecker.get(); + // + // We use getOrNullIfOutsideFiber so that it's OK to call check() + // from non-Fiber server contexts; the downside is that if you forget to + // bindEnvironment on some random callback in your method/publisher, + // it might not find the argumentChecker and you'll get an error about + // not checking an argument that it looks like you're checking (instead + // of just getting a "Node code must run in a Fiber" error). + var argChecker = currentArgumentChecker.getOrNullIfOutsideFiber(); if (argChecker) argChecker.checking(value); try { diff --git a/packages/check/match_test.js b/packages/check/match_test.js index 2707e4aebd..c8f35d2e08 100644 --- a/packages/check/match_test.js +++ b/packages/check/match_test.js @@ -257,3 +257,30 @@ Tinytest.add("check - Match error path", function (test) { match({ "return": 0 }, { "return": String }, "[\"return\"]"); }); +// Regression test for https://github.com/meteor/meteor/issues/2136 +Meteor.isServer && Tinytest.addAsync("check - non-fiber check works", function (test, onComplete) { + var Fiber = Npm.require('fibers'); + + // We can only call test.isTrue inside normal Meteor Fibery code, so give us a + // bindEnvironment way to get back. + var report = Meteor.bindEnvironment(function (success) { + test.isTrue(success); + onComplete(); + }); + + // Get out of a fiber with process.nextTick and ensure that we can still use + // check. + process.nextTick(function () { + var success = true; + if (Fiber.current) + success = false; + if (success) { + try { + check(true, Boolean); + } catch (e) { + success = false; + } + } + report(success); + }); +}); diff --git a/packages/deps/deps.js b/packages/deps/deps.js index d05ad80052..4192f9236c 100644 --- a/packages/deps/deps.js +++ b/packages/deps/deps.js @@ -44,17 +44,21 @@ var _throwOrLog = function (from, e) { } }; -// Like `Meteor._noYieldsAllowed(function () { f(comp); })` but shorter, -// and doesn't clutter the stack with an extra frame on the client, -// where `_noYieldsAllowed` is a no-op. `f` may be a computation -// function or an onInvalidate callback. -var callWithNoYieldsAllowed = function (f, comp) { +// Takes a function `f`, and wraps it in a `Meteor._noYieldsAllowed` +// block if we are running on the server. On the client, returns the +// original function (since `Meteor._noYieldsAllowed` is a +// no-op). This has the benefit of not adding an unnecessary stack +// frame on the client. +var withNoYieldsAllowed = function (f) { if ((typeof Meteor === 'undefined') || Meteor.isClient) { - f(comp); + return f; } else { - Meteor._noYieldsAllowed(function () { - f(comp); - }); + return function () { + var args = arguments; + Meteor._noYieldsAllowed(function () { + f.apply(null, args); + }); + }; } }; @@ -140,7 +144,7 @@ _assign(Deps.Computation.prototype, { if (self.invalidated) { Deps.nonreactive(function () { - callWithNoYieldsAllowed(f, self); + withNoYieldsAllowed(f)(self); }); } else { self._onInvalidateCallbacks.push(f); @@ -164,7 +168,7 @@ _assign(Deps.Computation.prototype, { // self.invalidated === true. for(var i = 0, f; f = self._onInvalidateCallbacks[i]; i++) { Deps.nonreactive(function () { - callWithNoYieldsAllowed(f, self); + withNoYieldsAllowed(f)(self); }); } self._onInvalidateCallbacks = []; @@ -188,7 +192,7 @@ _assign(Deps.Computation.prototype, { var previousInCompute = inCompute; inCompute = true; try { - callWithNoYieldsAllowed(self._func, self); + withNoYieldsAllowed(self._func)(self); } finally { setCurrentComputation(previous); inCompute = false; diff --git a/packages/deps/package.js b/packages/deps/package.js index ca7d98b7be..caf23c6da5 100644 --- a/packages/deps/package.js +++ b/packages/deps/package.js @@ -6,7 +6,6 @@ Package.describe({ }); Package.on_use(function (api) { - api.use('underscore'); api.export('Deps'); api.add_files('deps.js'); api.add_files('deprecated.js'); diff --git a/packages/ejson/ejson.js b/packages/ejson/ejson.js index ec846156f9..798fb38f4b 100644 --- a/packages/ejson/ejson.js +++ b/packages/ejson/ejson.js @@ -110,14 +110,19 @@ var builtinConverters = [ return EJSON._isCustomType(obj); }, toJSONValue: function (obj) { - return {$type: obj.typeName(), $value: obj.toJSONValue()}; + var jsonValue = Meteor._noYieldsAllowed(function () { + return obj.toJSONValue(); + }); + return {$type: obj.typeName(), $value: jsonValue}; }, fromJSONValue: function (obj) { var typeName = obj.$type; if (!_.has(customTypes, typeName)) throw new Error("Custom EJSON type " + typeName + " is not defined"); var converter = customTypes[typeName]; - return converter(obj.$value); + return Meteor._noYieldsAllowed(function () { + return converter(obj.$value); + }); } } ]; diff --git a/packages/email/email.js b/packages/email/email.js index 4ff6155be8..1afabb7dea 100644 --- a/packages/email/email.js +++ b/packages/email/email.js @@ -71,9 +71,6 @@ EmailTest.restoreOutputStream = function () { var devModeSend = function (mc) { var devmode_mail_id = next_devmode_mail_id++; - // Make sure we use whatever stream was set at the time of the Email.send - // call even in the 'end' callback, in case there are multiple concurrent - // test runs. var stream = output_stream; // This approach does not prevent other writers to stdout from interleaving. diff --git a/packages/email/email_tests.js b/packages/email/email_tests.js index dbdddafeea..d3164ad99d 100644 --- a/packages/email/email_tests.js +++ b/packages/email/email_tests.js @@ -15,8 +15,6 @@ Tinytest.add("email - dev mode smoke test", function (test) { text: "This is the body\nof the message\nFrom us.", headers: {'X-Meteor-Test': 'a custom header'} }); - // Note that we use the local "stream" here rather than Email._output_stream - // in case a concurrent test run mutates Email._output_stream too. // XXX brittle if mailcomposer changes header order, etc test.equal(stream.getContentsAsString("utf8"), "====== BEGIN MAIL #0 ======\n" + diff --git a/packages/force-ssl/force_ssl_server.js b/packages/force-ssl/force_ssl_server.js index c9306f6774..c4cd067e08 100644 --- a/packages/force-ssl/force_ssl_server.js +++ b/packages/force-ssl/force_ssl_server.js @@ -1,3 +1,5 @@ +var url = Npm.require("url"); + // Unfortunately we can't use a connect middleware here since // sockjs installs itself prior to all existing listeners // (meaning prior to any connect middlewares) so we need to take @@ -39,10 +41,7 @@ httpServer.addListener('request', function (req, res) { if (!isLocal && !isSsl) { // connection is not cool. send a 302 redirect! - // if we don't have a host header, there's not a lot we can do. We - // don't know how to redirect them. - // XXX can we do better here? - var host = req.headers.host || 'no-host-header'; + var host = url.parse(Meteor.absoluteUrl()).hostname; // strip off the port number. If we went to a URL with a custom // port, we don't know what the custom SSL port is anyway. diff --git a/packages/html-tools/package.js b/packages/html-tools/package.js index ffad07546a..da455867fd 100644 --- a/packages/html-tools/package.js +++ b/packages/html-tools/package.js @@ -6,6 +6,7 @@ Package.describe({ Package.on_use(function (api) { api.use('htmljs'); + api.imply('htmljs'); api.export('HTMLTools'); diff --git a/packages/html-tools/tokenize.js b/packages/html-tools/tokenize.js index 85aaf7be3b..3bcf82fc75 100644 --- a/packages/html-tools/tokenize.js +++ b/packages/html-tools/tokenize.js @@ -208,24 +208,24 @@ var getChars = makeRegexMatcher(/^[^&<\u0000][^&<\u0000{]*/); getHTMLToken = HTMLTools.Parse.getHTMLToken = function (scanner, dataMode) { var result = null; if (scanner.getSpecialTag) { - var lastPos = -1; // Try to parse a "special tag" by calling out to the provided // `getSpecialTag` function. If the function returns `null` but - // consumes characters, it must have parsed a comment or something, - // so we loop and try it again. If it ever returns `null` without + // consumes characters, it must have parsed a comment, so we return null + // and allow the lexer to continue. If it ever returns `null` without // consuming anything, that means it didn't see anything interesting // so we look for a normal token. If it returns a truthy value, // the value must be an object. We wrap it in a Special token. - while ((! result) && scanner.pos > lastPos) { - lastPos = scanner.pos; - result = scanner.getSpecialTag( - scanner, - (dataMode === 'rcdata' ? TEMPLATE_TAG_POSITION.IN_RCDATA : - (dataMode === 'rawtext' ? TEMPLATE_TAG_POSITION.IN_RAWTEXT : - TEMPLATE_TAG_POSITION.ELEMENT))); - } + var lastPos = scanner.pos; + result = scanner.getSpecialTag( + scanner, + (dataMode === 'rcdata' ? TEMPLATE_TAG_POSITION.IN_RCDATA : + (dataMode === 'rawtext' ? TEMPLATE_TAG_POSITION.IN_RAWTEXT : + TEMPLATE_TAG_POSITION.ELEMENT))); + if (result) return { t: 'Special', v: result }; + else if (scanner.pos > lastPos) + return null; } var chars = getChars(scanner); diff --git a/packages/http/httpcall_tests.js b/packages/http/httpcall_tests.js index 0849254408..1c721ab00a 100644 --- a/packages/http/httpcall_tests.js +++ b/packages/http/httpcall_tests.js @@ -89,12 +89,16 @@ testAsyncMulti("httpcall - errors", [ test.isFalse(result); test.isFalse(error.response); }; - HTTP.call("GET", "http://asfd.asfd/", expect(unknownServerCallback)); + + // 0.0.0.0 is an illegal IP address, and thus should always give an error. + // If your ISP is intercepting DNS misses and serving ads, an obviously + // invalid URL (http://asdf.asdf) might produce an HTTP response. + HTTP.call("GET", "http://0.0.0.0/", expect(unknownServerCallback)); if (Meteor.isServer) { // test sync version try { - var unknownServerResult = HTTP.call("GET", "http://asfd.asfd/"); + var unknownServerResult = HTTP.call("GET", "http://0.0.0.0/"); unknownServerCallback(undefined, unknownServerResult); } catch (e) { unknownServerCallback(e, e.response); diff --git a/packages/less/plugin/compile-less.js b/packages/less/plugin/compile-less.js index 5b00d99293..7eccb5e42f 100644 --- a/packages/less/plugin/compile-less.js +++ b/packages/less/plugin/compile-less.js @@ -44,7 +44,7 @@ Plugin.registerSourceHandler("less", function (compileStep) { compileStep.error({ message: "Less compiler error: " + e.message, sourcePath: e.filename || compileStep.inputPath, - line: e.line - 1, // dunno why, but it matches + line: e.line, column: e.column + 1 }); return; diff --git a/packages/livedata/.npm/package/npm-shrinkwrap.json b/packages/livedata/.npm/package/npm-shrinkwrap.json index e846e2ff4d..2d029c59cc 100644 --- a/packages/livedata/.npm/package/npm-shrinkwrap.json +++ b/packages/livedata/.npm/package/npm-shrinkwrap.json @@ -4,23 +4,15 @@ "version": "0.7.2", "dependencies": { "websocket-driver": { - "version": "0.3.2" + "version": "0.3.4" } } }, "sockjs": { - "version": "0.3.8", + "version": "0.3.9", "dependencies": { "node-uuid": { "version": "1.3.3" - }, - "faye-websocket": { - "version": "0.7.0", - "dependencies": { - "websocket-driver": { - "version": "0.3.2" - } - } } } } diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index abb717f0a5..b3468cad37 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -181,7 +181,7 @@ var Connection = function (url, options) { // Reactive userId. self._userId = null; - self._userIdDeps = (typeof Deps !== "undefined") && new Deps.Dependency; + self._userIdDeps = new Deps.Dependency; // Block auto-reload while we're waiting for method responses. if (Meteor.isClient && Package.reload && !options.reloadWithOutstanding) { @@ -522,9 +522,18 @@ _.extend(Connection.prototype, { params: EJSON.clone(params), inactive: false, ready: false, - readyDeps: (typeof Deps !== "undefined") && new Deps.Dependency, + readyDeps: new Deps.Dependency, readyCallback: callbacks.onReady, - errorCallback: callbacks.onError + errorCallback: callbacks.onError, + connection: self, + remove: function() { + delete this.connection._subscriptions[this.id]; + this.ready && this.readyDeps.changed(); + }, + stop: function() { + this.connection._send({msg: 'unsub', id: id}); + this.remove(); + } }; self._send({msg: 'sub', id: id, name: name, params: params}); } @@ -534,15 +543,15 @@ _.extend(Connection.prototype, { stop: function () { if (!_.has(self._subscriptions, id)) return; - self._send({msg: 'unsub', id: id}); - delete self._subscriptions[id]; + + self._subscriptions[id].stop(); }, ready: function () { // return false if we've unsubscribed. if (!_.has(self._subscriptions, id)) return false; var record = self._subscriptions[id]; - record.readyDeps && record.readyDeps.depend(); + record.readyDeps.depend(); return record.ready; } }; @@ -891,8 +900,7 @@ _.extend(Connection.prototype, { // but it doesn't seem worth it yet to have a special API for // subscriptions to preserve after unit tests. if (sub.name !== 'meteor_autoupdate_clientVersions') { - self._send({msg: 'unsub', id: id}); - delete self._subscriptions[id]; + self._subscriptions[id].stop(); } }); }, @@ -1297,7 +1305,7 @@ _.extend(Connection.prototype, { return; subRecord.readyCallback && subRecord.readyCallback(); subRecord.ready = true; - subRecord.readyDeps && subRecord.readyDeps.changed(); + subRecord.readyDeps.changed(); }); }); }, @@ -1353,7 +1361,7 @@ _.extend(Connection.prototype, { if (!_.has(self._subscriptions, msg.id)) return; var errorCallback = self._subscriptions[msg.id].errorCallback; - delete self._subscriptions[msg.id]; + self._subscriptions[msg.id].remove(); if (errorCallback && msg.error) { errorCallback(new Meteor.Error( msg.error.error, msg.error.reason, msg.error.details)); diff --git a/packages/livedata/livedata_connection_tests.js b/packages/livedata/livedata_connection_tests.js index 1c404669b6..334cd59a3b 100644 --- a/packages/livedata/livedata_connection_tests.js +++ b/packages/livedata/livedata_connection_tests.js @@ -122,13 +122,14 @@ Tinytest.add("livedata stub - subscribe", function (test) { test.isTrue(callback_fired); Deps.flush(); test.isTrue(reactivelyReady); - autorunHandle.stop(); // Unsubscribe. sub.stop(); test.length(stream.sent, 1); message = JSON.parse(stream.sent.shift()); test.equal(message, {msg: 'unsub', id: id}); + Deps.flush(); + test.isFalse(reactivelyReady); // Resubscribe. conn.subscribe('my_data'); @@ -161,13 +162,18 @@ Tinytest.add("livedata stub - reactive subscribe", function (test) { }; // Subscribe to some subs. - var stopperHandle; + var stopperHandle, completerHandle; var autorunHandle = Deps.autorun(function () { conn.subscribe("foo", rFoo.get(), onReady(rFoo.get())); conn.subscribe("bar", rBar.get(), onReady(rBar.get())); - conn.subscribe("completer", onReady("completer")); + completerHandle = conn.subscribe("completer", onReady("completer")); stopperHandle = conn.subscribe("stopper", onReady("stopper")); }); + + var completerReady; + var readyAutorunHandle = Deps.autorun(function() { + completerReady = completerHandle.ready(); + }); // Check sub messages. (Assume they are sent in the order executed.) test.length(stream.sent, 4); @@ -193,11 +199,15 @@ Tinytest.add("livedata stub - reactive subscribe", function (test) { // Haven't hit onReady yet. test.equal(onReadyCount, {}); + Deps.flush(); + test.isFalse(completerReady); // "completer" gets ready now. its callback should fire. stream.receive({msg: 'ready', 'subs': [idCompleter]}); test.equal(onReadyCount, {completer: 1}); test.length(stream.sent, 0); + Deps.flush(); + test.isTrue(completerReady); // Stop 'stopper'. stopperHandle.stop(); @@ -206,12 +216,15 @@ Tinytest.add("livedata stub - reactive subscribe", function (test) { test.equal(message, {msg: 'unsub', id: idStopper}); test.equal(onReadyCount, {completer: 1}); + Deps.flush(); + test.isTrue(completerReady); // Change the foo subscription and flush. We should sub to the new foo // subscription, re-sub to the stopper subscription, and then unsub from the old // foo subscription. The bar subscription should be unaffected. The completer // subscription should *NOT* call its new onReady callback, because we only // call at most one onReady for a given reactively-saved subscription. + // The completerHandle should have been reestablished to the ready handle. rFoo.set("foo2"); Deps.flush(); test.length(stream.sent, 3); @@ -230,6 +243,7 @@ Tinytest.add("livedata stub - reactive subscribe", function (test) { test.equal(message, {msg: 'unsub', id: idFoo1}); test.equal(onReadyCount, {completer: 1}); + test.isTrue(completerReady); // Ready the stopper and bar subs. Completing stopper should call only the // onReady from the new subscription because they were separate subscriptions @@ -244,6 +258,8 @@ Tinytest.add("livedata stub - reactive subscribe", function (test) { // time. autorunHandle.stop(); Deps.flush(); + test.isFalse(completerReady); + readyAutorunHandle.stop(); test.length(stream.sent, 4); // The order of unsubs here is not important. @@ -257,6 +273,86 @@ Tinytest.add("livedata stub - reactive subscribe", function (test) { test.equal(actualIds, expectedIds); }); +Tinytest.add("livedata stub - reactive subscribe handle correct", function (test) { + var stream = new StubStream(); + var conn = newConnection(stream); + + startAndConnect(test, stream); + + var rFoo = new ReactiveVar('foo1'); + + // Subscribe to some subs. + var fooHandle, fooReady; + var autorunHandle = Deps.autorun(function () { + fooHandle = conn.subscribe("foo", rFoo.get()); + Deps.autorun(function() { + fooReady = fooHandle.ready(); + }); + }); + + var message = JSON.parse(stream.sent.shift()); + var idFoo1 = message.id; + delete message.id; + test.equal(message, {msg: 'sub', name: 'foo', params: ['foo1']}); + + // Not ready yet + Deps.flush(); + test.isFalse(fooHandle.ready()); + test.isFalse(fooReady); + + // change the argument to foo. This will make a new handle, which isn't ready + // the ready autorun should invalidate, reading the new false value, and + // setting up a new dep which goes true soon + rFoo.set("foo2"); + Deps.flush(); + test.length(stream.sent, 2); + + message = JSON.parse(stream.sent.shift()); + var idFoo2 = message.id; + delete message.id; + test.equal(message, {msg: 'sub', name: 'foo', params: ['foo2']}); + + message = JSON.parse(stream.sent.shift()); + test.equal(message, {msg: 'unsub', id: idFoo1}); + + Deps.flush(); + test.isFalse(fooHandle.ready()); + test.isFalse(fooReady); + + // "foo" gets ready now. The handle should be ready and the autorun rerun + stream.receive({msg: 'ready', 'subs': [idFoo2]}); + test.length(stream.sent, 0); + Deps.flush(); + test.isTrue(fooHandle.ready()); + test.isTrue(fooReady); + + // change the argument to foo. This will make a new handle, which isn't ready + // the ready autorun should invalidate, making fooReady false too + rFoo.set("foo3"); + Deps.flush(); + test.length(stream.sent, 2); + + message = JSON.parse(stream.sent.shift()); + var idFoo3 = message.id; + delete message.id; + test.equal(message, {msg: 'sub', name: 'foo', params: ['foo3']}); + + message = JSON.parse(stream.sent.shift()); + test.equal(message, {msg: 'unsub', id: idFoo2}); + + Deps.flush(); + test.isFalse(fooHandle.ready()); + test.isFalse(fooReady); + + // "foo" gets ready again + stream.receive({msg: 'ready', 'subs': [idFoo3]}); + test.length(stream.sent, 0); + Deps.flush(); + test.isTrue(fooHandle.ready()); + test.isTrue(fooReady); + + autorunHandle.stop(); +}); Tinytest.add("livedata stub - this", function (test) { var stream = new StubStream(); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index b6295d13a8..0ff5dd81f3 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -403,13 +403,16 @@ _.extend(Session.prototype, { }); }, - // Destroy this session. Stop all processing and tear everything - // down. If a socket was attached, close it. - destroy: function () { + // Destroy this session and unregister it at the server. + close: function () { var self = this; + // Destroy this session, even if it's not registered at the + // server. Stop all processing and tear everything down. If a socket + // was attached, close it. + // Already destroyed. - if (!self.inQueue) + if (! self.inQueue) return; if (self.heartbeat) { @@ -430,7 +433,7 @@ _.extend(Session.prototype, { "livedata", "sessions", -1); Meteor.defer(function () { - // stop callbacks can yield, so we defer this on destroy. + // stop callbacks can yield, so we defer this on close. // sub._isDeactivated() detects that we set inQueue to null and // treats it as semi-deactivated (it will ignore incoming callbacks, etc). self._deactivateAllSubscriptions(); @@ -441,19 +444,9 @@ _.extend(Session.prototype, { callback(); }); }); - }, - // Destroy this session and unregister it at the server. - close: function () { - var self = this; - - // Unconditionally destroy this session, even if it's not - // registered at the server. - self.destroy(); - - // Unregister the session. This will also call `destroy`, but - // that's OK because `destroy` is idempotent. - self.server._closeSession(self); + // Unregister the session. + self.server._removeSession(self); }, // Send a message (doing nothing if no socket is connected right now.) @@ -920,6 +913,9 @@ _.extend(Subscription.prototype, { try { var res = maybeAuditArgumentChecks( self._handler, self, EJSON.clone(self._params), + // It's OK that this would look weird for universal subscriptions, + // because they have no arguments so there can never be an + // audit-argument-checks failure. "publisher '" + self._name + "'"); } catch (e) { self.error(e); @@ -1035,7 +1031,8 @@ _.extend(Subscription.prototype, { _recreate: function () { var self = this; return new Subscription( - self._session, self._handler, self._subscriptionId, self._params); + self._session, self._handler, self._subscriptionId, self._params, + self._name); }, error: function (error) { @@ -1217,37 +1214,40 @@ _.extend(Server.prototype, { _handleConnect: function (socket, msg) { var self = this; + + // The connect message must specify a version and an array of supported + // versions, and it must claim to support what it is proposing. + if (!(typeof (msg.version) === 'string' && + _.isArray(msg.support) && + _.all(msg.support, _.isString) && + _.contains(msg.support, msg.version))) { + socket.send(stringifyDDP({msg: 'failed', + version: SUPPORTED_DDP_VERSIONS[0]})); + socket.close(); + return; + } + // In the future, handle session resumption: something like: // socket._meteorSession = self.sessions[msg.session] var version = calculateVersion(msg.support, SUPPORTED_DDP_VERSIONS); - if (msg.version === version) { - // Creating a new session - socket._meteorSession = new Session(self, version, socket, self.options); - self.sessions[socket._meteorSession.id] = socket._meteorSession; - self.onConnectionHook.each(function (callback) { - if (socket._meteorSession) - callback(socket._meteorSession.connectionHandle); - return true; - }); - } else if (!msg.version) { - // connect message without a version. This means an old (pre-pre1) - // client is trying to connect. If we just disconnect the - // connection, they'll retry right away. Instead, just pause for a - // bit (randomly distributed so as to avoid synchronized swarms) - // and hold the connection open. - var timeout = 1000 * (30 + Random.fraction() * 60); - // drop all future data coming over this connection on the - // floor. We don't want to confuse things. - socket.removeAllListeners('data'); - Meteor.setTimeout(function () { - socket.send(stringifyDDP({msg: 'failed', version: version})); - socket.close(); - }, timeout); - } else { + if (msg.version !== version) { + // The best version to use (according to the client's stated preferences) + // is not the one the client is trying to use. Inform them about the best + // version to use. socket.send(stringifyDDP({msg: 'failed', version: version})); socket.close(); + return; } + + // Yay, version matches! Create a new session. + socket._meteorSession = new Session(self, version, socket, self.options); + self.sessions[socket._meteorSession.id] = socket._meteorSession; + self.onConnectionHook.each(function (callback) { + if (socket._meteorSession) + callback(socket._meteorSession.connectionHandle); + return true; + }); }, /** * Register a publish handler function. @@ -1323,11 +1323,10 @@ _.extend(Server.prototype, { } }, - _closeSession: function (session) { + _removeSession: function (session) { var self = this; if (self.sessions[session.id]) { delete self.sessions[session.id]; - session.destroy(); } }, diff --git a/packages/livedata/package.js b/packages/livedata/package.js index 8eaa60f32b..9eb95a68bc 100644 --- a/packages/livedata/package.js +++ b/packages/livedata/package.js @@ -7,7 +7,11 @@ Package.describe({ // because it's the same library used as a server in sockjs, and it's easiest to // deal with a single websocket implementation. (Plus, its maintainer is easy // to work with on pull requests.) -Npm.depends({sockjs: "0.3.8", "faye-websocket": "0.7.2"}); +// +// (By listing faye-websocket first, it's more likely that npm deduplication +// will prevent a second copy of faye-websocket from being installed inside +// sockjs.) +Npm.depends({"faye-websocket": "0.7.2", sockjs: "0.3.9"}); Package.on_use(function (api) { api.use(['check', 'random', 'ejson', 'json', 'underscore', 'deps', diff --git a/packages/localstorage/localstorage.js b/packages/localstorage/localstorage.js index fe6ff10f17..fb72c0bf59 100644 --- a/packages/localstorage/localstorage.js +++ b/packages/localstorage/localstorage.js @@ -1,32 +1,33 @@ // Meteor._localStorage is not an ideal name, but we can change it later. -if (window.localStorage) { - // Let's test to make sure that localStorage actually works. For example, in - // Safari with private browsing on, window.localStorage exists but actually - // trying to use it throws. +// Let's test to make sure that localStorage actually works. For example, in +// Safari with private browsing on, window.localStorage exists but actually +// trying to use it throws. +// Accessing window.localStorage can also immediately throw an error in IE (#1291). - var key = '_localstorage_test_' + Random.id(); - var retrieved; - try { +var key = '_localstorage_test_' + Random.id(); +var retrieved; +try { + if (window.localStorage) { window.localStorage.setItem(key, key); retrieved = window.localStorage.getItem(key); window.localStorage.removeItem(key); - } catch (e) { - // ... ignore - } - if (key === retrieved) { - Meteor._localStorage = { - getItem: function (key) { - return window.localStorage.getItem(key); - }, - setItem: function (key, value) { - window.localStorage.setItem(key, value); - }, - removeItem: function (key) { - window.localStorage.removeItem(key); - } - }; } +} catch (e) { + // ... ignore +} +if (key === retrieved) { + Meteor._localStorage = { + getItem: function (key) { + return window.localStorage.getItem(key); + }, + setItem: function (key, value) { + window.localStorage.setItem(key, value); + }, + removeItem: function (key) { + window.localStorage.removeItem(key); + } + }; } if (!Meteor._localStorage) { diff --git a/packages/meteor-developer/meteor_developer_client.js b/packages/meteor-developer/meteor_developer_client.js index 239afec978..82d7ca7c98 100644 --- a/packages/meteor-developer/meteor_developer_client.js +++ b/packages/meteor-developer/meteor_developer_client.js @@ -4,7 +4,13 @@ MeteorDeveloperAccounts = {}; // @param credentialRequestCompleteCallback {Function} Callback function to call on // completion. Takes one argument, credentialToken on success, or Error on // error. -var requestCredential = function (credentialRequestCompleteCallback) { +var requestCredential = function (options, credentialRequestCompleteCallback) { + // support a callback without options + if (! credentialRequestCompleteCallback && typeof options === "function") { + credentialRequestCompleteCallback = options; + options = null; + } + var config = ServiceConfiguration.configurations.findOne({ service: 'meteor-developer' }); @@ -20,8 +26,12 @@ var requestCredential = function (credentialRequestCompleteCallback) { METEOR_DEVELOPER_URL + "/oauth2/authorize?" + "state=" + credentialToken + "&response_type=code&" + - "client_id=" + config.clientId + - "&redirect_uri=" + Meteor.absoluteUrl("_oauth/meteor-developer?close"); + "client_id=" + config.clientId; + + if (options && options.userEmail) + loginUrl += '&user_email=' + encodeURIComponent(options.userEmail); + + loginUrl += "&redirect_uri=" + Meteor.absoluteUrl("_oauth/meteor-developer?close"); OAuth.showPopup( loginUrl, diff --git a/packages/meteor/dynamics_browser.js b/packages/meteor/dynamics_browser.js index 06c6860612..4700f1f87d 100644 --- a/packages/meteor/dynamics_browser.js +++ b/packages/meteor/dynamics_browser.js @@ -12,6 +12,10 @@ _.extend(Meteor.EnvironmentVariable.prototype, { return currentValues[this.slot]; }, + getOrNullIfOutsideFiber: function () { + return this.get(); + }, + withValue: function (value, func) { var saved = currentValues[this.slot]; try { diff --git a/packages/meteor/dynamics_nodejs.js b/packages/meteor/dynamics_nodejs.js index 6dc2add875..87156cb56c 100644 --- a/packages/meteor/dynamics_nodejs.js +++ b/packages/meteor/dynamics_nodejs.js @@ -24,6 +24,25 @@ _.extend(Meteor.EnvironmentVariable.prototype, { Fiber.current._meteor_dynamics[this.slot]; }, + // Most Meteor code ought to run inside a fiber, and the + // _nodeCodeMustBeInFiber assertion helps you remember to include appropriate + // bindEnvironment calls (which will get you the *right value* for your + // environment variables, on the server). + // + // In some very special cases, it's more important to run Meteor code on the + // server in non-Fiber contexts rather than to strongly enforce the safeguard + // against forgetting to use bindEnvironment. For example, using `check` in + // some top-level constructs like connect handlers without needing unnecessary + // Fibers on every request is more important that possibly failing to find the + // correct argumentChecker. So this function is just like get(), but it + // returns null rather than throwing when called from outside a Fiber. (On the + // client, it is identical to get().) + getOrNullIfOutsideFiber: function () { + if (!Fiber.current) + return null; + return this.get(); + }, + withValue: function (value, func) { Meteor._nodeCodeMustBeInFiber(); diff --git a/packages/meteor/errors.js b/packages/meteor/errors.js index 06e315f94b..bc76bdd8aa 100644 --- a/packages/meteor/errors.js +++ b/packages/meteor/errors.js @@ -1,11 +1,3 @@ -// http://davidshariff.com/blog/javascript-inheritance-patterns/ -var inherits = function (child, parent) { - var tmp = function () {}; - tmp.prototype = parent.prototype; - child.prototype = new tmp; - child.prototype.constructor = child; -}; - // Makes an error subclass which properly contains a stack trace in most // environments. constructor can set fields on `this` (and should probably set // `message`, which is what gets displayed at the top of a stack trace). @@ -34,7 +26,7 @@ Meteor.makeErrorType = function (name, constructor) { return self; }; - inherits(errorClass, Error); + Meteor._inherits(errorClass, Error); return errorClass; }; diff --git a/packages/meteor/helpers.js b/packages/meteor/helpers.js index 341df557b9..9ed6f64533 100644 --- a/packages/meteor/helpers.js +++ b/packages/meteor/helpers.js @@ -115,7 +115,7 @@ _.extend(Meteor, { // Sets child's prototype to a new object whose prototype is parent's // prototype. Used as: - // Meteor._inherit(ClassB, ClassA). + // Meteor._inherits(ClassB, ClassA). // _.extend(ClassB.prototype, { ... }) // Inspired by CoffeeScript's `extend` and Google Closure's `goog.inherits`. _inherits: function (Child, Parent) { diff --git a/packages/minifiers/.npm/package/npm-shrinkwrap.json b/packages/minifiers/.npm/package/npm-shrinkwrap.json index 4b424c691b..fbe29a32ed 100644 --- a/packages/minifiers/.npm/package/npm-shrinkwrap.json +++ b/packages/minifiers/.npm/package/npm-shrinkwrap.json @@ -17,13 +17,13 @@ } }, "uglify-js": { - "version": "2.4.7", + "version": "2.4.13", "dependencies": { "async": { - "version": "0.2.9" + "version": "0.2.10" }, "source-map": { - "version": "0.1.31", + "version": "0.1.33", "dependencies": { "amdefine": { "version": "0.1.0" @@ -39,7 +39,7 @@ } }, "uglify-to-browserify": { - "version": "1.0.1" + "version": "1.0.2" } } } diff --git a/packages/minifiers/minifiers.js b/packages/minifiers/minifiers.js index a73cfc306e..189e5392e6 100644 --- a/packages/minifiers/minifiers.js +++ b/packages/minifiers/minifiers.js @@ -91,6 +91,17 @@ CssTools = { _.each(ast.stylesheet.rules, function(rule, ruleIndex) { var basePath = path.dirname(rule.position.source); + // Set the correct basePath based on how the linked asset will be served. + // XXX This is wrong. We are coupling the information about how files will + // be served by the web server to the information how they were stored + // originally on the filesystem in the project structure. Ideally, there + // should be some module that tells us precisely how each asset will be + // served but for now we are just assuming that everything that comes from + // a folder starting with "/packages/" is served on the same path as + // it was on the filesystem and everything else is served on root "/". + if (! basePath.match(/^\/?packages\//i)) + basePath = "/"; + _.each(rule.declarations, function(declaration, declarationIndex) { var parts, resource, absolutePath, quotes, oldCssUrl, newCssUrl; var value = declaration.value; diff --git a/packages/minifiers/package.js b/packages/minifiers/package.js index 0815ea3fa4..6f25acdadf 100644 --- a/packages/minifiers/package.js +++ b/packages/minifiers/package.js @@ -4,7 +4,7 @@ Package.describe({ }); Npm.depends({ - "uglify-js": "2.4.7", + "uglify-js": "2.4.13", "css-parse": "https://github.com/reworkcss/css-parse/tarball/aa7e23285375ca621dd20250bac0266c6d8683a5", "css-stringify": "https://github.com/reworkcss/css-stringify/tarball/a7fe6de82e055d41d1c5923ec2ccef06f2a45efa" }); diff --git a/packages/minifiers/urlrewriting-tests.js b/packages/minifiers/urlrewriting-tests.js index 87e74fa3a9..27c7996220 100644 --- a/packages/minifiers/urlrewriting-tests.js +++ b/packages/minifiers/urlrewriting-tests.js @@ -4,8 +4,7 @@ Tinytest.add("minifiers - url rewriting when merging", function (test) { return "body { color: green; background: top center url(" + backgroundPath + ") black, bottom center url(" + backgroundPath + "); }" }; - var filename = 'dir/subdir/style.css'; - var parseOptions = { source: filename, position: true }; + var parseOptions = { source: null, position: true }; var t = function(relativeUrl, absoluteUrl, desc) { var ast1 = CssTools.parseCss(stylesheet(relativeUrl), parseOptions); @@ -15,17 +14,30 @@ Tinytest.add("minifiers - url rewriting when merging", function (test) { test.equal(CssTools.stringifyCss(ast1), CssTools.stringifyCss(ast2), desc); }; - t('../image.png', 'dir/image.png', 'parent directory'); - t('./../image.png', 'dir/image.png', 'parent directory'); - t('../subdir2/image.png', 'dir/subdir2/image.png', 'cousin directory'); + parseOptions.source = 'packages/nameOfPackage/style.css'; + t('../image.png', 'packages/image.png', 'parent directory'); + t('./../image.png', 'packages/image.png', 'parent directory'); + t('../nameOfPackage2/image.png', 'packages/nameOfPackage2/image.png', 'cousin directory'); t('../../image.png', 'image.png', 'grand parent directory'); - t('./image.png', 'dir/subdir/image.png', 'current directory'); - t('./child/image.png', 'dir/subdir/child/image.png', 'child directory'); - t('child/image.png', 'dir/subdir/child/image.png', 'child directory'); + t('./image.png', 'packages/nameOfPackage/image.png', 'current directory'); + t('./child/image.png', 'packages/nameOfPackage/child/image.png', 'child directory'); + t('child/image.png', 'packages/nameOfPackage/child/image.png', 'child directory'); + t('/image.png', '/image.png', 'absolute url'); + t('"/image.png"', '"/image.png"', 'double quoted url'); + t("'/image.png'", "'/image.png'", 'single quoted url'); + t('"./../image.png"', '"packages/image.png"', 'quoted parent directory'); + t('http://i.imgur.com/fBcdJIh.gif', 'http://i.imgur.com/fBcdJIh.gif', 'complete URL'); + t('"http://i.imgur.com/fBcdJIh.gif"', '"http://i.imgur.com/fBcdJIh.gif"', 'complete quoted URL'); + t('data:image/png;base64,iVBORw0K=', 'data:image/png;base64,iVBORw0K=', 'data URI'); + t('http://', 'http://', 'malformed URL'); + + parseOptions.source = 'application/client/dir/other-style.css'; + t('./image.png', '/image.png', 'base path is root'); + t('./child/image.png', '/child/image.png', 'child directory from root'); + t('child/image.png', '/child/image.png', 'child directory from root'); t('/image.png', '/image.png', 'absolute url'); t('"/image.png"', '"/image.png"', 'double quoted url'); t("'/image.png'", "'/image.png'", 'single quoted url'); - t('"./../image.png"', '"dir/image.png"', 'quoted parent directory'); t('http://i.imgur.com/fBcdJIh.gif', 'http://i.imgur.com/fBcdJIh.gif', 'complete URL'); t('"http://i.imgur.com/fBcdJIh.gif"', '"http://i.imgur.com/fBcdJIh.gif"', 'complete quoted URL'); t('data:image/png;base64,iVBORw0K=', 'data:image/png;base64,iVBORw0K=', 'data URI'); diff --git a/packages/minimongo/helpers.js b/packages/minimongo/helpers.js index ab8cc78b6f..759ce0ca62 100644 --- a/packages/minimongo/helpers.js +++ b/packages/minimongo/helpers.js @@ -8,7 +8,7 @@ isArray = function (x) { // XXX maybe this should be EJSON.isObject, though EJSON doesn't know about // RegExp // XXX note that _type(undefined) === 3!!!! -isPlainObject = function (x) { +isPlainObject = LocalCollection._isPlainObject = function (x) { return x && LocalCollection._f._type(x) === 3; }; diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index f9fc5debf9..3e688fe352 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -113,20 +113,16 @@ LocalCollection.Cursor = function (collection, selector, options) { self._transform = LocalCollection.wrapTransform(options.transform); - // db_objects is an array of the objects that match the cursor. (It's always - // an array, never an IdMap: LocalCollection.Cursor is always ordered.) - self.db_objects = null; - self.cursor_pos = 0; - // by default, queries register w/ Deps when it is available. if (typeof Deps !== "undefined") self.reactive = (options.reactive === undefined) ? true : options.reactive; }; +// Since we don't actually have a "nextObject" interface, there's really no +// reason to have a "rewind" interface. All it did was make multiple calls +// to fetch/map/forEach return nothing the second time. +// XXX COMPAT WITH 0.8.1 LocalCollection.Cursor.prototype.rewind = function () { - var self = this; - self.db_objects = null; - self.cursor_pos = 0; }; LocalCollection.prototype.findOne = function (selector, options) { @@ -150,25 +146,52 @@ LocalCollection.prototype.findOne = function (selector, options) { LocalCollection.Cursor.prototype.forEach = function (callback, thisArg) { var self = this; - if (self.db_objects === null) - self.db_objects = self._getRawObjects({ordered: true}); + var docs; + var needsClone = true; + if (self.reactive && Deps.active) { + // Ensure that we invalidate the current computation if the result of this + // query changes. We also piggy-back on top of the query done by + // observeChanges so we don't need to do another query. + var computation = Deps.currentComputation; + var invalidate = function () { + computation.invalidate(); + }; + var initial = true; + docs = []; + // observeChanges will stop() when this computation is invalidated + self.observeChanges({ + added: function (id, fields) { + if (initial) { + fields._id = id; + docs.push(fields); + } else { + invalidate(); + } + }, + changed: invalidate, + removed: invalidate, + movedBefore: invalidate + }); + initial = false; + needsClone = false; // observeChanges gives us cloned docs + } else { + docs = self._getRawObjects({ordered: true}); + } - if (self.reactive) - self._depend({ - addedBefore: true, - removed: true, - changed: true, - movedBefore: true}); - - while (self.cursor_pos < self.db_objects.length) { - var elt = EJSON.clone(self.db_objects[self.cursor_pos]); - if (self.projectionFn) + _.each(docs, function (elt, i) { + if (self.projectionFn) { elt = self.projectionFn(elt); + } else if (needsClone) { + // projection functions always clone the pieces they use, and + // observeChanges callbacks got a cloned document, but otherwise we have + // to do it here. + elt = EJSON.clone(elt); + } + if (self._transform) elt = self._transform(elt); - callback.call(thisArg, elt, self.cursor_pos, self); - ++self.cursor_pos; - } + callback.call(thisArg, elt, i, self); + }); }; LocalCollection.Cursor.prototype.getTransform = function () { @@ -196,14 +219,34 @@ LocalCollection.Cursor.prototype.fetch = function () { LocalCollection.Cursor.prototype.count = function () { var self = this; - if (self.reactive) - self._depend({added: true, removed: true}, - true /* allow the observe to be unordered */); + if (self.reactive && Deps.active) { + // Ensure that we invalidate the current computation if the result of this + // query changes. We also piggy-back on top of the query done by + // observeChanges so we don't need to do another query. + var computation = Deps.currentComputation; + var invalidate = function () { + computation.invalidate(); + }; + var initial = true; + var count = 0; + // observeChanges will stop() when this computation is invalidated + self.observeChanges({ + // we have to use addedBefore rather than added, because observeChanges in + // unordered (added) mode doesn't support skip/limit + addedBefore: function () { + if (initial) { + count++; + } else { + invalidate(); + } + }, + removed: invalidate + }); + initial = false; + return count; + } - if (self.db_objects === null) - self.db_objects = self._getRawObjects({ordered: true}); - - return self.db_objects.length; + return self._getRawObjects({ordered: true}).length; }; LocalCollection.Cursor.prototype._publishCursor = function (sub) { @@ -277,7 +320,7 @@ _.extend(LocalCollection.Cursor.prototype, { // unordered observe. eg, update's EJSON.clone, and the "there are several" // comment in _modifyAndNotify // XXX allow skip/limit with unordered observe - if (!options._allow_unordered && !ordered && (self.skip || self.limit)) + if (!ordered && (self.skip || self.limit)) throw new Error("must use ordered observe with skip or limit"); if (self.fields && (self.fields._id === 0 || self.fields._id === false)) @@ -472,31 +515,6 @@ LocalCollection.Cursor.prototype._getRawObjects = function (options) { return results.slice(idx_start, idx_end); }; -// XXX Maybe we need a version of observe that just calls a callback if -// anything changed. -LocalCollection.Cursor.prototype._depend = function (changers, _allow_unordered) { - var self = this; - - if (Deps.active) { - var v = new Deps.Dependency; - v.depend(); - var notifyChange = _.bind(v.changed, v); - - var options = { - _suppress_initial: true, - _allow_unordered: _allow_unordered - }; - _.each(['added', 'changed', 'removed', 'addedBefore', 'movedBefore'], - function (fnName) { - if (changers[fnName]) - options[fnName] = notifyChange; - }); - - // observeChanges will stop() when this computation is invalidated - self.observeChanges(options); - } -}; - // XXX enforce rule that field names can't start with '$' or contain '.' // (real mongodb does in fact enforce this) // XXX possibly enforce that 'undefined' does not appear (we assume diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 933ee9365f..cd5680649a 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -176,11 +176,11 @@ Tinytest.add("minimongo - cursors", function (test) { // fetch res = q.fetch(); test.length(res, 20); - for (var i = 0; i < 20; i++) + for (var i = 0; i < 20; i++) { test.equal(res[i].i, i); - // everything empty - test.length(q.fetch(), 0); - q.rewind(); + } + // call it again, it still works + test.length(q.fetch(), 20); // forEach var count = 0; @@ -192,9 +192,8 @@ Tinytest.add("minimongo - cursors", function (test) { test.isTrue(cursor === q); }, context); test.equal(count, 20); - // everything empty - test.length(q.fetch(), 0); - q.rewind(); + // call it again, it still works + test.length(q.fetch(), 20); // map res = q.map(function (obj, i, cursor) { @@ -206,8 +205,8 @@ Tinytest.add("minimongo - cursors", function (test) { test.length(res, 20); for (var i = 0; i < 20; i++) test.equal(res[i], i * 2); - // everything empty - test.length(q.fetch(), 0); + // call it again, it still works + test.length(q.fetch(), 20); // findOne (and no rewind first) test.equal(c.findOne({i: 0}).i, 0); @@ -2920,7 +2919,26 @@ Tinytest.add("minimongo - count on cursor with limit", function(test){ test.equal(count, 3); c.stop(); +}); +Tinytest.add("minimongo - reactive count with cached cursor", function (test) { + var coll = new LocalCollection; + var cursor = coll.find({}); + var firstAutorunCount, secondAutorunCount; + Deps.autorun(function(){ + firstAutorunCount = cursor.count(); + }); + Deps.autorun(function(){ + secondAutorunCount = coll.find({}).count(); + }); + test.equal(firstAutorunCount, 0); + test.equal(secondAutorunCount, 0); + coll.insert({i: 1}); + coll.insert({i: 2}); + coll.insert({i: 3}); + Deps.flush(); + test.equal(firstAutorunCount, 3); + test.equal(secondAutorunCount, 3); }); Tinytest.add("minimongo - $near operator tests", function (test) { diff --git a/packages/mongo-livedata/allow_tests.js b/packages/mongo-livedata/allow_tests.js index e8a919a03e..ca37a955fb 100644 --- a/packages/mongo-livedata/allow_tests.js +++ b/packages/mongo-livedata/allow_tests.js @@ -837,8 +837,7 @@ if (Meteor.isServer) { Tinytest.add("collection - global insecure", function (test) { // note: This test alters the global insecure status, by sneakily hacking - // the global Package object! This may collide with itself if run multiple - // times (but is better than the old test which had the same problem) + // the global Package object! var insecurePackage = Package.insecure; Package.insecure = {}; diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 3f8f0c690f..cd36cf8559 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -15,12 +15,11 @@ var Future = Npm.require(path.join('fibers', 'future')); MongoInternals = {}; MongoTest = {}; +// This is used to add or remove EJSON from the beginning of everything nested +// inside an EJSON custom type. It should only be called on pure JSON! var replaceNames = function (filter, thing) { if (typeof thing === "object") { - // XXX This condition should match our `looksLikeArray` condition in - // underscore. (A Buffer might not be the only thing that should be - // treated as an array.) - if (_.isArray(thing) || thing instanceof Buffer) { + if (_.isArray(thing)) { return _.map(thing, _.bind(replaceNames, null, filter)); } var ret = {}; @@ -51,7 +50,8 @@ var replaceMongoAtomWithMeteor = function (document) { if (document instanceof MongoDB.ObjectID) { return new Meteor.Collection.ObjectID(document.toHexString()); } - if (document["EJSON$type"] && document["EJSON$value"]) { + if (document["EJSON$type"] && document["EJSON$value"] + && _.size(document) === 2) { return EJSON.fromJSONValue(replaceNames(unmakeMongoLegal, document)); } if (document instanceof MongoDB.Timestamp) { @@ -303,13 +303,25 @@ var bindEnvironmentForWrite = function (callback) { MongoConnection.prototype._insert = function (collection_name, document, callback) { var self = this; + + var sendError = function (e) { + if (callback) + return callback(e); + throw e; + }; + if (collection_name === "___meteor_failure_test_collection") { var e = new Error("Failure test"); e.expected = true; - if (callback) - return callback(e); - else - throw e; + sendError(e); + return; + } + + if (!(LocalCollection._isPlainObject(document) && + !EJSON._isCustomType(document))) { + sendError(new Error( + "Only documents (plain objects) may be inserted into MongoDB")); + return; } var write = self._maybeBeginWrite(); @@ -708,7 +720,7 @@ Cursor = function (mongo, cursorDescription) { self._synchronousCursor = null; }; -_.each(['forEach', 'map', 'rewind', 'fetch', 'count'], function (method) { +_.each(['forEach', 'map', 'fetch', 'count'], function (method) { Cursor.prototype[method] = function () { var self = this; @@ -731,6 +743,13 @@ _.each(['forEach', 'map', 'rewind', 'fetch', 'count'], function (method) { }; }); +// Since we don't actually have a "nextObject" interface, there's really no +// reason to have a "rewind" interface. All it did was make multiple calls +// to fetch/map/forEach return nothing the second time. +// XXX COMPAT WITH 0.8.1 +Cursor.prototype.rewind = function () { +}; + Cursor.prototype.getTransform = function () { return this._cursorDescription.options.transform; }; @@ -862,6 +881,9 @@ _.extend(SynchronousCursor.prototype, { forEach: function (callback, thisArg) { var self = this; + // Get back to the beginning. + self._rewind(); + // We implement the loop ourself instead of using self._dbCursor.each, // because "each" will call its callback outside of a fiber which makes it // much more complex to make this function synchronous. @@ -883,7 +905,7 @@ _.extend(SynchronousCursor.prototype, { return res; }, - rewind: function () { + _rewind: function () { var self = this; // known to be synchronous diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 9aafb19193..d41f711670 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -24,6 +24,10 @@ if (Meteor.isServer) { Meteor.publish('c-' + name, function () { return c.find(); }); + }, + dropInsecureCollection: function(name) { + var c = COLLECTIONS[name]; + c._dropCollection(); } }); } @@ -330,7 +334,6 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on }, context); test.equal(total, 14); - cur.rewind(); index = 0; test.equal(cur.map(function (doc, i, cursor) { // XXX we could theoretically make map run its iterations in parallel or @@ -1448,21 +1451,28 @@ testAsyncMulti('mongo-livedata - document with a custom type, ' + idGeneration, Meteor.subscribe('c-' + this.collectionName, expect()); } }, function (test, expect) { - var coll = new Meteor.Collection(this.collectionName, collectionOptions); + var self = this; + self.coll = new Meteor.Collection(this.collectionName, collectionOptions); var docId; // Dog is implemented at the top of the file, outside of the idGeneration // loop (so that we only call EJSON.addType once). var d = new Dog("reginald", "purple"); - coll.insert({d: d}, expect(function (err, id) { + self.coll.insert({d: d}, expect(function (err, id) { test.isFalse(err); test.isTrue(id); docId = id; - var cursor = coll.find(); + var cursor = self.coll.find(); test.equal(cursor.count(), 1); - var inColl = coll.findOne(); + var inColl = self.coll.findOne(); test.isTrue(inColl); inColl && test.equal(inColl.d.speak(), "woof"); })); + }, function (test, expect) { + var self = this; + self.coll.insert(new Dog("rover", "orange"), expect(function (err, id) { + test.isTrue(err); + test.isFalse(id); + })); } ]); @@ -2331,14 +2341,21 @@ _.each( ['STRING', 'MONGO'], function (idGeneration) { testAsyncMulti('mongo-livedata - consistent _id generation ' + name + ', ' + repetitions + ' repetitions on ' + collectionCount + ' collections, idGeneration=' + idGeneration, [ function (test, expect) { var collectionOptions = { idGeneration: idGeneration }; + var cleanups = this.cleanups = []; this.collections = _.times(collectionCount, function () { var collectionName = "consistentid_" + Random.id(); if (Meteor.isClient) { Meteor.call('createInsecureCollection', collectionName, collectionOptions); Meteor.subscribe('c-' + collectionName, expect()); + cleanups.push(function (expect) { Meteor.call('dropInsecureCollection', collectionName, expect(function () {})); }); } - return (COLLECTIONS[collectionName] = new Meteor.Collection(collectionName, collectionOptions)); + var collection = new Meteor.Collection(collectionName, collectionOptions); + if (Meteor.isServer) { + cleanups.push(function () { collection._dropCollection(); }); + } + COLLECTIONS[collectionName] = collection; + return collection; }); }, function (test, expect) { // now run the actual test @@ -2347,6 +2364,11 @@ _.each( ['STRING', 'MONGO'], function (idGeneration) { fn(test, expect, this.collections[j], i); } } + }, function (test, expect) { + // Run any registered cleanup functions (e.g. to drop collections) + _.each(this.cleanups, function(cleanup) { + cleanup(expect); + }); }]); }); @@ -2954,12 +2976,3 @@ testAsyncMulti("mongo-livedata - undefined find options", [ test.equal(result, self.doc); } ]); - -// We're not sure if this should be supported, but it was broken in -// 0.8.1 and we decided to make a quick -// fix. https://github.com/meteor/meteor/issues/2095 -Meteor.isServer && Tinytest.add("mongo-livedata - insert and retrieve EJSON user-defined type as document", function (test) { - var coll = new Meteor.Collection(Random.id()); - coll.insert(new Meteor.Collection.ObjectID()); - coll.find({}).fetch(); -}); diff --git a/packages/mongo-livedata/polling_observe_driver.js b/packages/mongo-livedata/polling_observe_driver.js index c1c700d0c1..c2f2ce0efb 100644 --- a/packages/mongo-livedata/polling_observe_driver.js +++ b/packages/mongo-livedata/polling_observe_driver.js @@ -126,10 +126,11 @@ _.extend(PollingObserveDriver.prototype, { --self._pollsScheduledButNotStarted; var first = false; - if (!self._results) { + var oldResults = self._results; + if (!oldResults) { first = true; // XXX maybe use OrderedDict instead? - self._results = self._ordered ? [] : new LocalCollection._IdMap; + oldResults = self._ordered ? [] : new LocalCollection._IdMap; } self._testOnlyPollCallback && self._testOnlyPollCallback(); @@ -138,25 +139,34 @@ _.extend(PollingObserveDriver.prototype, { var writesForCycle = self._pendingWrites; self._pendingWrites = []; - // Get the new query results. (These calls can yield.) - if (!first) - self._synchronousCursor.rewind(); - var newResults = self._synchronousCursor.getRawObjects(self._ordered); - var oldResults = self._results; + // Get the new query results. (This yields.) + try { + var newResults = self._synchronousCursor.getRawObjects(self._ordered); + } catch (e) { + // getRawObjects can throw if we're having trouble talking to the + // database. That's fine --- we will repoll later anyway. But we should + // make sure not to lose track of this cycle's writes. + Array.prototype.push.apply(self._pendingWrites, writesForCycle); + throw e; + } - // Run diffs. (This can yield too.) + // Run diffs. if (!self._stopped) { LocalCollection._diffQueryChanges( self._ordered, oldResults, newResults, self._multiplexer); } - // Replace self._results atomically. - self._results = newResults; - - // Signals the multiplexer to call all initial adds. + // Signals the multiplexer to allow all observeChanges calls that share this + // multiplexer to return. (This happens asynchronously, via the + // multiplexer's queue.) if (first) self._multiplexer.ready(); + // Replace self._results atomically. (This assignment is what makes `first` + // stay through on the next cycle, so we've waited until after we've + // committed to ready-ing the multiplexer.) + self._results = newResults; + // Once the ObserveMultiplexer has processed everything we've done in this // round, mark all the writes which existed before this call as // commmitted. (If new writes have shown up in the meantime, there'll diff --git a/packages/oauth/oauth_server.js b/packages/oauth/oauth_server.js index 0accb7cf4c..fa9e3f01ee 100644 --- a/packages/oauth/oauth_server.js +++ b/packages/oauth/oauth_server.js @@ -106,18 +106,13 @@ middleware = function (req, res, next) { } } - // XXX the following is actually wrong. if someone wants to - // redirect rather than close once we are done with the OAuth - // flow, as supported by - // Oauth_renderOauthResults, this will still - // close the popup instead. Once we fully support the redirect - // flow (by supporting that in places such as - // packages/facebook/facebook_client.js) we should revisit this. - // // close the popup. because nobody likes them just hanging // there. when someone sees this multiple times they might // think to check server logs (we hope?) - closePopup(res); + OAuth._endOfLoginResponse(res, { + query: req.query, + error: err + }); } }; @@ -149,67 +144,114 @@ var ensureConfigured = function(serviceName) { } }; +var isSafe = function (value) { + // This matches strings generated by `Random.secret` and + // `Random.id`. + return typeof value === "string" && + /^[a-zA-Z0-9\-_]+$/.test(value); +}; + // Internal: used by the oauth1 and oauth2 packages OAuth._renderOauthResults = function(res, query, credentialSecret) { - // We support ?close and ?redirect=URL. Any other query should just - // serve a blank page. For tests, we support the + // We expect the ?close parameter to be present, in which case we + // close the popup at the end of the OAuth flow. Any other query + // string should just serve a blank page. For tests, we support the // `only_credential_secret_for_test` parameter, which just returns the // credential secret without any surrounding HTML. (The test needs to // be able to easily grab the secret and use it to log in.) + // + // XXX only_credential_secret_for_test could be useful for other + // things beside tests, like command-line clients. We should give it a + // real name and serve the credential secret in JSON. if (query.only_credential_secret_for_test) { res.writeHead(200, {'Content-Type': 'text/html'}); res.end(credentialSecret, 'utf-8'); - } else if (query.error) { - Log.warn("Error in OAuth Server: " + query.error); - closePopup(res); - } else if ('close' in query) { // check with 'in' because we don't set a value - closePopup(res, query.state, credentialSecret); - } else if (query.redirect) { - // Only redirect to URLs on the same domain as this app. - // XXX No code in core uses this code path right now. - // XXX In order for the redirect flow to be fully supported, we'd - // have to communicate the credentialSecret back to the app somehow. - var redirectHostname = url.parse(query.redirect).hostname; - var appHostname = url.parse(Meteor.absoluteUrl()).hostname; - if (appHostname === redirectHostname) { - // We rely on node to make sure the header is really only a single header - // (not, for example, a url with a newline and then another header). - res.writeHead(302, {'Location': query.redirect}); - } else { - res.writeHead(400); - } - res.end(); } else { - res.writeHead(200, {'Content-Type': 'text/html'}); - res.end('', 'utf-8'); + var details = { query: query }; + if (query.error) { + details.error = query.error; + } else { + var token = query.state; + var secret = credentialSecret; + if (token && secret && + isSafe(token) && isSafe(secret)) { + details.credentials = { token: token, secret: secret}; + } else { + details.error = "invalid_credential_token_or_secret"; + } + } + + OAuth._endOfLoginResponse(res, details); } }; -var closePopup = function(res, state, credentialSecret) { - - var isSafe = function (value) { - // This matches strings generated by `Random.secret` and - // `Random.id`. - return typeof value === "string" && - /^[a-zA-Z0-9\-_]+$/.test(value); - }; +// Writes an HTTP response to the popup window at the end of an OAuth +// login flow. At this point, if the user has successfully authenticated +// to the OAuth server and authorized this app, we communicate the +// credentialToken and credentialSecret to the main window. The main +// window must provide both these values to the DDP `login` method to +// authenticate its DDP connection. After communicating these vaues to +// the main window, we close the popup. +// +// We export this function so that developers can override this +// behavior, which is particularly useful in, for example, some mobile +// environments where popups and/or `window.opener` don't work. For +// example, an app could override `OAuth._endOfLoginResponse` to put the +// credential token and credential secret in the popup URL for the main +// window to read them there instead of using `window.opener`. If you +// override this function, you take responsibility for writing to the +// request and calling `res.end()` to complete the request. +// +// Arguments: +// - res: the HTTP response object +// - details: +// - query: the query string on the HTTP request +// - credentials: { token: *, secret: * }. If present, this field +// indicates that the login was successful. Return these values +// to the client, who can use them to log in over DDP. If +// present, the values have been checked against a limited +// character set and are safe to include in HTML. +// - error: if present, a string or Error indicating an error that +// occurred during the login. This can come from the client and +// so shouldn't be trusted for security decisions or included in +// the response without sanitizing it first. Only one of `error` +// or `credentials` should be set. +OAuth._endOfLoginResponse = function(res, details) { res.writeHead(200, {'Content-Type': 'text/html'}); - // If we have a credentialSecret, report it back to the parent window, with - // the corresponding state (which we sanitize because it came from a - // query parameter). The parent window uses the state and credential secret - // to log in over DDP. - var setCredentialSecret = ''; - if (state && credentialSecret && isSafe(state) && isSafe(credentialSecret)) { - setCredentialSecret = 'window.opener && ' + - 'window.opener.Package.oauth.OAuth._handleCredentialSecret(' + - JSON.stringify(state) + ', ' + JSON.stringify(credentialSecret) + ');'; + + var content = function (setCredentialSecret) { + return '<html><head><script>' + + setCredentialSecret + + 'window.close()</script></head></html>'; + }; + + if (details.error) { + Log.warn("Error in OAuth Server: " + + (details.error instanceof Error ? + details.error.message : details.error)); + res.end(content(""), 'utf-8'); + return; + } + + if ("close" in details.query) { + // If we have a credentialSecret, report it back to the parent + // window, with the corresponding credentialToken. The parent window + // uses the credentialToken and credentialSecret to log in over DDP. + var setCredentialSecret = ''; + if (details.credentials.token && details.credentials.secret) { + setCredentialSecret = 'var credentialToken = ' + + JSON.stringify(details.credentials.token) + ';' + + 'var credentialSecret = ' + + JSON.stringify(details.credentials.secret) + ';' + + 'window.opener && ' + + 'window.opener.Package.oauth.OAuth._handleCredentialSecret(' + + 'credentialToken, credentialSecret);'; + } + res.end(content(setCredentialSecret), "utf-8"); + } else { + res.end("", "utf-8"); } - var content = - '<html><head><script>' + - setCredentialSecret + - 'window.close()</script></head></html>'; - res.end(content, 'utf-8'); }; diff --git a/packages/observe-sequence/package.js b/packages/observe-sequence/package.js index 5009db78d2..7604f3d6a7 100644 --- a/packages/observe-sequence/package.js +++ b/packages/observe-sequence/package.js @@ -6,6 +6,8 @@ Package.describe({ Package.on_use(function (api) { api.use('deps'); api.use('minimongo'); // for idStringify + api.use('underscore'); + api.use('random'); api.export('ObserveSequence'); api.add_files(['observe_sequence.js']); }); diff --git a/packages/sha/.gitignore b/packages/sha/.gitignore new file mode 100644 index 0000000000..677a6fc263 --- /dev/null +++ b/packages/sha/.gitignore @@ -0,0 +1 @@ +.build* diff --git a/packages/sha/package.js b/packages/sha/package.js new file mode 100644 index 0000000000..fbf93e6c8d --- /dev/null +++ b/packages/sha/package.js @@ -0,0 +1,9 @@ +Package.describe({ + summary: "SHA256 implementation", + internal: true +}); + +Package.on_use(function (api) { + api.export('SHA256'); + api.add_files(['sha256.js'], ['client', 'server']); +}); diff --git a/packages/srp/sha256.js b/packages/sha/sha256.js similarity index 96% rename from packages/srp/sha256.js rename to packages/sha/sha256.js index 4743264b4e..675f87ef1a 100644 --- a/packages/srp/sha256.js +++ b/packages/sha/sha256.js @@ -1,7 +1,5 @@ /// METEOR WRAPPER // -// XXX this should get packaged and moved into the Meteor.crypto -// namespace, along with other hash functions. SHA256 = (function () { @@ -14,18 +12,18 @@ SHA256 = (function () { * Original code by Angel Marin, Paul Johnston. * **/ - + function SHA256(s){ - + var chrsz = 8; var hexcase = 0; - + function safe_add (x, y) { var lsw = (x & 0xFFFF) + (y & 0xFFFF); var msw = (x >> 16) + (y >> 16) + (lsw >> 16); return (msw << 16) | (lsw & 0xFFFF); } - + function S (X, n) { return ( X >>> n ) | (X << (32 - n)); } function R (X, n) { return ( X >>> n ); } function Ch(x, y, z) { return ((x & y) ^ ((~x) & z)); } @@ -34,17 +32,17 @@ function SHA256(s){ function Sigma1256(x) { return (S(x, 6) ^ S(x, 11) ^ S(x, 25)); } function Gamma0256(x) { return (S(x, 7) ^ S(x, 18) ^ R(x, 3)); } function Gamma1256(x) { return (S(x, 17) ^ S(x, 19) ^ R(x, 10)); } - + function core_sha256 (m, l) { var K = new Array(0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, 0xE49B69C1, 0xEFBE4786, 0xFC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x6CA6351, 0x14292967, 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2); var HASH = new Array(0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19); var W = new Array(64); var a, b, c, d, e, f, g, h, i, j; var T1, T2; - + m[l >> 5] |= 0x80 << (24 - l % 32); m[((l + 64 >> 9) << 4) + 15] = l; - + for ( var i = 0; i<m.length; i+=16 ) { a = HASH[0]; b = HASH[1]; @@ -54,14 +52,14 @@ function SHA256(s){ f = HASH[5]; g = HASH[6]; h = HASH[7]; - + for ( var j = 0; j<64; j++) { if (j < 16) W[j] = m[j + i]; else W[j] = safe_add(safe_add(safe_add(Gamma1256(W[j - 2]), W[j - 7]), Gamma0256(W[j - 15])), W[j - 16]); - + T1 = safe_add(safe_add(safe_add(safe_add(h, Sigma1256(e)), Ch(e, f, g)), K[j]), W[j]); T2 = safe_add(Sigma0256(a), Maj(a, b, c)); - + h = g; g = f; f = e; @@ -71,7 +69,7 @@ function SHA256(s){ b = a; a = safe_add(T1, T2); } - + HASH[0] = safe_add(a, HASH[0]); HASH[1] = safe_add(b, HASH[1]); HASH[2] = safe_add(c, HASH[2]); @@ -83,7 +81,7 @@ function SHA256(s){ } return HASH; } - + function str2binb (str) { var bin = Array(); var mask = (1 << chrsz) - 1; @@ -92,7 +90,7 @@ function SHA256(s){ } return bin; } - + function Utf8Encode(string) { // METEOR change: // The webtoolkit.info version of this code added this @@ -102,11 +100,11 @@ function SHA256(s){ // // string = string.replace(/\r\n/g,"\n"); var utftext = ""; - + for (var n = 0; n < string.length; n++) { - + var c = string.charCodeAt(n); - + if (c < 128) { utftext += String.fromCharCode(c); } @@ -119,12 +117,12 @@ function SHA256(s){ utftext += String.fromCharCode(((c >> 6) & 63) | 128); utftext += String.fromCharCode((c & 63) | 128); } - + } - + return utftext; } - + function binb2hex (binarray) { var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; var str = ""; @@ -134,10 +132,10 @@ function SHA256(s){ } return str; } - + s = Utf8Encode(s); return binb2hex(core_sha256(str2binb(s), s.length * chrsz)); - + } /// METEOR WRAPPER diff --git a/packages/spacebars-common/.gitignore b/packages/spacebars-common/.gitignore new file mode 100644 index 0000000000..677a6fc263 --- /dev/null +++ b/packages/spacebars-common/.gitignore @@ -0,0 +1 @@ +.build* diff --git a/packages/spacebars-common/package.js b/packages/spacebars-common/package.js new file mode 100644 index 0000000000..53765c7230 --- /dev/null +++ b/packages/spacebars-common/package.js @@ -0,0 +1,9 @@ +Package.describe({ + summary: "Common code for spacebars and spacebars-compiler", + internal: true +}); + +Package.on_use(function (api) { + api.export('Spacebars'); + api.add_files('spacebars.js'); +}); diff --git a/packages/spacebars-common/spacebars.js b/packages/spacebars-common/spacebars.js new file mode 100644 index 0000000000..fe1e0185da --- /dev/null +++ b/packages/spacebars-common/spacebars.js @@ -0,0 +1 @@ +Spacebars = {}; diff --git a/packages/spacebars-compiler/compile_tests.js b/packages/spacebars-compiler/compile_tests.js index a881f1e474..e357c6f0fd 100644 --- a/packages/spacebars-compiler/compile_tests.js +++ b/packages/spacebars-compiler/compile_tests.js @@ -125,6 +125,20 @@ Tinytest.add("spacebars - compiler output", function (test) { })); }); + run("{{!-- --}}{{#if cond}}aaa{{!\n}}{{else}}{{!}}bbb{{!-- --}}{{/if}}{{!}}", + function() { + var self = this; + return UI.If(function () { + return Spacebars.call(self.lookup("cond")); + }, UI.block(function() { + var self = this; + return "aaa"; + }), UI.block(function() { + var self = this; + return "bbb"; + })); + }); + run("{{> foo bar}}", function() { var self = this; diff --git a/packages/spacebars-compiler/package.js b/packages/spacebars-compiler/package.js index c219d82aa6..f5159a9774 100644 --- a/packages/spacebars-compiler/package.js +++ b/packages/spacebars-compiler/package.js @@ -3,8 +3,8 @@ Package.describe({ }); Package.on_use(function (api) { - api.use('spacebars'); - api.imply('spacebars'); + api.use('spacebars-common'); + api.imply('spacebars-common'); // we attach stuff to the global symbol `HTML`, exported // by `htmljs` via `html-tools`, so we both use and effectively @@ -13,7 +13,6 @@ Package.on_use(function (api) { api.imply('html-tools'); api.use('underscore'); - api.use('ui'); api.use('minifiers', ['server']); api.add_files(['tokens.js', 'tojs.js', 'templatetag.js', 'spacebars-compiler.js']); diff --git a/packages/spacebars-compiler/spacebars-compiler.js b/packages/spacebars-compiler/spacebars-compiler.js index b4d6158ec4..a24f2a11ab 100644 --- a/packages/spacebars-compiler/spacebars-compiler.js +++ b/packages/spacebars-compiler/spacebars-compiler.js @@ -163,10 +163,22 @@ var builtInBlockHelpers = { 'each': 'UI.Each' }; -// These must be prefixed with `UI.` when you use them in a template. -var builtInLexicals = { +// Some `UI.*` paths are special in that they generate code that +// doesn't folow the normal lookup rules for dotted symbols. The +// following names must be prefixed with `UI.` when you use them in a +// template. +var builtInUIPaths = { + // `template` is a local variable defined in the generated render + // function for the template in which `UI.contentBlock` (or + // `UI.elseBlock`) is invoked. `template` is a reference to the + // template itself. 'contentBlock': 'template.__content', - 'elseBlock': 'template.__elseContent' + 'elseBlock': 'template.__elseContent', + + // `Template` is the global template namespace. If you define a + // template named `foo` in Spacebars, it gets defined as + // `Template.foo` in JavaScript. + 'dynamic': 'Template.__dynamic' }; // A "reserved name" can't be used as a <template> name. This @@ -288,11 +300,11 @@ var codeGenPath = function (path, opts) { // inclusion or as a block helper, in addition to supporting // `{{> UI.contentBlock}}`. if (path.length >= 2 && - path[0] === 'UI' && builtInLexicals.hasOwnProperty(path[1])) { + path[0] === 'UI' && builtInUIPaths.hasOwnProperty(path[1])) { if (path.length > 2) throw new Error("Unexpected dotted path beginning with " + path[0] + '.' + path[1]); - return builtInLexicals[path[1]]; + return builtInUIPaths[path[1]]; } var args = [toJSLiteral(path[0])]; diff --git a/packages/spacebars-compiler/spacebars_tests.js b/packages/spacebars-compiler/spacebars_tests.js index 2ef521d014..dd5ecfc1b9 100644 --- a/packages/spacebars-compiler/spacebars_tests.js +++ b/packages/spacebars-compiler/spacebars_tests.js @@ -102,6 +102,8 @@ Tinytest.add("spacebars - stache tags", function (test) { run('{{foo.[]/[]}}', {type: 'DOUBLE', path: ['foo', '', ''], args: []}); + run('{{x foo.[=]}}', {type: 'DOUBLE', path: ['x'], + args: [['PATH', ['foo', '=']]]}); run('{{[].foo}}', "Path can't start with empty string"); run('{{foo null}}', {type: 'DOUBLE', path: ['foo'], @@ -159,6 +161,9 @@ Tinytest.add("spacebars - stache tags", function (test) { run('{{./this}}', "Can only use"); run('{{../this}}', "Can only use"); + run('{{foo "="}}', {type: 'DOUBLE', path: ['foo'], + args: [['STRING', '=']]}); + }); diff --git a/packages/spacebars-compiler/templatetag.js b/packages/spacebars-compiler/templatetag.js index 4616f37970..72ae2f7474 100644 --- a/packages/spacebars-compiler/templatetag.js +++ b/packages/spacebars-compiler/templatetag.js @@ -167,7 +167,7 @@ TemplateTag.parse = function (scannerOrString) { // Result is either the keyword matched, or null // if we're not at a keyword argument position. var scanArgKeyword = function () { - var match = /^([^\{\}\(\)\>#=\s]+)\s*=\s*/.exec(scanner.rest()); + var match = /^([^\{\}\(\)\>#=\s"'\[\]]+)\s*=\s*/.exec(scanner.rest()); if (match) { scanner.pos += match[0].length; return match[1]; diff --git a/packages/spacebars-tests/assets/markdown_basic.html b/packages/spacebars-tests/assets/markdown_basic.html new file mode 100644 index 0000000000..dbb49ca965 --- /dev/null +++ b/packages/spacebars-tests/assets/markdown_basic.html @@ -0,0 +1,49 @@ +<p><i>hi</i> +/each}}</p> + +<p><b><i>hi</i></b> +<b>/each}}</b></p> + +<ul> +<li><i>hi</i></li> +<li><p>/each}}</p></li> +<li><p><b><i>hi</i></b></p></li> +<li><b>/each}}</b></li> +</ul> + +<p>some paragraph to fix showdown's four space parsing below.</p> + +<pre><code>&lt;i&gt;hi&lt;/i&gt; +/each}} + +&lt;b&gt;&lt;i&gt;hi&lt;/i&gt;&lt;/b&gt; +&lt;b&gt;/each}}&lt;/b&gt; +</code></pre> + +<p>&amp;gt</p> + +<ul> +<li>&amp;gt</li> +</ul> + +<p><code>&amp;gt</code></p> + +<pre><code>&amp;gt +</code></pre> + +<p>&gt;</p> + +<ul> +<li>&gt;</li> +</ul> + +<p><code>&amp;gt;</code></p> + +<pre><code>&amp;gt; +</code></pre> + +<p><code>&lt;i&gt;hi&lt;/i&gt;</code> +<code>/each}}</code></p> + +<p><code>&lt;b&gt;&lt;i&gt;hi&lt;/i&gt;&lt;/b&gt;</code> +<code>&lt;b&gt;/each}}</code></p> diff --git a/packages/spacebars-tests/assets/markdown_each1.html b/packages/spacebars-tests/assets/markdown_each1.html new file mode 100644 index 0000000000..111f7e535b --- /dev/null +++ b/packages/spacebars-tests/assets/markdown_each1.html @@ -0,0 +1,15 @@ +<p><b></b></p> + +<ul> +<li></li> +<li><b></b></li> +</ul> + +<p>some paragraph to fix showdown's four space parsing below.</p> + +<pre><code>&lt;b&gt;&lt;/b&gt; +</code></pre> + +<p>``</p> + +<p><code>&lt;b&gt;&lt;/b&gt;</code></p> diff --git a/packages/spacebars-tests/assets/markdown_each2.html b/packages/spacebars-tests/assets/markdown_each2.html new file mode 100644 index 0000000000..52613cfb61 --- /dev/null +++ b/packages/spacebars-tests/assets/markdown_each2.html @@ -0,0 +1,19 @@ +<p>item</p> + +<p><b>item</b></p> + +<ul> +<li><p>item</p></li> +<li><p><b>item</b></p></li> +</ul> + +<p>some paragraph to fix showdown's four space parsing below.</p> + +<pre><code>item + +&lt;b&gt;item&lt;/b&gt; +</code></pre> + +<p><code>item</code></p> + +<p><code>&lt;b&gt;item&lt;/b&gt;</code></p> diff --git a/packages/spacebars-tests/assets/markdown_if1.html b/packages/spacebars-tests/assets/markdown_if1.html new file mode 100644 index 0000000000..378522b316 --- /dev/null +++ b/packages/spacebars-tests/assets/markdown_if1.html @@ -0,0 +1,19 @@ +<p>false</p> + +<p><b>false</b></p> + +<ul> +<li><p>false</p></li> +<li><p><b>false</b></p></li> +</ul> + +<p>some paragraph to fix showdown's four space parsing below.</p> + +<pre><code>false + +&lt;b&gt;false&lt;/b&gt; +</code></pre> + +<p><code>false</code></p> + +<p><code>&lt;b&gt;false&lt;/b&gt;</code></p> diff --git a/packages/spacebars-tests/assets/markdown_if2.html b/packages/spacebars-tests/assets/markdown_if2.html new file mode 100644 index 0000000000..603c070c8a --- /dev/null +++ b/packages/spacebars-tests/assets/markdown_if2.html @@ -0,0 +1,19 @@ +<p>true</p> + +<p><b>true</b></p> + +<ul> +<li><p>true</p></li> +<li><p><b>true</b></p></li> +</ul> + +<p>some paragraph to fix showdown's four space parsing below.</p> + +<pre><code>true + +&lt;b&gt;true&lt;/b&gt; +</code></pre> + +<p><code>true</code></p> + +<p><code>&lt;b&gt;true&lt;/b&gt;</code></p> diff --git a/packages/spacebars-tests/package.js b/packages/spacebars-tests/package.js index 3a4628d47b..c65d18058a 100644 --- a/packages/spacebars-tests/package.js +++ b/packages/spacebars-tests/package.js @@ -18,4 +18,14 @@ Package.on_test(function (api) { 'template_tests.html', 'template_tests.js' ], 'client'); + + api.add_files('template_tests_server.js', 'server'); + + api.add_files([ + 'assets/markdown_basic.html', + 'assets/markdown_if1.html', + 'assets/markdown_if2.html', + 'assets/markdown_each1.html', + 'assets/markdown_each2.html' + ], 'server', { isAsset: true }); }); diff --git a/packages/spacebars-tests/template_tests.html b/packages/spacebars-tests/template_tests.html index 0f2e733dcd..0964ba4b9f 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -664,6 +664,34 @@ Hi there! <a href="#bad-url" id="spacebars_test_event_returns_false_link">click me</a> </template> +<template name="spacebars_test_event_selectors1"> + <div>{{> spacebars_test_event_selectors2}}</div> +</template> + +<template name="spacebars_test_event_selectors2"> + <p class="p1">Not it</p> + <div><p class="p2">It</p></div> +</template> + +<template name="spacebars_test_event_selectors_capturing1"> + <div>{{> spacebars_test_event_selectors_capturing2}}</div> +</template> + +<template name="spacebars_test_event_selectors_capturing2"> + <video class='video1'> + <source id='mp4' + src="http://media.w3.org/2010/05/sintel/trailer.mp4" + type='video/mp4'> + </video> + <div> + <video class='video2'> + <source id='mp4' + src="http://media.w3.org/2010/05/sintel/trailer.mp4" + type='video/mp4'> + </video> + </div> +</template> + <template name="spacebars_test_tables1"> <table><tr><td>Foo</td></tr></table> </template> @@ -726,3 +754,71 @@ Hi there! <img src="{{foo}}" /> <input value="{{foo}}" /> </template> + +<template name="spacebars_test_event_handler_cleanup"> + {{#if foo}} + {{>spacebars_test_event_handler_cleanup_sub}} + {{/if}} +</template> + +<template name="spacebars_test_event_handler_cleanup_sub"> + <div></div> +</template> + +<template name="spacebars_test_each_with_autorun_insert"> + {{#each items}} + {{name}} + {{/each}} +</template> + +<template name="spacebars_test_ui_hooks"> + <div class="test-ui-hooks"> + {{#each items}} + <div class="item">{{_id}}</div> + {{/each}} + </div> +</template> + +<template name="spacebars_test_ui_hooks_nested"> + {{#if foo}} + {{> spacebars_test_ui_hooks_nested_sub}} + {{/if}} +</template> + +<template name="spacebars_test_ui_hooks_nested_sub"> + <div> + {{#with true}} + <p>hello</p> + {{/with}} + </div> +</template> + +<template name="spacebars_test_template_instance_helper"> + {{#with true}}{{foo}}{{/with}} +</template> + +<template name="spacebars_test_with_cleanup"> + <div class="test-with-cleanup"> + {{#with foo}} + {{this}} + {{/with}} + </div> +</template> + +<template name="spacebars_test_template_parent_data_helper"> + {{#with "parent"}} + {{> spacebars_test_template_parent_data_helper_child}} + {{/with}} +</template> + +<template name="spacebars_test_template_parent_data_helper_child"> + {{#each a}} + {{#with b}} + {{#if c}} + {{#with "d"}} + {{foo}} + {{/with}} + {{/if}} + {{/with}} + {{/each}} +</template> diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index 93c82cbc2c..da45b0bed9 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -1,9 +1,3 @@ -var renderToDiv = function (comp) { - var div = document.createElement("DIV"); - UI.materialize(comp, div); - return div; -}; - var divRendersTo = function (test, div, html) { Deps.flush({_throwFirstError: true}); var actual = canonicalizeHtml(div.innerHTML); @@ -767,30 +761,40 @@ Tinytest.add('spacebars - templates - textarea each', function (test) { // Ensure that one can call `Meteor.defer` within a rendered callback // triggered by a document insertion that happend in a method stub. +// +// Why do we have this test? Because you generally can't call +// `Meteor.defer` inside a method stub (see +// packages/meteor/timers.js). This test verifies that rendered +// callbacks don't fire synchronously as part of a method stub. testAsyncMulti('spacebars - template - defer in rendered callbacks', [function (test, expect) { var tmpl = Template.spacebars_template_test_defer_in_rendered; - var coll = new Meteor.Collection("test-defer-in-rendered--client-only"); + var coll = new Meteor.Collection(null); + + Meteor.methods({ + spacebarsTestInsertEmptyObject: function () { + // cause a new instance of `subtmpl` to be placed in the + // DOM. verify that it's not fired directly within a method + // stub, in which `Meteor.defer` is not allowed. + coll.insert({}); + } + }); + tmpl.items = function () { return coll.find(); }; var subtmpl = Template.spacebars_template_test_defer_in_rendered_subtemplate; + subtmpl.rendered = expect(function () { // will throw if called in a method stub - Meteor.defer(function () { - }); + Meteor.defer(function () {}); }); var div = renderToDiv(tmpl); - // `coll` is not defined on the server so we'll get an error. We - // can't make this a client-only collection since then we won't be - // running in a stub and the error won't fire. - Meteor._suppress_log(1); - // cause a new instance of `subtmpl` to be placed in the DOM. verify - // that it's not fired directly within a method stub, in which - // `Meteor.defer` is not allowed. - coll.insert({}); + // not defined on the server, but it's fine since the stub does + // the relevant work + Meteor.call("spacebarsTestInsertEmptyObject"); }]); testAsyncMulti('spacebars - template - rendered template is DOM in rendered callbacks', [ @@ -955,176 +959,76 @@ Tinytest.add('spacebars - templates - constant #each argument', function (test) 'foo bar 2'); }); -// extract a multi-line string from a comment within a function. -// @param f {Function} eg function () { /* [[[...content...]]] */ } -// @returns {String} eg "content" -var textFromFunction = function(f) { - var str = f.toString().match(/\[\[\[([\S\s]*)\]\]\]/m)[1]; - // remove line number comments added by linker - str = str.replace(/[ ]*\/\/ \d+$/gm, ''); - return str; -}; - -Tinytest.add('spacebars - templates - #markdown - basic', function (test) { +Tinytest.addAsync('spacebars - templates - #markdown - basic', function (test, onComplete) { var tmpl = Template.spacebars_template_test_markdown_basic; tmpl.obj = {snippet: "<i>hi</i>"}; tmpl.hi = function () { return this.snippet; }; var div = renderToDiv(tmpl); - test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(textFromFunction(function () { /* -[[[<p><i>hi</i> -/each}}</p> -<p><b><i>hi</i></b> -<b>/each}}</b></p> - -<ul> -<li><i>hi</i></li> -<li><p>/each}}</p></li> -<li><p><b><i>hi</i></b></p></li> -<li><b>/each}}</b></li> -</ul> - -<p>some paragraph to fix showdown's four space parsing below.</p> - -<pre><code>&lt;i&gt;hi&lt;/i&gt; -/each}} - -&lt;b&gt;&lt;i&gt;hi&lt;/i&gt;&lt;/b&gt; -&lt;b&gt;/each}}&lt;/b&gt; -</code></pre> - -<p>&amp;gt</p> - -<ul> -<li>&amp;gt</li> -</ul> - -<p><code>&amp;gt</code></p> - -<pre><code>&amp;gt -</code></pre> - -<p>&gt;</p> - -<ul> -<li>&gt;</li> -</ul> - -<p><code>&amp;gt;</code></p> - -<pre><code>&amp;gt; -</code></pre> - -<p><code>&lt;i&gt;hi&lt;/i&gt;</code> -<code>/each}}</code></p> - -<p><code>&lt;b&gt;&lt;i&gt;hi&lt;/i&gt;&lt;/b&gt;</code> -<code>&lt;b&gt;/each}}</code></p>]]] */ - }))); + Meteor.call("getAsset", "markdown_basic.html", function (err, html) { + test.isFalse(err); + test.equal(canonicalizeHtml(div.innerHTML), + canonicalizeHtml(html)); + onComplete(); + }); }); -Tinytest.add('spacebars - templates - #markdown - if', function (test) { - var tmpl = Template.spacebars_template_test_markdown_if; - var R = new ReactiveVar(false); - tmpl.cond = function () { return R.get(); }; +testAsyncMulti('spacebars - templates - #markdown - if', [ + function (test, expect) { + var self = this; + Meteor.call("getAsset", "markdown_if1.html", expect(function (err, html) { + test.isFalse(err); + self.html1 = html; + })); + Meteor.call("getAsset", "markdown_if2.html", expect(function (err, html) { + test.isFalse(err); + self.html2 = html; + })); + }, - var div = renderToDiv(tmpl); - test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(textFromFunction(function () { /* -[[[<p>false</p> + function (test, expect) { + var self = this; + var tmpl = Template.spacebars_template_test_markdown_if; + var R = new ReactiveVar(false); + tmpl.cond = function () { return R.get(); }; -<p><b>false</b></p> + var div = renderToDiv(tmpl); + test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(self.html1)); + R.set(true); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(self.html2)); + } +]); -<ul> -<li><p>false</p></li> -<li><p><b>false</b></p></li> -</ul> +testAsyncMulti('spacebars - templates - #markdown - each', [ + function (test, expect) { + var self = this; + Meteor.call("getAsset", "markdown_each1.html", expect(function (err, html) { + test.isFalse(err); + self.html1 = html; + })); + Meteor.call("getAsset", "markdown_each2.html", expect(function (err, html) { + test.isFalse(err); + self.html2 = html; + })); + }, -<p>some paragraph to fix showdown's four space parsing below.</p> + function (test, expect) { + var self = this; + var tmpl = Template.spacebars_template_test_markdown_each; + var R = new ReactiveVar([]); + tmpl.seq = function () { return R.get(); }; -<pre><code>false + var div = renderToDiv(tmpl); + test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(self.html1)); -&lt;b&gt;false&lt;/b&gt; -</code></pre> - -<p><code>false</code></p> - -<p><code>&lt;b&gt;false&lt;/b&gt;</code></p>]]] */ - }))); - R.set(true); - Deps.flush(); - test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(textFromFunction(function () { /* -[[[<p>true</p> - -<p><b>true</b></p> - -<ul> -<li><p>true</p></li> -<li><p><b>true</b></p></li> -</ul> - -<p>some paragraph to fix showdown's four space parsing below.</p> - -<pre><code>true - -&lt;b&gt;true&lt;/b&gt; -</code></pre> - -<p><code>true</code></p> - -<p><code>&lt;b&gt;true&lt;/b&gt;</code></p>]]] */ - }))); -}); - -Tinytest.add('spacebars - templates - #markdown - each', function (test) { - var tmpl = Template.spacebars_template_test_markdown_each; - var R = new ReactiveVar([]); - tmpl.seq = function () { return R.get(); }; - - var div = renderToDiv(tmpl); - test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(textFromFunction(function () { /* -[[[<p><b></b></p> - -<ul> -<li></li> -<li><b></b></li> -</ul> - -<p>some paragraph to fix showdown's four space parsing below.</p> - -<pre><code>&lt;b&gt;&lt;/b&gt; -</code></pre> - -<p>``</p> - -<p><code>&lt;b&gt;&lt;/b&gt;</code></p>]]] */ - }))); - - R.set(["item"]); - Deps.flush(); - test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(textFromFunction(function () { /* -[[[<p>item</p> - -<p><b>item</b></p> - -<ul> -<li><p>item</p></li> -<li><p><b>item</b></p></li> -</ul> - -<p>some paragraph to fix showdown's four space parsing below.</p> - -<pre><code>item - -&lt;b&gt;item&lt;/b&gt; -</code></pre> - -<p><code>item</code></p> - -<p><code>&lt;b&gt;item&lt;/b&gt;</code></p>]]] */ - }))); -}); + R.set(["item"]); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), canonicalizeHtml(self.html2)); + } +]); Tinytest.add('spacebars - templates - #markdown - inclusion', function (test) { var tmpl = Template.spacebars_template_test_markdown_inclusion; @@ -1440,22 +1344,15 @@ _.each(['textarea', 'text', 'password', 'submit', 'button', test.equal(DomUtils.getElementValue(input), "This is a fridge"); if (canFocus) { - // ...unless focused + // ...if focused, it still updates but focus isn't lost. focusElement(input); + DomUtils.setElementValue(input, "something else"); R.set({x:"frog"}); Deps.flush(); - test.equal(DomUtils.getElementValue(input), "This is a fridge"); - - // blurring and re-setting works - blurElement(input); - Deps.flush(); - test.equal(DomUtils.getElementValue(input), "This is a fridge"); + test.equal(DomUtils.getElementValue(input), "This is a frog"); + test.equal(document.activeElement, input); } - R.set({x:"new frog"}); - Deps.flush(); - - test.equal(DomUtils.getElementValue(input), "This is a new frog"); // Setting a value (similar to user typing) should prevent value from being // reverted if the div is re-rendered but the rendered value (ie, R) does @@ -1833,6 +1730,60 @@ Tinytest.add( } ); +// Make sure that if you bind an event on "div p", for example, +// both the div and the p need to be in the template. jQuery's +// `$(elem).find(...)` works this way, but the browser's +// querySelector doesn't. +Tinytest.add( + "spacebars - template - event map selector scope", + function (test) { + var tmpl = Template.spacebars_test_event_selectors1; + var tmpl2 = Template.spacebars_test_event_selectors2; + var buf = []; + tmpl2.events({ + 'click div p': function (evt) { buf.push(evt.currentTarget.className); } + }); + + var div = renderToDiv(tmpl); + document.body.appendChild(div); + test.equal(buf.join(), ''); + clickIt(div.querySelector('.p1')); + test.equal(buf.join(), ''); + clickIt(div.querySelector('.p2')); + test.equal(buf.join(), 'p2'); + document.body.removeChild(div); + } +); + +if (document.addEventListener) { + // see note about non-bubbling events in the "capuring events" + // templating test for why we use the VIDEO tag. (It would be + // nice to get rid of the network dependency, though.) + // We skip this test in IE 8. + Tinytest.add( + "spacebars - template - event map selector scope (capturing)", + function (test) { + var tmpl = Template.spacebars_test_event_selectors_capturing1; + var tmpl2 = Template.spacebars_test_event_selectors_capturing2; + var buf = []; + tmpl2.events({ + 'play div video': function (evt) { buf.push(evt.currentTarget.className); } + }); + + var div = renderToDiv(tmpl); + document.body.appendChild(div); + test.equal(buf.join(), ''); + simulateEvent(div.querySelector(".video1"), + "play", {}, {bubbles: false}); + test.equal(buf.join(), ''); + simulateEvent(div.querySelector(".video2"), + "play", {}, {bubbles: false}); + test.equal(buf.join(), 'video2'); + document.body.removeChild(div); + } + ); +} + Tinytest.add("spacebars - template - tables", function (test) { var tmpl1 = Template.spacebars_test_tables1; @@ -2011,3 +1962,297 @@ Tinytest.add( checkAttrs(" javascript:alert(1)", false); } ); + +Tinytest.add( + "spacebars - template - event handlers get cleaned up with template is removed", + function (test) { + var tmpl = Template.spacebars_test_event_handler_cleanup; + var subtmpl = Template.spacebars_test_event_handler_cleanup_sub; + + var rv = new ReactiveVar(true); + tmpl.foo = function () { + return rv.get(); + }; + + subtmpl.events({ + "click/mouseover": function () { } + }); + + var div = renderToDiv(tmpl); + + test.equal(div.$_uievents["click"].handlers.length, 1); + test.equal(div.$_uievents["mouseover"].handlers.length, 1); + + rv.set(false); + Deps.flush(); + + test.equal(div.$_uievents["click"].handlers.length, 0); + test.equal(div.$_uievents["mouseover"].handlers.length, 0); + } +); + +// https://github.com/meteor/meteor/issues/2156 +Tinytest.add( + "spacebars - template - each with inserts inside autorun", + function (test) { + var tmpl = Template.spacebars_test_each_with_autorun_insert; + var coll = new Meteor.Collection(null); + var rv = new ReactiveVar; + + tmpl.items = function () { + return coll.find(); + }; + + var div = renderToDiv(tmpl); + + Deps.autorun(function () { + if (rv.get()) { + coll.insert({ name: rv.get() }); + } + }); + + rv.set("foo1"); + Deps.flush(); + var firstId = coll.findOne()._id; + + rv.set("foo2"); + Deps.flush(); + + test.equal(canonicalizeHtml(div.innerHTML), "foo1 foo2"); + + coll.update(firstId, { $set: { name: "foo3" } }); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "foo3 foo2"); + } +); + +Tinytest.add( + "spacebars - ui hooks", + function (test) { + var tmpl = Template.spacebars_test_ui_hooks; + var rv = new ReactiveVar([]); + tmpl.items = function () { + return rv.get(); + }; + + var div = renderToDiv(tmpl); + + var hooks = []; + var container = div.querySelector(".test-ui-hooks"); + + // Before we attach the ui hooks, put two items in the DOM. + var origVal = [{ _id: 'foo1' }, { _id: 'foo2' }]; + rv.set(origVal); + Deps.flush(); + + container._uihooks = { + insertElement: function (n, next) { + hooks.push("insert"); + + // check that the element hasn't actually been added yet + test.isTrue(n.parentNode.nodeType === 11 /*DOCUMENT_FRAGMENT_NODE*/); + test.isFalse(n.parentNode.parentNode); + }, + removeElement: function (n) { + hooks.push("remove"); + // check that the element hasn't actually been removed yet + test.isTrue(n.parentNode === container); + }, + moveElement: function (n, next) { + hooks.push("move"); + // check that the element hasn't actually been moved yet + test.isFalse(n.nextNode === next); + } + }; + + var testDomUnchanged = function () { + var items = div.querySelectorAll(".item"); + test.equal(items.length, 2); + test.equal(canonicalizeHtml(items[0].innerHTML), "foo1"); + test.equal(canonicalizeHtml(items[1].innerHTML), "foo2"); + }; + + var newVal = _.clone(origVal); + newVal.push({ _id: 'foo3' }); + rv.set(newVal); + Deps.flush(); + test.equal(hooks, ['insert']); + testDomUnchanged(); + + newVal.reverse(); + rv.set(newVal); + Deps.flush(); + test.equal(hooks, ['insert', 'move']); + testDomUnchanged(); + + newVal = [origVal[0]]; + rv.set(newVal); + Deps.flush(); + test.equal(hooks, ['insert', 'move', 'remove']); + testDomUnchanged(); + } +); + +Tinytest.add( + "spacebars - ui hooks - nested domranges", + function (test) { + var tmpl = Template.spacebars_test_ui_hooks_nested; + var rv = new ReactiveVar(true); + + tmpl.foo = function () { + return rv.get(); + }; + + var subtmpl = Template.spacebars_test_ui_hooks_nested_sub; + var uiHookCalled = false; + subtmpl.rendered = function () { + this.firstNode.parentNode._uihooks = { + removeElement: function (node) { + uiHookCalled = true; + } + }; + }; + + var div = renderToDiv(tmpl); + document.body.appendChild(div); + Deps.flush(); + + var htmlBeforeRemove = canonicalizeHtml(div.innerHTML); + rv.set(false); + Deps.flush(); + test.isTrue(uiHookCalled); + var htmlAfterRemove = canonicalizeHtml(div.innerHTML); + test.equal(htmlBeforeRemove, htmlAfterRemove); + document.body.removeChild(div); + } +); + +Tinytest.add( + "spacebars - access template instance from helper", + function (test) { + // Set a property on the template instance; check that it's still + // there from a helper. + + var tmpl = Template.spacebars_test_template_instance_helper; + var value = Random.id(); + var instanceFromHelper; + + tmpl.created = function () { + this.value = value; + }; + tmpl.foo = function () { + instanceFromHelper = UI._templateInstance(); + }; + + var div = renderToDiv(tmpl); + test.equal(instanceFromHelper.value, value); + } +); + +// XXX This is for traversing empty text nodes and should be removed +// on blaze-refactor. +var getSiblingText = function (node, siblingNum) { + var sibling = node; + for (var i = 0; i < siblingNum; i++) { + if (sibling) + sibling = sibling.nextSibling; + } + return $(sibling).text(); +}; + +Tinytest.add( + "spacebars - access template instance from helper, " + + "template instance is kept up-to-date", + function (test) { + var tmpl = Template.spacebars_test_template_instance_helper; + var rv = new ReactiveVar(""); + var instanceFromHelper; + + tmpl.foo = function () { + instanceFromHelper = UI._templateInstance(); + return rv.get(); + }; + + var div = renderToDiv(tmpl); + rv.set("first"); + Deps.flush(); + // `nextSibling` because the first node is an empty text node. + test.equal(getSiblingText(instanceFromHelper.firstNode, 4), + "first"); + + rv.set("second"); + Deps.flush(); + test.equal(getSiblingText(instanceFromHelper.firstNode, 4), + "second"); + + // UI._templateInstance() should throw when called from not within a + // helper. + test.throws(function () { + UI._templateInstance(); + }); + } +); + +Tinytest.add( + "spacebars - {{#with}} autorun is cleaned up", + function (test) { + var tmpl = Template.spacebars_test_with_cleanup; + var rv = new ReactiveVar(""); + var helperCalled = false; + tmpl.foo = function () { + helperCalled = true; + return rv.get(); + }; + + var div = renderToDiv(tmpl); + rv.set("first"); + Deps.flush(); + test.equal(helperCalled, true); + + helperCalled = false; + $(div).find(".test-with-cleanup").remove(); + + rv.set("second"); + Deps.flush(); + test.equal(helperCalled, false); + } +); + +Tinytest.add( + "spacebars - access parent data contexts from helper", + function (test) { + var childTmpl = Template.spacebars_test_template_parent_data_helper_child; + var parentTmpl = Template.spacebars_test_template_parent_data_helper; + var rv = new ReactiveVar(0); + + childTmpl.a = ["a"]; + childTmpl.b = new ReactiveVar("b"); + childTmpl.c = ["c"]; + + childTmpl.foo = function () { + var data = UI._parentData(rv.get()); + return data.get === undefined ? data : data.get(); + }; + + var div = renderToDiv(parentTmpl); + test.equal(canonicalizeHtml(div.innerHTML), "d"); + + rv.set(1); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "b"); + + // Test UI._parentData() reactivity + + childTmpl.b.set("bNew"); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "bNew"); + + rv.set(2); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "a"); + + rv.set(3); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "parent"); + } +); diff --git a/packages/spacebars-tests/template_tests_server.js b/packages/spacebars-tests/template_tests_server.js new file mode 100644 index 0000000000..e120b869dd --- /dev/null +++ b/packages/spacebars-tests/template_tests_server.js @@ -0,0 +1,7 @@ +var path = Npm.require("path"); + +Meteor.methods({ + getAsset: function (filename) { + return Assets.getText(path.join("assets", filename)); + } +}); diff --git a/packages/spacebars/dynamic.html b/packages/spacebars/dynamic.html new file mode 100644 index 0000000000..00dedc7b8f --- /dev/null +++ b/packages/spacebars/dynamic.html @@ -0,0 +1,26 @@ +<!-- Expects the data context to have a `template` property (the name of +the template to render) and an optional `data` property. If the `data` +property is not specified, then the parent data context will be used +instead. Uses the __dynamicWithDataContext template below to actually +render the template. --> +<template name="__dynamic"> + {{checkContext}} + {{#if dataContextPresent}} + {{> __dynamicWithDataContext}} + {{else}} + {{! if there was no explicit 'data' argument, use the parent context}} + {{> __dynamicWithDataContext template=template data=..}} + {{/if}} +</template> + +<!-- Expects the data context to have a `template` property (the name of +the template to render) and a `data` property, which can be falsey. --> +<template name="__dynamicWithDataContext"> + {{#with chooseTemplate template}} + {{#with ../data}} {{! original 'dataContext' argument to __dynamic}} + {{> ..}} {{! return value from chooseTemplate(template) }} + {{else}} {{! if the 'dataContext' argument was falsey }} + {{> .. ../data}} {{! return value from chooseTemplate(template) }} + {{/with}} + {{/with}} +</template> diff --git a/packages/spacebars/dynamic.js b/packages/spacebars/dynamic.js new file mode 100644 index 0000000000..5f019ff208 --- /dev/null +++ b/packages/spacebars/dynamic.js @@ -0,0 +1,21 @@ +Template.__dynamicWithDataContext.chooseTemplate = function (name) { + return Template[name] || null; +}; + +Template.__dynamic.dataContextPresent = function () { + return _.has(this, "data"); +}; + +Template.__dynamic.checkContext = function () { + if (! _.has(this, "template")) { + throw new Error("Must specify name in the 'template' argument " + + "to {{> UI.dynamic}}."); + } + + _.each(this, function (v, k) { + if (k !== "template" && k !== "data") { + throw new Error("Invalid argument to {{> UI.dynamic}}: " + + k); + } + }); +}; diff --git a/packages/spacebars/dynamic_tests.html b/packages/spacebars/dynamic_tests.html new file mode 100644 index 0000000000..5099677f09 --- /dev/null +++ b/packages/spacebars/dynamic_tests.html @@ -0,0 +1,45 @@ +<template name="ui_dynamic_test"> + {{> UI.dynamic template=templateName data=templateData}} +</template> + +<template name="ui_dynamic_test_no_data"> + {{> UI.dynamic template=templateName}} +</template> + +<template name="ui_dynamic_test_inherited_data"> + {{#with context}} + {{> UI.dynamic template=templateName}} + {{else}} + {{> UI.dynamic template=templateName}} + {{/with}} +</template> + +<template name="ui_dynamic_test_sub"> + test{{foo}} +</template> + +<template name="ui_dynamic_test_falsey_inner_context"> + {{#with foo="bar"}} + {{> UI.dynamic template=templateName data=context}} + {{/with}} +</template> + +<template name="ui_dynamic_test_bad_args0"> + {{> UI.dynamic}} +</template> + +<template name="ui_dynamic_test_bad_args1"> + {{> UI.dynamic foo="bar"}} +</template> + +<template name="ui_dynamic_test_bad_args2"> + {{> UI.dynamic template="ui_dynamic_test_sub" foo="bar"}} +</template> + +<template name="ui_dynamic_test_falsey_context"> + {{> UI.dynamic template="ui_dynamic_test_falsey_context_sub"}} +</template> + +<template name="ui_dynamic_test_falsey_context_sub"> + {{foo}} +</template> diff --git a/packages/spacebars/dynamic_tests.js b/packages/spacebars/dynamic_tests.js new file mode 100644 index 0000000000..a8b62f5275 --- /dev/null +++ b/packages/spacebars/dynamic_tests.js @@ -0,0 +1,146 @@ +Tinytest.add( + "ui-dynamic-template - render template dynamically", function (test, expect) { + var tmpl = Template.ui_dynamic_test; + + var nameVar = new ReactiveVar; + var dataVar = new ReactiveVar; + tmpl.templateName = function () { + return nameVar.get(); + }; + tmpl.templateData = function () { + return dataVar.get(); + }; + + // No template chosen + var div = renderToDiv(tmpl); + test.equal(canonicalizeHtml(div.innerHTML), ""); + + // Choose the "ui-dynamic-test-sub" template, with no data context + // passed in. + nameVar.set("ui_dynamic_test_sub"); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "test"); + + // Set a data context. + dataVar.set({ foo: "bar" }); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "testbar"); + }); + +// Same test as above, but the {{> UI.dynamic}} inclusion has no +// `dataContext` argument. +Tinytest.add( + "ui-dynamic-template - render template dynamically, no data context", + function (test, expect) { + var tmpl = Template.ui_dynamic_test_no_data; + + var nameVar = new ReactiveVar; + tmpl.templateName = function () { + return nameVar.get(); + }; + + var div = renderToDiv(tmpl); + test.equal(canonicalizeHtml(div.innerHTML), ""); + + nameVar.set("ui_dynamic_test_sub"); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "test"); + }); + + +Tinytest.add( + "ui-dynamic-template - render template " + + "dynamically, data context gets inherited", + function (test, expect) { + var tmpl = Template.ui_dynamic_test_inherited_data; + + var nameVar = new ReactiveVar(); + var dataVar = new ReactiveVar(); + tmpl.templateName = function () { + return nameVar.get(); + }; + tmpl.context = function () { + return dataVar.get(); + }; + + var div = renderToDiv(tmpl); + test.equal(canonicalizeHtml(div.innerHTML), ""); + + nameVar.set("ui_dynamic_test_sub"); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "test"); + + // Set the top-level template's data context; this should be + // inherited by the dynamically-chosen template, since the {{> + // UI.dynamic}} inclusion didn't include a data argument. + dataVar.set({ foo: "bar" }); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), "testbar"); + } +); + +Tinytest.add( + "ui-dynamic-template - render template " + + "dynamically, data context does not get inherited if " + + "falsey context is passed in", + function (test, expect) { + var tmpl = Template.ui_dynamic_test_falsey_inner_context; + + var nameVar = new ReactiveVar(); + var dataVar = new ReactiveVar(); + tmpl.templateName = function () { + return nameVar.get(); + }; + tmpl.context = function () { + return dataVar.get(); + }; + + var div = renderToDiv(tmpl); + test.equal(canonicalizeHtml(div.innerHTML), ""); + + nameVar.set("ui_dynamic_test_sub"); + Deps.flush(); + // Even though the data context is falsey, we DON'T expect the + // subtemplate to inherit the data context from the parent template. + test.equal(canonicalizeHtml(div.innerHTML), "test"); + } +); + +Tinytest.add( + "ui-dynamic-template - render template " + + "dynamically, bad arguments", + function (test, expect) { + var tmplPrefix = "ui_dynamic_test_bad_args"; + var errors = [ + "Must specify 'template' as an argument", + "Must specify 'template' as an argument", + "Invalid argument to {{> UI.dynamic}}" + ]; + + for (var i = 0; i < 3; i++) { + var tmpl = Template[tmplPrefix + i]; + test.throws(function () { + var div = renderToDiv(tmpl); + }); + } + } +); + +Tinytest.add( + "ui-dynamic-template - render template " + + "dynamically, falsey context", + function (test, expect) { + var tmpl = Template.ui_dynamic_test_falsey_context; + var subtmpl = Template.ui_dynamic_test_falsey_context_sub; + + var subtmplContext; + subtmpl.foo = function () { + subtmplContext = this; + }; + var div = renderToDiv(tmpl); + + // Because `this` can only be an object, Blaze normalizes falsey + // data contexts to {}. + test.equal(subtmplContext, {}); + } +); diff --git a/packages/spacebars/package.js b/packages/spacebars/package.js index 93c2f50dc5..4c20f2ee2b 100644 --- a/packages/spacebars/package.js +++ b/packages/spacebars/package.js @@ -12,9 +12,18 @@ Package.describe({ // Additional tests are in `spacebars-tests`. Package.on_use(function (api) { - api.export('Spacebars'); + api.use('spacebars-common'); + api.imply('spacebars-common'); api.use('htmljs'); api.use('ui'); + api.use('templating'); api.add_files(['spacebars-runtime.js']); + api.add_files(['dynamic.html', 'dynamic.js'], 'client'); +}); + +Package.on_test(function (api) { + api.use(["spacebars", "tinytest", "test-helpers"]); + api.use("templating", "client"); + api.add_files(["dynamic_tests.html", "dynamic_tests.js"], "client"); }); diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index 59c2f37a47..c79b1fc66b 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -1,5 +1,3 @@ -Spacebars = {}; - // * `templateOrFunction` - template (component) or function returning a template // or null Spacebars.include = function (templateOrFunction, contentBlock, elseContentBlock) { @@ -222,13 +220,18 @@ Spacebars.With = function (argFunc, contentBlock, elseContentBlock) { return UI.If(this.v, UI.With(this.v, contentBlock), elseContentBlock); }, materialized: (function () { - var f = function () { + var f = function (range) { var self = this; if (Deps.active) { Deps.onInvalidate(function () { self.v.stop(); }); } + if (range) { + range.removed = function () { + self.v.stop(); + }; + } }; f.isWith = true; return f; diff --git a/packages/srp/package.js b/packages/srp/package.js index f304b8dccc..674a14437a 100644 --- a/packages/srp/package.js +++ b/packages/srp/package.js @@ -1,13 +1,18 @@ +// XXX COMPAT WITH 0.8.1.3 +// This package is replaced by the use of bcrypt in accounts-password, +// but we are leaving in some of the code to allow existing user +// databases to be upgraded from SRP to bcrypt. + Package.describe({ summary: "Library for Secure Remote Password (SRP) exchanges", internal: true }); Package.on_use(function (api) { - api.use(['random', 'check'], ['client', 'server']); + api.use(['random', 'check', 'sha'], ['client', 'server']); api.use('underscore'); api.export('SRP'); - api.add_files(['biginteger.js', 'sha256.js', 'srp.js'], + api.add_files(['biginteger.js', 'srp.js'], ['client', 'server']); }); diff --git a/packages/srp/srp.js b/packages/srp/srp.js index 231a1b59b7..1fd7344007 100644 --- a/packages/srp/srp.js +++ b/packages/srp/srp.js @@ -1,6 +1,11 @@ -SRP = {}; +// This package contains just enough of the original SRP code to +// support the backwards-compatibility upgrade path. +// +// An SRP (and possibly also accounts-srp) package should eventually be +// available in Atmosphere so that users can continue to use SRP if they +// want to. -/////// PUBLIC CLIENT +SRP = {}; /** * Generate a new SRP verifier. Password is the plaintext password. @@ -8,6 +13,7 @@ SRP = {}; * options is optional and can include: * - identity: String. The SRP username to user. Mostly this is passed * in for testing. Random UUID if not provided. + * - hashedIdentityAndPassword: combined identity and password, already hashed, for the SRP to bcrypt upgrade path. * - salt: String. A salt to use. Mostly this is passed in for * testing. Random UUID if not provided. * - SRP parameters (see _defaults and paramsFromOptions below) @@ -15,14 +21,19 @@ SRP = {}; SRP.generateVerifier = function (password, options) { var params = paramsFromOptions(options); - var identity = (options && options.identity) || Random.secret(); var salt = (options && options.salt) || Random.secret(); - var x = params.hash(salt + params.hash(identity + ":" + password)); + var identity; + var hashedIdentityAndPassword = options && options.hashedIdentityAndPassword; + if (!hashedIdentityAndPassword) { + identity = (options && options.identity) || Random.secret(); + hashedIdentityAndPassword = params.hash(identity + ":" + password); + } + + var x = params.hash(salt + hashedIdentityAndPassword); var xi = new BigInteger(x, 16); var v = params.g.modPow(xi, params.N); - return { identity: identity, salt: salt, @@ -38,249 +49,6 @@ SRP.matchVerifier = { }; -/** - * Generate a new SRP client object. Password is the plaintext password. - * - * options is optional and can include: - * - a: client's private ephemeral value. String or - * BigInteger. Normally, this is picked randomly, but it can be - * passed in for testing. - * - SRP parameters (see _defaults and paramsFromOptions below) - */ -SRP.Client = function (password, options) { - var self = this; - self.params = paramsFromOptions(options); - self.password = password; - - // shorthand - var N = self.params.N; - var g = self.params.g; - - // construct public and private keys. - var a, A; - if (options && options.a) { - if (typeof options.a === "string") - a = new BigInteger(options.a, 16); - else if (options.a instanceof BigInteger) - a = options.a; - else - throw new Error("Invalid parameter: a"); - - A = g.modPow(a, N); - - if (A.mod(N) === 0) - throw new Error("Invalid parameter: a: A mod N == 0."); - - } else { - while (!A || A.mod(N) === 0) { - a = randInt(); - A = g.modPow(a, N); - } - } - - self.a = a; - self.A = A; - self.Astr = A.toString(16); -}; - - -/** - * Initiate an SRP exchange. - * - * returns { A: 'client public ephemeral key. hex encoded integer.' } - */ -SRP.Client.prototype.startExchange = function () { - var self = this; - - return { - A: self.Astr - }; -}; - -/** - * Respond to the server's challenge with a proof of password. - * - * challenge is an object with - * - B: server public ephemeral key. hex encoded integer. - * - identity: user's identity (SRP username). - * - salt: user's salt. - * - * returns { M: 'client proof of password. hex encoded integer.' } - * throws an error if it got an invalid challenge. - */ -SRP.Client.prototype.respondToChallenge = function (challenge) { - var self = this; - - // shorthand - var N = self.params.N; - var g = self.params.g; - var k = self.params.k; - var H = self.params.hash; - - // XXX check for missing / bad parameters. - self.identity = challenge.identity; - self.salt = challenge.salt; - self.Bstr = challenge.B; - self.B = new BigInteger(self.Bstr, 16); - - if (self.B.mod(N) === 0) - throw new Error("Server sent invalid key: B mod N == 0."); - - var u = new BigInteger(H(self.Astr + self.Bstr), 16); - var x = new BigInteger( - H(self.salt + H(self.identity + ":" + self.password)), 16); - - var kgx = k.multiply(g.modPow(x, N)); - var aux = self.a.add(u.multiply(x)); - var S = self.B.subtract(kgx).modPow(aux, N); - var M = H(self.Astr + self.Bstr + S.toString(16)); - var HAMK = H(self.Astr + M + S.toString(16)); - - self.S = S; - self.HAMK = HAMK; - - return { - M: M - }; -}; - - -/** - * Verify server's confirmation message. - * - * confirmation is an object with - * - HAMK: server's proof of password. - * - * returns true or false. - */ -SRP.Client.prototype.verifyConfirmation = function (confirmation) { - var self = this; - - return (self.HAMK && (confirmation.HAMK === self.HAMK)); -}; - - - -/////// PUBLIC SERVER - - -/** - * Generate a new SRP server object. Password is the plaintext password. - * - * options is optional and can include: - * - b: server's private ephemeral value. String or - * BigInteger. Normally, this is picked randomly, but it can be - * passed in for testing. - * - SRP parameters (see _defaults and paramsFromOptions below) - */ -SRP.Server = function (verifier, options) { - var self = this; - self.params = paramsFromOptions(options); - self.verifier = verifier; - - // shorthand - var N = self.params.N; - var g = self.params.g; - var k = self.params.k; - var v = new BigInteger(self.verifier.verifier, 16); - - // construct public and private keys. - var b, B; - if (options && options.b) { - if (typeof options.b === "string") - b = new BigInteger(options.b, 16); - else if (options.b instanceof BigInteger) - b = options.b; - else - throw new Error("Invalid parameter: b"); - - B = k.multiply(v).add(g.modPow(b, N)).mod(N); - - if (B.mod(N) === 0) - throw new Error("Invalid parameter: b: B mod N == 0."); - - } else { - while (!B || B.mod(N) === 0) { - b = randInt(); - B = k.multiply(v).add(g.modPow(b, N)).mod(N); - } - } - - self.b = b; - self.B = B; - self.Bstr = B.toString(16); - -}; - - -/** - * Issue a challenge to the client. - * - * Takes a request from the client containing: - * - A: hex encoded int. - * - * Returns a challenge with: - * - B: server public ephemeral key. hex encoded integer. - * - identity: user's identity (SRP username). - * - salt: user's salt. - * - * Throws an error if issued a bad request. - */ -SRP.Server.prototype.issueChallenge = function (request) { - var self = this; - - // XXX check for missing / bad parameters. - self.Astr = request.A; - self.A = new BigInteger(self.Astr, 16); - - if (self.A.mod(self.params.N) === 0) - throw new Error("Client sent invalid key: A mod N == 0."); - - // shorthand - var N = self.params.N; - var H = self.params.hash; - - // Compute M and HAMK in advance. Don't send to client yet. - var u = new BigInteger(H(self.Astr + self.Bstr), 16); - var v = new BigInteger(self.verifier.verifier, 16); - var avu = self.A.multiply(v.modPow(u, N)); - self.S = avu.modPow(self.b, N); - self.M = H(self.Astr + self.Bstr + self.S.toString(16)); - self.HAMK = H(self.Astr + self.M + self.S.toString(16)); - - return { - identity: self.verifier.identity, - salt: self.verifier.salt, - B: self.Bstr - }; -}; - - -/** - * Verify a response from the client and return confirmation. - * - * Takes a challenge response from the client containing: - * - M: client proof of password. hex encoded int. - * - * Returns a confirmation if the client's proof is good: - * - HAMK: server proof of password. hex encoded integer. - * OR null if the client's proof doesn't match. - */ -SRP.Server.prototype.verifyResponse = function (response) { - var self = this; - - if (response.M !== self.M) - return null; - - return { - HAMK: self.HAMK - }; -}; - - - -/////// INTERNAL - /** * Default parameter values for SRP. * @@ -331,8 +99,3 @@ var paramsFromOptions = function (options) { return ret; }; - - -var randInt = function () { - return new BigInteger(Random.hexString(36), 16); -}; diff --git a/packages/srp/srp_tests.js b/packages/srp/srp_tests.js index d1ea3edc35..597b4cf4df 100644 --- a/packages/srp/srp_tests.js +++ b/packages/srp/srp_tests.js @@ -1,38 +1,6 @@ -Tinytest.add("srp - good exchange", function(test) { - var password = 'hi there!'; - var verifier = SRP.generateVerifier(password); - - var C = new SRP.Client(password); - var S = new SRP.Server(verifier); - - var request = C.startExchange(); - var challenge = S.issueChallenge(request); - var response = C.respondToChallenge(challenge); - var confirmation = S.verifyResponse(response); - - test.isTrue(confirmation); - test.isTrue(C.verifyConfirmation(confirmation)); - -}); - -Tinytest.add("srp - bad exchange", function(test) { - var verifier = SRP.generateVerifier('one password'); - - var C = new SRP.Client('another password'); - var S = new SRP.Server(verifier); - - var request = C.startExchange(); - var challenge = S.issueChallenge(request); - var response = C.respondToChallenge(challenge); - var confirmation = S.verifyResponse(response); - - test.isFalse(confirmation); -}); - - Tinytest.add("srp - fixed values", function(test) { - // Test exact values during the exchange. We have to be very careful - // about changing the SRP code, because changes could render + // Test exact values outputted by `generateVerifier`. We have to be very + // careful about changing the SRP code, because changes could render // people's existing user database unusable. This test is // intentionally brittle to catch change that could affect the // validity of user passwords. @@ -45,71 +13,7 @@ Tinytest.add("srp - fixed values", function(test) { var verifier = SRP.generateVerifier( password, {identity: identity, salt: salt}); - - var C = new SRP.Client(password, {a: a}); - var S = new SRP.Server(verifier, {b: b}); - - var request = C.startExchange(); - test.equal(request.A, "8a75aa61471a92d4c3b5d53698c910af5ef013c42799876c40612d1d5e0dc41d01f669bc022fadcd8a704030483401a1b86b8670191bd9dfb1fb506dd11c688b2f08e9946756263954db2040c1df1894af7af5f839c9215bb445268439157e65e8f100469d575d5d0458e19e8bd4dd4ea2c0b30b1b3f4f39264de4ec596e0bb7"); - - var challenge = S.issueChallenge(request); - test.equal(challenge.B, "77ab0a40ef428aa2fa2bc257c905f352c7f75fbcfdb8761393c9dc0f730bbb0270ba9f837545b410c955c3f761494b329ad23c6efdec7e63509e538c2f68a3526e072550a11dac46017718362205e0c698b5bed67d6ff475aa92c191ca169f865c81a1a577373c449b98df720c7b7ff50536f9919d781e698025fd7164932ba7"); - - var response = C.respondToChallenge(challenge); - test.equal(response.M, "8705d31bb61497279adf44eef6c167dcb7e03aa7a42102c1ea7e73025fbd4cd9"); - - var confirmation = S.verifyResponse(response); - test.equal(confirmation.HAMK, "07a0f200392fa9a084db7acc2021fbc174bfb36956b46835cc12506b68b27bba"); - - test.isTrue(C.verifyConfirmation(confirmation)); -}); - - -Tinytest.add("srp - options", function(test) { - // test that all options are respected. - // - // Note, all test strings here should be hex, because the 'hash' - // function needs to output numbers. - - var baseOptions = { - hash: function (x) { return x; }, - N: 'b', - g: '2', - k: '1' - }; - var verifierOptions = _.extend({ - identity: 'a', - salt: 'b' - }, baseOptions); - var clientOptions = _.extend({ - a: "2" - }, baseOptions); - var serverOptions = _.extend({ - b: "2" - }, baseOptions); - - var verifier = SRP.generateVerifier('c', verifierOptions);; - - test.equal(verifier.identity, 'a'); - test.equal(verifier.salt, 'b'); - test.equal(verifier.verifier, '3'); - - var C = new SRP.Client('c', clientOptions); - var S = new SRP.Server(verifier, serverOptions); - - var request = C.startExchange(); - test.equal(request.A, '4'); - - var challenge = S.issueChallenge(request); - test.equal(challenge.identity, 'a'); - test.equal(challenge.salt, 'b'); - test.equal(challenge.B, '7'); - - var response = C.respondToChallenge(challenge); - test.equal(response.M, '471'); - - var confirmation = S.verifyResponse(response); - test.isTrue(confirmation); - test.equal(confirmation.HAMK, '44711'); - + test.equal(verifier.identity, identity); + test.equal(verifier.salt, salt); + test.equal(verifier.verifier, "56778b720d20b2e306f04e47180fb94335b88a6052808483acb0e85612606f9f1d8d5a3c6b85e0c7bfec7f08c07bdfbd0d40b032f517871dd8afd045b0f24e2edc05ccdc47b19f35d2eb9f7670521a38c1b358fcee63f052a1aedbb1282d3b92c7a554f8523f3379c2fbc6885be8227fbd426ad6960c3839809f8c94d80a6c51"); }); diff --git a/packages/stylus/.npm/plugin/compileStylus/npm-shrinkwrap.json b/packages/stylus/.npm/plugin/compileStylus/npm-shrinkwrap.json index f1651f24a7..b04c343ccb 100644 --- a/packages/stylus/.npm/plugin/compileStylus/npm-shrinkwrap.json +++ b/packages/stylus/.npm/plugin/compileStylus/npm-shrinkwrap.json @@ -23,7 +23,7 @@ } }, "stylus": { - "version": "0.42.3", + "version": "0.46.3", "dependencies": { "css-parse": { "version": "1.7.0" @@ -32,16 +32,24 @@ "version": "0.3.5" }, "debug": { - "version": "0.7.4" + "version": "1.0.1", + "dependencies": { + "ms": { + "version": "0.6.2" + } + } }, "sax": { "version": "0.5.8" }, "glob": { - "version": "3.2.9", + "version": "3.2.11", "dependencies": { + "inherits": { + "version": "2.0.1" + }, "minimatch": { - "version": "0.2.14", + "version": "0.3.0", "dependencies": { "lru-cache": { "version": "2.5.0" @@ -50,9 +58,6 @@ "version": "1.0.0" } } - }, - "inherits": { - "version": "2.0.1" } } } diff --git a/packages/stylus/package.js b/packages/stylus/package.js index c3b995389c..371d6528e6 100644 --- a/packages/stylus/package.js +++ b/packages/stylus/package.js @@ -8,7 +8,7 @@ Package._transitional_registerBuildPlugin({ sources: [ 'plugin/compile-stylus.js' ], - npmDependencies: { stylus: "0.42.3", nib: "1.0.2" } + npmDependencies: { stylus: "0.46.3", nib: "1.0.2" } }); Package.on_test(function (api) { diff --git a/packages/test-helpers/canonicalize_html.js b/packages/test-helpers/canonicalize_html.js index ba1868d0a3..6a5ed5f4a9 100644 --- a/packages/test-helpers/canonicalize_html.js +++ b/packages/test-helpers/canonicalize_html.js @@ -31,12 +31,15 @@ canonicalizeHtml = function(html) { attrs = attrs.replace(/\s+/g, ' '); // quote unquoted attribute values, as in `type=checkbox`. This // will do the wrong thing if there's an `=` in an attribute value. - attrs = attrs.replace(/(\w)=([^" >/]+)/g, '$1="$2"'); - // for the purpose of splitting attributes in a string like - // 'a="b" c="d"', assume they are separated by a single space - // and values are double-quoted, but allow for spaces inside - // the quotes. Split on space following quote. - var attrList = attrs.replace(/" /g, '"\u0000').split('\u0000'); + attrs = attrs.replace(/(\w)=([^'" >/]+)/g, '$1="$2"'); + + // for the purpose of splitting attributes in a string like 'a="b" + // c="d"', assume they are separated by a single space and values + // are double- or single-quoted, but allow for spaces inside the + // quotes. Split on space following quote. + var attrList = attrs.replace(/(\w)='([^']*)' /g, "$1='$2'\u0000"); + attrList = attrList.replace(/(\w)="([^"]*)" /g, '$1="$2"\u0000'); + attrList = attrList.split("\u0000"); // put attributes in alphabetical order attrList.sort(); @@ -59,11 +62,33 @@ canonicalizeHtml = function(html) { if (key === 'sizset') continue; var value = a[1]; - value = value.replace(/["'`]/g, '"'); - // this check is probably made unreachable by a regex above - // that quotes unquoted attribute values - if (value.charAt(0) !== '"') - value = '"'+value+'"'; + + // make sure the attribute is doubled-quoted + if (value.charAt(0) === '"') { + // Do nothing + } else { + if (value.charAt(0) !== "'") { + // attribute is unquoted. should be unreachable because of + // regex above. + value = '"' + value + '"'; + } else { + // attribute is single-quoted. make it double-quoted. + value = value.replace(/\"/g, "&quot;"); + } + value = value.replace(/["'`]/g, '"'); + } + + // Encode quotes and double quotes in the attribute. + var attr = value.slice(1, -1); + attr = attr.replace(/\"/g, "&quot;"); + attr = attr.replace(/\'/g, "&quot;"); + value = '"' + attr + '"'; + + // Ensure that styles do not end with a semicolon. + if (key === 'style') { + value = value.replace(/;\"$/, '"'); + } + tagContents.push(key+'='+value); } return '<'+tagContents.join(' ')+'>'; diff --git a/packages/test-helpers/package.js b/packages/test-helpers/package.js index 1098be3b0d..f20568a909 100644 --- a/packages/test-helpers/package.js +++ b/packages/test-helpers/package.js @@ -20,6 +20,7 @@ Package.on_use(function (api) { 'pollUntil', 'try_all_permutations', 'SeededRandom', 'ReactiveVar', 'clickElement', 'blurElement', 'focusElement', 'simulateEvent', 'getStyleProperty', 'canonicalizeHtml', + 'renderToDiv', 'withCallbackLogger', 'testAsyncMulti', 'simplePoll', 'makeTestConnection', 'DomUtils'], {testOnly: true}); @@ -28,6 +29,7 @@ Package.on_use(function (api) { api.add_files('event_simulation.js'); api.add_files('seeded_random.js'); api.add_files('canonicalize_html.js'); + api.add_files('render_div.js'); api.add_files('current_style.js'); api.add_files('reactivevar.js'); api.add_files('callback_logger.js'); diff --git a/packages/test-helpers/render_div.js b/packages/test-helpers/render_div.js new file mode 100644 index 0000000000..451afea535 --- /dev/null +++ b/packages/test-helpers/render_div.js @@ -0,0 +1,5 @@ +renderToDiv = function (comp) { + var div = document.createElement("DIV"); + UI.materialize(comp, div); + return div; +}; diff --git a/packages/test-in-console/run.sh b/packages/test-in-console/run.sh index e6999508a8..2b31a7a065 100755 --- a/packages/test-in-console/run.sh +++ b/packages/test-in-console/run.sh @@ -1,6 +1,6 @@ #!/bin/bash -cd `dirname $0` +cd "`dirname "$0"`" cd ../.. export METEOR_HOME=`pwd` diff --git a/packages/tinytest/tinytest.js b/packages/tinytest/tinytest.js index 21498996a6..528a462551 100644 --- a/packages/tinytest/tinytest.js +++ b/packages/tinytest/tinytest.js @@ -1,3 +1,7 @@ +var Future; +if (Meteor.isServer) + Future = Npm.require('fibers/future'); + /******************************************************************************/ /* TestCaseResults */ /******************************************************************************/ @@ -288,12 +292,12 @@ _.extend(TestCaseResults.prototype, { }, // XXX should change to lengthOf to match vowsjs - length: function (obj, expected_length) { + length: function (obj, expected_length, msg) { if (obj.length === expected_length) this.ok(); else this.fail({type: "length", expected: expected_length, - actual: obj.length}); + actual: obj.length, message: msg}); }, // EXPERIMENTAL way to compare two strings that results in @@ -317,11 +321,10 @@ _.extend(TestCaseResults.prototype, { /* TestCase */ /******************************************************************************/ -TestCase = function (name, func, async) { +TestCase = function (name, func) { var self = this; self.name = name; self.func = func; - self.async = async || false; var nameParts = _.map(name.split(" - "), function(s) { return s.replace(/^\s*|\s*$/g, ""); // trim @@ -366,16 +369,10 @@ _.extend(TestCase.prototype, { Meteor.defer(function () { try { - if (self.async) { - self.func(results, function () { - if (markComplete()) - onComplete(); - }); - } else { - self.func(results); + self.func(results, function () { if (markComplete()) onComplete(); - } + }); } catch (e) { if (markComplete()) onException(e); @@ -392,6 +389,7 @@ TestManager = function () { var self = this; self.tests = {}; self.ordered_tests = []; + self.testQueue = Meteor.isServer && new Meteor._SynchronousQueue(); }; _.extend(TestManager.prototype, { @@ -442,73 +440,110 @@ _.extend(TestRun.prototype, { return true; }, + _runTest: function (test, onComplete, stop_at_offset) { + var self = this; + + var startTime = (+new Date); + + test.run(function (event) { + /* onEvent */ + // Ignore result callbacks if the test has already been reported + // as timed out. + if (test.timedOut) + return; + self._report(test, event); + }, function () { + /* onComplete */ + if (test.timedOut) + return; + var totalTime = (+new Date) - startTime; + self._report(test, {type: "finish", timeMs: totalTime}); + onComplete(); + }, function (exception) { + /* onException */ + if (test.timedOut) + return; + + // XXX you want the "name" and "message" fields on the + // exception, to start with.. + self._report(test, { + type: "exception", + details: { + message: exception.message, // XXX empty??? + stack: exception.stack // XXX portability + } + }); + + onComplete(); + }, stop_at_offset); + }, + + // Run a single test. On the server, ensure that only one test runs + // at a time, even with multiple clients submitting tests. However, + // time out the test after three minutes to avoid locking up the + // server if a test fails to complete. + // _runOne: function (test, onComplete, stop_at_offset) { var self = this; - var startTime = (+new Date); - if (self._prefixMatch(test.groupPath)) { - test.run(function (event) { - /* onEvent */ - self._report(test, event); - }, function () { - /* onComplete */ - var totalTime = (+new Date) - startTime; - self._report(test, {type: "finish", timeMs: totalTime}); - onComplete && onComplete(); - }, function (exception) { - /* onException */ - // XXX you want the "name" and "message" fields on the - // exception, to start with.. - self._report(test, { - type: "exception", - details: { - message: exception.message, // XXX empty??? - stack: exception.stack // XXX portability - } - }); + if (! self._prefixMatch(test.groupPath)) { + onComplete && onComplete(); + return; + } + if (Meteor.isServer) { + // On the server, ensure that only one test runs at a time, even + // with multiple clients. + self.manager.testQueue.queueTask(function () { + // The future resolves when the test completes or times out. + var future = new Future(); + Meteor.setTimeout( + function () { + if (future.isResolved()) + // If the future has resolved the test has completed. + return; + test.timedOut = true; + self._report(test, { + type: "exception", + details: { + message: "test timed out" + } + }); + future['return'](); + }, + 3 * 60 * 1000 // 3 minutes + ); + self._runTest(test, function () { + // The test can complete after it has timed out (it might + // just be slow), so only resolve the future if the test + // hasn't timed out. + if (! future.isResolved()) + future['return'](); + }, stop_at_offset); + // Wait for the test to complete or time out. + future.wait(); + onComplete & onComplete(); + }); + } else { + // client + self._runTest(test, function () { onComplete && onComplete(); }, stop_at_offset); - } else { - onComplete && onComplete(); } }, run: function (onComplete) { var self = this; - // create array of arrays of tests; synchronous tests in - // different groups are run in parallel on client, async tests or - // tests in different groups are run in sequence, as are all - // tests on server - var testGroups = _.values( - _.groupBy(self.manager.ordered_tests, - function(t) { - if (Meteor.isServer) - return "SERVER"; - if (t.async) - return "ASYNC"; - return t.name.split(" - ")[0]; - })); + var tests = _.clone(self.manager.ordered_tests); - if (! testGroups.length) { - onComplete(); - } else { - var groupsDone = 0; + var runNext = function () { + if (tests.length) + self._runOne(tests.shift(), runNext); + else + onComplete && onComplete(); + }; - _.each(testGroups, function(tests) { - var runNext = function () { - if (tests.length) { - self._runOne(tests.shift(), runNext); - } else { - groupsDone++; - if (groupsDone >= testGroups.length) - onComplete(); - } - }; - - runNext(); - }); - } + runNext(); }, // An alternative to run(). Given the 'cookie' attribute of a @@ -542,12 +577,15 @@ _.extend(TestRun.prototype, { Tinytest = {}; -Tinytest.add = function (name, func) { +Tinytest.addAsync = function (name, func) { TestManager.addCase(new TestCase(name, func)); }; -Tinytest.addAsync = function (name, func) { - TestManager.addCase(new TestCase(name, func, true)); +Tinytest.add = function (name, func) { + Tinytest.addAsync(name, function (test, onComplete) { + func(test); + onComplete(); + }); }; // Run every test, asynchronously. Runs the test in the current diff --git a/packages/ui/attrs.js b/packages/ui/attrs.js index c87392b2b2..a15e2e7a6e 100644 --- a/packages/ui/attrs.js +++ b/packages/ui/attrs.js @@ -48,46 +48,61 @@ AttributeHandler.extend = function (options) { return subType; }; -// Extended below to support both regular and SVG elements -var BaseClassHandler = AttributeHandler.extend({ +/// Apply the diff between the attributes of "oldValue" and "value" to "element." +// +// Each subclass must implement a parseValue method which takes a string +// as an input and returns a dict of attributes. The keys of the dict +// are unique identifiers (ie. css properties in the case of styles), and the +// values are the entire attribute which will be injected into the element. +// +// Extended below to support classes, SVG elements and styles. + +var DiffingAttributeHandler = AttributeHandler.extend({ update: function (element, oldValue, value) { - if (!this.getCurrentValue || !this.setValue) - throw new Error("Missing methods in subclass of 'BaseClassHandler'"); + if (!this.getCurrentValue || !this.setValue || !this.parseValue) + throw new Error("Missing methods in subclass of 'DiffingAttributeHandler'"); - var oldClasses = oldValue ? _.compact(oldValue.split(' ')) : []; - var newClasses = value ? _.compact(value.split(' ')) : []; + var oldAttrsMap = oldValue ? this.parseValue(oldValue) : {}; + var newAttrsMap = value ? this.parseValue(value) : {}; - // the current classes on the element, which we will mutate. - var classes = _.compact(this.getCurrentValue(element).split(' ')); + // the current attributes on the element, which we will mutate. - // optimize this later (to be asymptotically faster) if necessary - for (var i = 0; i < oldClasses.length; i++) { - var c = oldClasses[i]; - if (! _.contains(newClasses, c)) - classes = _.without(classes, c); - } - for (var i = 0; i < newClasses.length; i++) { - var c = newClasses[i]; - if ((! _.contains(oldClasses, c)) && - (! _.contains(classes, c))) - classes.push(c); - } + var attrString = this.getCurrentValue(element); + var attrsMap = attrString ? this.parseValue(attrString) : {}; - this.setValue(element, classes.join(' ')); + _.each(_.keys(oldAttrsMap), function (t) { + if (! (t in newAttrsMap)) + delete attrsMap[t]; + }); + + _.each(_.keys(newAttrsMap), function (t) { + attrsMap[t] = newAttrsMap[t]; + }); + + this.setValue(element, _.values(attrsMap).join(' ')); } }); -var ClassHandler = BaseClassHandler.extend({ +var ClassHandler = DiffingAttributeHandler.extend({ // @param rawValue {String} getCurrentValue: function (element) { return element.className; }, setValue: function (element, className) { element.className = className; + }, + parseValue: function (attrString) { + var tokens = {}; + + _.each(attrString.split(' '), function(token) { + if (token) + tokens[token] = token; + }); + return tokens; } }); -var SVGClassHandler = BaseClassHandler.extend({ +var SVGClassHandler = ClassHandler.extend({ getCurrentValue: function (element) { return element.className.baseVal; }, @@ -96,48 +111,61 @@ var SVGClassHandler = BaseClassHandler.extend({ } }); -var BooleanHandler = AttributeHandler.extend({ - update: function (element, oldValue, value) { - var focused = this.focused(element); - - if (!focused) { - var name = this.name; - if (value == null) { - if (oldValue != null) - element[name] = false; - } else { - element[name] = true; - } +var StyleHandler = DiffingAttributeHandler.extend({ + getCurrentValue: function (element) { + return element.getAttribute('style'); + }, + setValue: function (element, style) { + if (style === '') { + element.removeAttribute('style'); + } else { + element.setAttribute('style', style); } }, - // is the element part of a control which is focused? - focused: function (element) { - if (element.tagName === 'INPUT') { - return element === document.activeElement; - } else if (element.tagName === 'OPTION') { - // find the containing SELECT element, on which focus - // is actually set - var selectEl = element; - while (selectEl && selectEl.tagName !== 'SELECT') - selectEl = selectEl.parentNode; + // Parse a string to produce a map from property to attribute string. + // + // Example: + // "color:red; foo:12px" produces a token {color: "color:red", foo:"foo:12px"} + parseValue: function (attrString) { + var tokens = {}; - if (selectEl) - return selectEl === document.activeElement; - else - return false; + // Regex for parsing a css attribute declaration, taken from css-parse: + // https://github.com/reworkcss/css-parse/blob/7cef3658d0bba872cde05a85339034b187cb3397/index.js#L219 + var regex = /(\*?[-#\/\*\\\w]+(?:\[[0-9a-z_-]+\])?)\s*:\s*(?:\'(?:\\\'|.)*?\'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+[;\s]*/g; + var match = regex.exec(attrString); + while (match) { + // match[0] = entire matching string + // match[1] = css property + // Prefix the token to prevent conflicts with existing properties. + + // XXX No `String.trim` on Safari 4. Swap out $.trim if we want to + // remove strong dep on jquery. + tokens[' ' + match[1]] = match[0].trim ? + match[0].trim() : $.trim(match[0]); + + match = regex.exec(attrString); + } + + return tokens; + } +}); + +var BooleanHandler = AttributeHandler.extend({ + update: function (element, oldValue, value) { + var name = this.name; + if (value == null) { + if (oldValue != null) + element[name] = false; } else { - throw new Error("Expected INPUT or OPTION element"); + element[name] = true; } } }); var ValueHandler = AttributeHandler.extend({ update: function (element, oldValue, value) { - var focused = (element === document.activeElement); - - if (!focused) - element.value = value; + element.value = value; } }); @@ -205,24 +233,20 @@ if (Meteor.isClient) { var anchorForNormalization = document.createElement('A'); } -var normalizeUrl = function (url) { +var getUrlProtocol = function (url) { if (Meteor.isClient) { anchorForNormalization.href = url; - return anchorForNormalization.href; + return (anchorForNormalization.protocol || "").toLowerCase(); } else { - throw new Error('normalizeUrl not implemented on the server'); + throw new Error('getUrlProtocol not implemented on the server'); } }; // UrlHandler is an attribute handler for all HTML attributes that take // URL values. It disallows javascript: URLs, unless // UI._allowJavascriptUrls() has been called. To detect javascript: -// urls, we set the attribute and then reads the attribute out of the -// DOM, in order to avoid writing our own URL normalization code. (We -// don't want to be fooled by ' javascript:alert(1)' or -// 'jAvAsCrIpT:alert(1)'.) In future, when the URL interface is more -// widely supported, we can use that, which will be -// cleaner. https://developer.mozilla.org/en-US/docs/Web/API/URL +// urls, we set the attribute on a dummy anchor element and then read +// out the 'protocol' property of the attribute. var origUpdate = AttributeHandler.prototype.update; var UrlHandler = AttributeHandler.extend({ update: function (element, oldValue, value) { @@ -232,8 +256,7 @@ var UrlHandler = AttributeHandler.extend({ if (UI._javascriptUrlsAllowed()) { origUpdate.apply(self, args); } else { - var isJavascriptProtocol = - (normalizeUrl(value).indexOf('javascript:') === 0); + var isJavascriptProtocol = (getUrlProtocol(value) === "javascript:"); if (isJavascriptProtocol) { Meteor._debug("URLs that use the 'javascript:' protocol are not " + "allowed in URL attribute values. " + @@ -257,6 +280,8 @@ makeAttributeHandler = function (elem, name, value) { } else { return new ClassHandler(name, value); } + } else if (name === 'style') { + return new StyleHandler(name, value); } else if ((elem.tagName === 'OPTION' && name === 'selected') || (elem.tagName === 'INPUT' && name === 'checked')) { return new BooleanHandler(name, value); diff --git a/packages/ui/base.js b/packages/ui/base.js index 4f4d3f0bea..fe18942dd4 100644 --- a/packages/ui/base.js +++ b/packages/ui/base.js @@ -206,6 +206,19 @@ findComponentWithProp = function (id, comp) { return null; }; +// Look up the component's chain of parents until we find one with +// `__helperHost` set (a component that can have helpers defined on it, +// i.e. a template). +var findHelperHostComponent = function (comp) { + while (comp) { + if (comp.__helperHost) { + return comp; + } + comp = comp.parent; + } + return null; +}; + findComponentWithHelper = function (id, comp) { while (comp) { if (comp.__helperHost) { @@ -352,3 +365,48 @@ UI._allowJavascriptUrls = function () { UI._javascriptUrlsAllowed = function () { return jsUrlsAllowed; }; + +UI._templateInstance = function () { + var currentComp = currentComponent.get(); + if (! currentComp) { + throw new Error("You can only call UI._templateInstance() from within" + + " a helper function."); + } + + // Find the enclosing component that is a template. (`currentComp` + // could be, for example, an #if or #with, and we want the component + // that is the surrounding template.) + var template = findHelperHostComponent(currentComp); + if (! template) { + throw new Error("Current component is not inside a template?"); + } + + // Lazily update the template instance for this helper, and do it only + // once. + if (! currentTemplateInstance) { + updateTemplateInstance(template); + currentTemplateInstance = template.templateInstance; + } + return currentTemplateInstance; +}; + +// Returns the data context of the parent which is 'numLevels' above the +// component. Same behavior as {{../..}} in a template, with 'numLevels' +// occurrences of '..'. +UI._parentData = function (numLevels) { + var component = currentComponent.get(); + while (component && numLevels >= 0) { + // Decrement numLevels every time we find a new data context. Break + // once we have reached numLevels < 0. + if (component.data !== undefined && --numLevels < 0) { + break; + } + component = component.parent; + } + + if (! component) { + return null; + } + + return getComponentData(component); +}; diff --git a/packages/ui/dombackend.js b/packages/ui/dombackend.js index 637ccf6ab0..2d291b7a26 100644 --- a/packages/ui/dombackend.js +++ b/packages/ui/dombackend.js @@ -28,7 +28,7 @@ if (Meteor.isClient) { // Causes `elem` (a DOM element) to be detached from its parent, if any. // Whether or not `elem` was detached, causes any callbacks registered - // with `onRemoveElement` on `elem` and its descendants to fire. + // with `onElementTeardown` on `elem` and its descendants to fire. // Not for use on non-element nodes. // // This method is modeled after the behavior of jQuery's `$(elem).remove()`, @@ -37,11 +37,17 @@ if (Meteor.isClient) { $jq(elem).remove(); }; + DomBackend.tearDownElement = function (elem) { + var elems = _.toArray(elem.getElementsByTagName('*')); + elems.push(elem); + $jq.cleanData(elems); + }; + // Registers a callback function to be called when the given element or // one of its ancestors is removed from the DOM via the backend library. // The callback function is called at most once, and it receives the element // in question as an argument. - DomBackend.onRemoveElement = function (elem, func) { + DomBackend.onElementTeardown = function (elem, func) { if (! elem[REMOVAL_CALLBACKS_PROPERTY_NAME]) { elem[REMOVAL_CALLBACKS_PROPERTY_NAME] = []; @@ -136,4 +142,4 @@ if (Meteor.isClient) { return type; }; -} \ No newline at end of file +} diff --git a/packages/ui/dombackend_tests.js b/packages/ui/dombackend_tests.js index 1b3583b192..5799e3c030 100644 --- a/packages/ui/dombackend_tests.js +++ b/packages/ui/dombackend_tests.js @@ -35,16 +35,16 @@ var isDetachedSingleNode = function (test, node) { }; Tinytest.add("ui - DomBackend - element removal", function (test) { - // Test that calling removeElement on a detached element calls onRemoveElement + // Test that calling removeElement on a detached element calls onElementTeardown // on it and its descendents. For jQuery, `removeElement` runs `$(elem).remove()`, // so it tests detecting a jQuery removal, as well as the stronger condition // that clean-up still happens on the DOM tree in the detached case. runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onRemoveElement(div, func1); - DomBackend.onRemoveElement(span, func2); - DomBackend.onRemoveElement(b, func3); + DomBackend.onElementTeardown(div, func1); + DomBackend.onElementTeardown(span, func2); + DomBackend.onElementTeardown(b, func3); // test second callback on same element - DomBackend.onRemoveElement(div, func4); + DomBackend.onElementTeardown(div, func4); DomBackend.removeElement(div); // "remove" the (parentless) DIV @@ -59,10 +59,10 @@ Tinytest.add("ui - DomBackend - element removal", function (test) { // Test that `removeElement` actually removes the element // (and fires appropriate callbacks). runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onRemoveElement(div, func1); - DomBackend.onRemoveElement(span, func2); - DomBackend.onRemoveElement(b, func3); - DomBackend.onRemoveElement(div, func4); + DomBackend.onElementTeardown(div, func1); + DomBackend.onElementTeardown(span, func2); + DomBackend.onElementTeardown(b, func3); + DomBackend.onElementTeardown(div, func4); DomBackend.removeElement(span); // remove the SPAN @@ -83,10 +83,10 @@ Tinytest.add("ui - DomBackend - element removal (jQuery)", function (test) { // Test with `$(elem).remove()`. runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onRemoveElement(div, func1); - DomBackend.onRemoveElement(span, func2); - DomBackend.onRemoveElement(b, func3); - DomBackend.onRemoveElement(div, func4); + DomBackend.onElementTeardown(div, func1); + DomBackend.onElementTeardown(span, func2); + DomBackend.onElementTeardown(b, func3); + DomBackend.onElementTeardown(div, func4); $(span).remove(); // remove the SPAN @@ -103,10 +103,10 @@ Tinytest.add("ui - DomBackend - element removal (jQuery)", function (test) { // Test that `$(elem).detach()` is NOT considered a removal. runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onRemoveElement(div, func1); - DomBackend.onRemoveElement(span, func2); - DomBackend.onRemoveElement(b, func3); - DomBackend.onRemoveElement(div, func4); + DomBackend.onElementTeardown(div, func1); + DomBackend.onElementTeardown(span, func2); + DomBackend.onElementTeardown(b, func3); + DomBackend.onElementTeardown(div, func4); $(span).detach(); // detach the SPAN diff --git a/packages/ui/domrange.js b/packages/ui/domrange.js index cc0460cd41..a875b759de 100644 --- a/packages/ui/domrange.js +++ b/packages/ui/domrange.js @@ -7,29 +7,34 @@ var DomBackend = UI.DomBackend; var removeNode = function (n) { -// if (n.nodeType === 1 && -// n.parentNode.$uihooks && n.parentNode.$uihooks.removeElement) -// n.parentNode.$uihooks.removeElement(n); -// else + if (n.nodeType === 1 && + n.parentNode._uihooks && n.parentNode._uihooks.removeElement) { + n.parentNode._uihooks.removeElement(n); + } else { n.parentNode.removeChild(n); + } }; var insertNode = function (n, parent, next) { -// if (n.nodeType === 1 && -// parent.$uihooks && parent.$uihooks.insertElement) -// parent.$uihooks.insertElement(n, parent, next); -// else - // `|| null` because IE throws an error if 'next' is undefined - parent.insertBefore(n, next || null); + // `|| null` because IE throws an error if 'next' is undefined + next = next || null; + if (n.nodeType === 1 && + parent._uihooks && parent._uihooks.insertElement) { + parent._uihooks.insertElement(n, next); + } else { + parent.insertBefore(n, next); + } }; var moveNode = function (n, parent, next) { -// if (n.nodeType === 1 && -// parent.$uihooks && parent.$uihooks.moveElement) -// parent.$uihooks.moveElement(n, parent, next); -// else - // `|| null` because IE throws an error if 'next' is undefined - parent.insertBefore(n, next || null); + // `|| null` because IE throws an error if 'next' is undefined + next = next || null; + if (n.nodeType === 1 && + parent._uihooks && parent._uihooks.moveElement) { + parent._uihooks.moveElement(n, next); + } else { + parent.insertBefore(n, next); + } }; // A very basic operation like Underscore's `_.extend` that @@ -105,8 +110,8 @@ var rangeParented = function (range) { range._rangeDict = rangeDict; // get jQuery to tell us when this node is removed - DomBackend.onRemoveElement(parentNode, function () { - rangeRemoved(range); + DomBackend.onElementTeardown(parentNode, function () { + rangeRemoved(range, true /* alreadyTornDown */); }); } @@ -123,42 +128,57 @@ var rangeParented = function (range) { } }; -var rangeRemoved = function (range) { +var rangeRemoved = function (range, alreadyTornDown) { if (! range.isRemoved) { range.isRemoved = true; if (range._rangeDict) delete range._rangeDict[range._rangeId]; - // XXX clean up events in $_uievents + // clean up events + if (range.stopHandles) { + for (var i = 0; i < range.stopHandles.length; i++) + range.stopHandles[i].stop(); + range.stopHandles = null; + } // notify component of removal if (range.removed) range.removed(); - membersRemoved(range); + membersRemoved(range, alreadyTornDown); } }; -var nodeRemoved = function (node, viaBackend) { +var nodeRemoved = function (node, alreadyTornDown) { if (node.nodeType === 1) { // ELEMENT var comps = DomRange.getComponents(node); for (var i = 0, N = comps.length; i < N; i++) - rangeRemoved(comps[i]); + rangeRemoved(comps[i], true /* alreadyTornDown */); - if (! viaBackend) - DomBackend.removeElement(node); + // `alreadyTornDown` is an optimization so that we don't + // tear down the same elements multiple times when tearing + // down a tree of DomRanges and elements, leading to asymptotic + // inefficiency. + // + // When jQuery removes an element or DomBackend.tearDownElement + // is called, the DOM is "cleaned" recursively, calling all + // onElementTearDown handlers on the entire DOM subtree. + // Since the entire subtree is already walked, we don't want to + // also walk the subtrees of each DomRange for teardown purposes. + if (! alreadyTornDown) + DomBackend.tearDownElement(node); } }; -var membersRemoved = function (range) { +var membersRemoved = function (range, alreadyTornDown) { var members = range.members; for (var k in members) { var mem = members[k]; if (mem instanceof DomRange) - rangeRemoved(mem); + rangeRemoved(mem, alreadyTornDown); else - nodeRemoved(mem); + nodeRemoved(mem, alreadyTornDown); } }; @@ -183,6 +203,8 @@ var DomRange = function () { this.isParented = false; this.isRemoved = false; + + this.stopHandles = null; }; _extend(DomRange.prototype, { @@ -374,6 +396,7 @@ _extend(DomRange.prototype, { var member = (members.hasOwnProperty(id) && members[id]); + // Don't mind if member doesn't exist. if (! member) return; @@ -971,6 +994,7 @@ DomRange.prototype.on = function (events, selector, handler) { selector = null; } + var newHandlerRecs = []; for (var i = 0, N = eventTypes.length; i < N; i++) { var type = eventTypes[i]; @@ -986,6 +1010,7 @@ DomRange.prototype.on = function (events, selector, handler) { var handlerList = info.handlers; var handlerRec = new HandlerRec( parentNode, type, selector, handler, this); + newHandlerRecs.push(handlerRec); handlerRec.bind(); handlerList.push(handlerRec); // move handlers of enclosing ranges to end @@ -1005,6 +1030,31 @@ DomRange.prototype.on = function (events, selector, handler) { } } } + + this.stopHandles = (this.stopHandles || []); + this.stopHandles.push({ + // closes over just `parentNode` and `newHandlerRecs` + stop: function () { + var eventDict = parentNode.$_uievents; + if (! eventDict) + return; + + for (var i = 0; i < newHandlerRecs.length; i++) { + var handlerToRemove = newHandlerRecs[i]; + var info = eventDict[handlerToRemove.type]; + if (! info) + continue; + var handlerList = info.handlers; + for (var j = handlerList.length - 1; j >= 0; j--) { + if (handlerList[j] === handlerToRemove) { + handlerToRemove.unbind(); + handlerList.splice(j, 1); // remove handlerList[j] + } + } + } + newHandlerRecs.length = 0; + } + }); }; // Returns true if element a contains node b and is not node b. diff --git a/packages/ui/domrange_tests.js b/packages/ui/domrange_tests.js index fb042f2dea..2fd7f2b519 100644 --- a/packages/ui/domrange_tests.js +++ b/packages/ui/domrange_tests.js @@ -25,7 +25,8 @@ var inDocument = function (range, func) { try { func(range); } finally { - document.body.removeChild(onscreen); + if (onscreen.parentNode === document.body) + document.body.removeChild(onscreen); } }; @@ -862,7 +863,7 @@ Tinytest.add("ui - DomRange - structural removal", function (test) { test.isTrue(e.isRemoved); - for (var scenario = 0; scenario < 2; scenario++) { + for (var scenario = 0; scenario < 3; scenario++) { var f = new DomRange; var g = document.createElement("DIV"); var h = new DomRange; @@ -882,6 +883,8 @@ Tinytest.add("ui - DomRange - structural removal", function (test) { r.removeAll(); else if (scenario === 1) r.remove('f'); + else if (scenario === 2) + $(r.parentNode()).remove(); test.isTrue(f.isRemoved); test.isTrue(h.isRemoved); test.isTrue(k.isRemoved); diff --git a/packages/ui/fields.js b/packages/ui/fields.js index e598d37668..71341ded99 100644 --- a/packages/ui/fields.js +++ b/packages/ui/fields.js @@ -1,6 +1,8 @@ var global = (function () { return this; })(); +currentComponent = new Meteor.EnvironmentVariable(); + // Searches for the given property in `comp` or a parent, // and returns it as is (without call it if it's a function). var lookupComponentProp = function (comp, prop) { @@ -89,10 +91,11 @@ _extend(UI.Component, { } else { // Resolve id `foo` as `data.foo` (with a "soft dot"). - return function (/*arguments*/) { + return function (/* arguments */) { var data = getComponentData(self); if (template && !(data && _.has(data, id))) - throw new Error("Can't find template, helper or data context key: " + id); + throw new Error("Can't find template, helper or data context " + + "key: " + id); if (! data) return data; var result = data[id]; @@ -107,8 +110,12 @@ _extend(UI.Component, { // This creates a dependency when the result function is called. // Don't do this if the function is really just an emboxed constant. return function (/*arguments*/) { - var data = getComponentData(self); - return result.apply(data === null ? {} : data, arguments); + var args = arguments; + return currentComponent.withValue(self, function () { + currentTemplateInstance = null; // lazily computed, since `updateTemplateInstance` is a little slow + var data = getComponentData(self); + return result.apply(data === null ? {} : data, args); + }); }; } else { return result; diff --git a/packages/ui/package.js b/packages/ui/package.js index 11d0c040ce..2f50cf3942 100644 --- a/packages/ui/package.js +++ b/packages/ui/package.js @@ -6,6 +6,9 @@ Package.describe({ Package.on_use(function (api) { api.export(['UI', 'Handlebars']); api.use('jquery'); // should be a weak dep, by having multiple "DOM backends" + // XXX StyleHandler uses $.trim since Safari 4 doesn't support + // `String.trim`. We should just replace this with our own `trim` if + // we want to make jquery a weak dep. api.use('deps'); api.use('random'); api.use('ejson'); diff --git a/packages/ui/render.js b/packages/ui/render.js index 5c38868eaa..58d30787dc 100644 --- a/packages/ui/render.js +++ b/packages/ui/render.js @@ -15,12 +15,15 @@ UI.Component.instantiate = function (parent) { // XXX messy to define this here inst.templateInstance = { - findAll: function (selector) { + $: function(selector) { // XXX check that `.dom` exists here? return inst.dom.$(selector); }, + findAll: function (selector) { + return $.makeArray(this.$(selector)); + }, find: function (selector) { - var result = this.findAll(selector); + var result = this.$(selector); return result[0] || null; }, firstNode: null, @@ -28,7 +31,6 @@ UI.Component.instantiate = function (parent) { data: null, __component__: inst }; - inst.templateInstance.$ = inst.templateInstance.findAll; inst.parent = (parent || null); @@ -198,7 +200,16 @@ var insert = function (nodeOrRange, parent, before) { } }; -UI.render = function (kind, parentComponent) { +// options include: +// - _nestInCurrentComputation: defaults to false. If true, then +// `render`'s autoruns will be nested inside the current +// computation, so if the current computation is invalidated, then +// the autoruns set up inside `render` will be stopped. If false, +// the autoruns will be set up in a fresh Deps context, so +// invalidating the current computation will have no effect on them. +UI.render = function (kind, parentComponent, options) { + options = options || {}; + if (kind.isInited) throw new Error("Can't render component instance, only component kind"); @@ -214,9 +225,15 @@ UI.render = function (kind, parentComponent) { inst.dom = range; range.component = inst; + if (! options._nestInCurrentComputation) { + materialize(content, range, null, inst); + } + }); - materialize(content, range, null, inst); + if (options._nestInCurrentComputation) { + materialize(content, range, null, inst); + } range.removed = function () { inst.isDestroyed = true; @@ -231,7 +248,8 @@ UI.render = function (kind, parentComponent) { return inst; }; -UI.renderWithData = function (kind, data, parentComponent) { +// options are the same as for UI.render. +UI.renderWithData = function (kind, data, parentComponent, options) { if (! UI.isComponent(kind)) throw new Error("Component required here"); if (kind.isInited) @@ -240,7 +258,7 @@ UI.renderWithData = function (kind, data, parentComponent) { throw new Error("Data argument can't be a function"); return UI.render(kind.extend({data: function () { return data; }}), - parentComponent); + parentComponent, options); }; var contentEquals = function (a, b) { @@ -364,7 +382,7 @@ var materialize = function (node, parent, before, parentComponent) { reportUIException(e); } }); - UI.DomBackend.onRemoveElement(elem, function () { + UI.DomBackend.onElementTeardown(elem, function () { attrComp.stop(); }); } @@ -373,12 +391,14 @@ var materialize = function (node, parent, before, parentComponent) { insert(elem, parent, before); } else if (typeof node.instantiate === 'function') { // component - var instance = UI.render(node, parentComponent); + var instance = UI.render(node, parentComponent, { + _nestInCurrentComputation: true + }); // Call internal callback, which may take advantage of the current // Deps computation. if (instance.materialized) - instance.materialized(); + instance.materialized(instance.dom); insert(instance.dom, parent, before); } else if (node instanceof HTML.CharRef) { diff --git a/packages/ui/render_tests.js b/packages/ui/render_tests.js index 87688c866b..d2ffd56c5d 100644 --- a/packages/ui/render_tests.js +++ b/packages/ui/render_tests.js @@ -204,6 +204,15 @@ Tinytest.add("ui - render - closures", function (test) { }); +// IE strips malformed styles like "bar::d" from the `style` +// attribute. We detect this to adjust expectations for the StyleHandler +// test below. +var malformedStylesAllowed = function () { + var div = document.createElement("div"); + div.setAttribute("style", "bar::d;"); + return (div.getAttribute("style") === "bar::d;"); +}; + Tinytest.add("ui - render - closure GC", function (test) { // test that removing parent element removes listeners and stops autoruns. (function () { @@ -268,6 +277,87 @@ Tinytest.add("ui - render - reactive attributes", function (test) { test.equal(R.numListeners(), 0); })(); + // Test styles. + (function () { + // Test the case where there is a semicolon in the css attribute. + var R = ReactiveVar({'style': 'foo: "a;aa"; bar: b;', + id: 'foo'}); + + var spanCode = SPAN({$dynamic: [function () { return R.get(); }]}); + + test.equal(toHTML(spanCode), '<span style="foo: &quot;a;aa&quot;; bar: b;" id="foo"></span>'); + + test.equal(R.numListeners(), 0); + + var div = document.createElement("DIV"); + materialize(spanCode, div); + test.equal(canonicalizeHtml(div.innerHTML), '<span id="foo" style="foo: &quot;a;aa&quot;; bar: b"></span>'); + + test.equal(R.numListeners(), 1); + var span = div.firstChild; + test.equal(span.nodeName, 'SPAN'); + + span.setAttribute('style', span.getAttribute('style') + '; jquery-style: hidden'); + + R.set({'style': 'foo: "a;zz;aa";', id: 'bar'}); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML, true), '<span id="bar" style="foo: &quot;a;zz;aa&quot;; jquery-style: hidden"></span>'); + test.equal(R.numListeners(), 1); + + R.set({}); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), '<span style="jquery-style: hidden"></span>'); + test.equal(R.numListeners(), 1); + + $(div).remove(); + + test.equal(R.numListeners(), 0); + })(); + + // Test that identical styles are successfully overwritten. + (function () { + + var R = ReactiveVar({'style': 'foo: a;'}); + + var spanCode = SPAN({$dynamic: [function () { return R.get(); }]}); + + var div = document.createElement("DIV"); + document.body.appendChild(div); + materialize(spanCode, div); + test.equal(canonicalizeHtml(div.innerHTML), '<span style="foo: a"></span>'); + + var span = div.firstChild; + test.equal(span.nodeName, 'SPAN'); + span.setAttribute("style", 'foo: b;'); + test.equal(canonicalizeHtml(div.innerHTML), '<span style="foo: b"></span>'); + + R.set({'style': 'foo: c;'}); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), '<span style="foo: c"></span>'); + + // test malformed styles - different expectations in IE (which + // strips malformed styles) from other browsers + R.set({'style': 'foo: a; bar::d;:e; baz: c;'}); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), + malformedStylesAllowed() ? + '<span style="foo: a; bar::d; baz: c"></span>' : + '<span style="foo: a; baz: c"></span>'); + + // Test strange styles + R.set({'style': ' foo: c; constructor: a; __proto__: b;'}); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), '<span style="foo: c; constructor: a; __proto__: b"></span>'); + + R.set({}); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), '<span></span>'); + + R.set({'style': 'foo: bar;'}); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), '<span style="foo: bar"></span>'); + })(); + // Test `null`, `undefined`, and `[]` attributes (function () { var R = ReactiveVar({id: 'foo', @@ -407,6 +497,31 @@ Tinytest.add("ui - render - components", function (test) { })(); }); +Tinytest.add("ui - render - findAll", function (test) { + var found = null; + var $found = null; + + var myComponent = UI.Component.extend({ + render: function() { + return DIV([P('first'), P('second')]); + }, + rendered: function() { + found = this.findAll('p'); + $found = this.$('p'); + }, + }); + + var div = document.createElement("DIV"); + + materialize(myComponent, div); + Deps.flush(); + + test.equal(_.isArray(found), true); + test.equal(_.isArray($found), false); + test.equal(found.length, 2); + test.equal($found.length, 2); +}); + Tinytest.add("ui - render - reactive attributes 2", function (test) { var R1 = ReactiveVar(['foo']); var R2 = ReactiveVar(['bar']); @@ -536,3 +651,47 @@ Tinytest.add("ui - UI.getDataContext", function (test) { test.isTrue(span); test.equal(UI.getElementData(span), {foo: "bar"}); }); + +Tinytest.add("ui - UI.render _nestInCurrentComputation flag", function (test) { + _.each([true, false], function (nest) { + + var firstComputation; + var rv1 = new ReactiveVar; + var rv2 = new ReactiveVar; + + // Render a component in an autorun. Save the current computation + // from the first time we run the render function. Invalidate the + // autorun, and check whether that stops the computation from the + // first time the component rendered. + + var tmpl = UI.Component.extend({ + render: function () { + return function () { + if (! firstComputation) { + firstComputation = Deps.currentComputation; + } + return rv1.get(); + }; + } + }); + + Deps.autorun(function () { + rv2.get(); // register a dependency + UI.render(tmpl, undefined, { + _nestInCurrentComputation: nest + }); + }); + + rv2.set("foo"); + Deps.flush(); + + // If we nested inside the current computation, then we expect the + // computation from within the render function to have been stopped + // when the outer computation was invalidated. + if (nest) { + test.equal(firstComputation.stopped, true); + } else { + test.equal(firstComputation.stopped, false); + } + }); +}); diff --git a/packages/webapp/.npm/package/npm-shrinkwrap.json b/packages/webapp/.npm/package/npm-shrinkwrap.json index 1cd8fc28af..dc8dc414d6 100644 --- a/packages/webapp/.npm/package/npm-shrinkwrap.json +++ b/packages/webapp/.npm/package/npm-shrinkwrap.json @@ -15,17 +15,6 @@ "cookie": { "version": "0.1.0" }, - "send": { - "version": "0.1.4", - "dependencies": { - "mime": { - "version": "1.2.11" - }, - "range-parser": { - "version": "0.0.4" - } - } - }, "bytes": { "version": "0.2.0" }, diff --git a/packages/webapp/package.js b/packages/webapp/package.js index 64a05ee60b..2ad86c3809 100644 --- a/packages/webapp/package.js +++ b/packages/webapp/package.js @@ -30,3 +30,8 @@ Package.on_use(function (api) { api.add_files('boilerplate.html', 'server', {isAsset: true}); api.add_files('webapp_client.js', 'client'); }); + +Package.on_test(function (api) { + api.use(['tinytest', 'webapp', 'http']); + api.add_files('webapp_tests.js', 'server'); +}); diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index ab22ce2414..5b2429cefd 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -296,7 +296,8 @@ var runWebAppServer = function () { path: item.path, cacheable: item.cacheable, // Link from source to its map - sourceMapUrl: item.sourceMapUrl + sourceMapUrl: item.sourceMapUrl, + type: item.type }; if (item.sourceMap) { @@ -310,6 +311,9 @@ var runWebAppServer = function () { } }); + // Exported for tests. + WebAppInternals.staticFiles = staticFiles; + // Serve static files from the manifest. // This is inspired by the 'static' middleware. @@ -328,7 +332,9 @@ var runWebAppServer = function () { } var serveStaticJs = function (s) { - res.writeHead(200, { 'Content-type': 'application/javascript' }); + res.writeHead(200, { + 'Content-type': 'application/javascript; charset=UTF-8' + }); res.write(s); res.end(); }; @@ -400,6 +406,13 @@ var runWebAppServer = function () { // in `about:config` (it is on by default in FF 24). if (info.sourceMapUrl) res.setHeader('X-SourceMap', info.sourceMapUrl); + + if (info.type === "js") { + res.setHeader("Content-Type", "application/javascript; charset=UTF-8"); + } else if (info.type === "css") { + res.setHeader("Content-Type", "text/css; charset=UTF-8"); + } + send(req, path.join(clientDir, info.path)) .maxage(maxAge) .hidden(true) // if we specified a dotfile in the manifest, serve it diff --git a/packages/webapp/webapp_tests.js b/packages/webapp/webapp_tests.js new file mode 100644 index 0000000000..1f555c362c --- /dev/null +++ b/packages/webapp/webapp_tests.js @@ -0,0 +1,23 @@ +var url = Npm.require("url"); + +Tinytest.add("webapp - content-type header", function (test) { + var cssResource = _.find( + _.keys(WebAppInternals.staticFiles), + function (url) { + return WebAppInternals.staticFiles[url].type === "css"; + } + ); + var jsResource = _.find( + _.keys(WebAppInternals.staticFiles), + function (url) { + return WebAppInternals.staticFiles[url].type === "js"; + } + ); + + var resp = HTTP.get(url.resolve(Meteor.absoluteUrl(), cssResource)); + test.equal(resp.headers["content-type"].toLowerCase(), + "text/css; charset=utf-8"); + resp = HTTP.get(url.resolve(Meteor.absoluteUrl(), jsResource)); + test.equal(resp.headers["content-type"].toLowerCase(), + "application/javascript; charset=utf-8"); +}); diff --git a/scripts/admin/banner.txt b/scripts/admin/banner.txt index 748c205ee0..c72d6b8ddb 100644 --- a/scripts/admin/banner.txt +++ b/scripts/admin/banner.txt @@ -1,5 +1,7 @@ -=> Meteor 0.8.1.3: Fixes a security flaw in the `spiderable` package and - minor regressions from 0.8.1. +=> Meteor 0.8.2: Switch `accounts-password` to use bcrypt on the + server. User accounts will seamlessly transition to bcrypt on the + next login, but this transition is one-way, so you cannot downgrade a + production app once you upgrade to 0.8.2. This release is being downloaded in the background. Update your - project to Meteor 0.8.1.3 by running 'meteor update'. + project to Meteor 0.8.2 by running 'meteor update'. diff --git a/scripts/admin/build-package-tarballs.sh b/scripts/admin/build-package-tarballs.sh index aff7616311..7c7d18117d 100755 --- a/scripts/admin/build-package-tarballs.sh +++ b/scripts/admin/build-package-tarballs.sh @@ -14,7 +14,7 @@ set -e set -u # cd to top level dir -cd `dirname $0` +cd "`dirname "$0"`" cd ../.. export TOPDIR=$(pwd) diff --git a/scripts/admin/build-release.sh b/scripts/admin/build-release.sh index d70df75c57..63b4845648 100755 --- a/scripts/admin/build-release.sh +++ b/scripts/admin/build-release.sh @@ -4,7 +4,7 @@ set -e set -u # cd to top level dir -cd `dirname $0` +cd "`dirname "$0"`" cd ../.. TOPDIR=$(pwd) diff --git a/scripts/admin/build-tools-tarballs.sh b/scripts/admin/build-tools-tarballs.sh index 13baa9b307..62a4978f1a 100755 --- a/scripts/admin/build-tools-tarballs.sh +++ b/scripts/admin/build-tools-tarballs.sh @@ -4,7 +4,7 @@ set -e set -u # cd to top level dir -cd `dirname $0` +cd "`dirname "$0"`" cd ../.. TOPDIR=$(pwd) diff --git a/scripts/admin/build-tools-tree.sh b/scripts/admin/build-tools-tree.sh index 92e7df519c..8d949f8902 100755 --- a/scripts/admin/build-tools-tree.sh +++ b/scripts/admin/build-tools-tree.sh @@ -8,7 +8,7 @@ set -e set -u -cd `dirname $0`/../.. +cd "`dirname "$0"`"/../.. if [ "$TARGET_DIR" == "" ] ; then echo 'Must set $TARGET_DIR' diff --git a/scripts/admin/configure_parties.js b/scripts/admin/configure_parties.js new file mode 100644 index 0000000000..d60677747d --- /dev/null +++ b/scripts/admin/configure_parties.js @@ -0,0 +1,12 @@ +db.meteor_accounts_loginServiceConfiguration.insert({ + "service" : "facebook", + "appId" : "137758583064594", + "secret" : "3915c1077d25e56fc6444498c6a7984d", + "_id" : "gjpDD9vwGw2tF45ww" +}); +db.meteor_accounts_loginServiceConfiguration.insert({ + "service" : "twitter", + "consumerKey" : "4HF4e0BhNRR7WwC9WqhRBLPRK", + "secret" : "VSzSnLU2W0dT64a9XGVqKhYo90yAu9pQIJ6McTtIRyRzVopHvT", + "_id" : "FCXK6RmNhKyhjSBQk" +}); diff --git a/scripts/admin/copy-dev-bundle-from-jenkins.sh b/scripts/admin/copy-dev-bundle-from-jenkins.sh index 54a7cbca9e..f7ebb5c92d 100755 --- a/scripts/admin/copy-dev-bundle-from-jenkins.sh +++ b/scripts/admin/copy-dev-bundle-from-jenkins.sh @@ -8,7 +8,7 @@ set -e set -u -cd `dirname $0` +cd "`dirname "$0"`" TARGET="s3://com.meteor.static/test/" TEST=no diff --git a/scripts/admin/deploy-examples.sh b/scripts/admin/deploy-examples.sh index a9a4a7fcdf..8377680bbe 100755 --- a/scripts/admin/deploy-examples.sh +++ b/scripts/admin/deploy-examples.sh @@ -1,18 +1,61 @@ #!/bin/bash - set -e -cd `dirname $0` -cd ../examples +if [ -z $1 ]; then + echo "This script is to be used in advance of running automated QA on Rainforest" + echo + echo "Usage: ./deploy-example.sh RELEASE" + exit 1 +fi +RELEASE=$1 -read -p "Prefix? " PREFIX; +cd `dirname "$0"`/../.. +METEOR_ROOT=`pwd` +LOG="$METEOR_ROOT/rainforestqa-deploy.log" +rm $LOG &> /dev/null || true -for EXAMPLE in * ; do - if [ -d "$EXAMPLE/.meteor" ] ; then - cd $EXAMPLE; - echo "meteor deploy $@ $PREFIX-$EXAMPLE;" - meteor deploy $@ $PREFIX-$EXAMPLE; - cd ..; - fi +# Store the original contents in ~/.meteorsession, which contain the +# credentials for the currently logged-in user. Restore that file if +# this script exits. +METEORSESSION_RESTORE="$METEOR_ROOT/.meteorsession-restore" +cp ~/.meteorsession "$METEORSESSION_RESTORE" +function cleanup { + echo "Logs can be found at $METEOR_ROOT/rainforestqa-deploy.log" + cp "$METEORSESSION_RESTORE" ~/.meteorsession + rm "$METEORSESSION_RESTORE" + rm -rf rainforestqa-tmp +} +trap cleanup EXIT + +# Now, login as rainforestqa. This way, anyone can access apps +# deployed by this script. +(echo rainforestqa; echo rainforestqa;) | meteor login + +PREFIX=rainforest-test +EXAMPLES=`meteor create --list --release $RELEASE | grep '^ ' | cut -c 3-` + +# This is where we'll create the example app to be deployed +rm -rf rainforestqa-tmp || true +mkdir rainforestqa-tmp +cd rainforestqa-tmp + +# Deploy all example apps +for EXAMPLE in $EXAMPLES ; do + SITE=$PREFIX-$EXAMPLE.meteor.com + + # `|| true` so that the script doesn't fail if the the app doesn't exist + meteor deploy -D $SITE >> $LOG 2>&1 || true + meteor create --example $EXAMPLE --release $RELEASE $EXAMPLE >> $LOG 2>&1 + cd $EXAMPLE + echo -n "* Deploying $EXAMPLE to $SITE... " + meteor deploy $SITE >> $LOG 2>&1 + echo DONE + cd .. done + +# Configure OAuth on parties +cd .. # meteor root +echo -n "* Configuring OAuth for $PREFIX-parties.meteor.com... " +meteor --release $RELEASE mongo $PREFIX-parties.meteor.com >> $LOG 2>&1 < scripts/admin/configure_parties.js +echo DONE diff --git a/scripts/admin/notices.json b/scripts/admin/notices.json index ab738757bc..3b002c98b5 100644 --- a/scripts/admin/notices.json +++ b/scripts/admin/notices.json @@ -138,6 +138,17 @@ { "release": "0.8.1.3" }, + { + "release": "0.8.2", + "packageNotices": { + "accounts-password": [ + "Transition to bcrypt for password storage on the server.", + "You do not need to make any changes to your app, but you will", + "not be able to downgrade production apps after you update them", + "to 0.8.2." + ] + } + }, { "release": "NEXT" } diff --git a/scripts/admin/publish-release.sh b/scripts/admin/publish-release.sh index 0c33ecd343..404ec741b1 100755 --- a/scripts/admin/publish-release.sh +++ b/scripts/admin/publish-release.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e set -u -cd `dirname $0` +cd "`dirname "$0"`" DIR="$(pwd)" METEOR_DIR="$(pwd)/../.." diff --git a/scripts/admin/publish-release/server/publish-release.js b/scripts/admin/publish-release/server/publish-release.js index 19b8c8abf5..75caa7c2cf 100644 --- a/scripts/admin/publish-release/server/publish-release.js +++ b/scripts/admin/publish-release/server/publish-release.js @@ -73,6 +73,9 @@ var configureS3 = function () { var getManifest = function(s3, gitSha) { var manifestMetas = list3FilesWithPrefix( s3, ["unpublished", gitSha, "release.json-"].join("/")); + if (! manifestMetas) + die("A build for " + gitSha + " can't be found on S3. Maybe you forgot to build it on Jenkins?"); + var manifests = _.map(manifestMetas, function (meta) { return s3.GetObject({ BucketName: WAREHOUSE_BUCKET, @@ -103,6 +106,9 @@ var list3FilesWithPrefix = function (s3, prefix) { Prefix: prefix }).Body.ListBucketResult.Contents; + if (! artifacts) + return null; + // We support 3 platforms. if (artifacts.length !== 3) throw new Error("Expected three artifacts with prefix " + prefix + diff --git a/scripts/admin/upgrade-to-engine/build-fake-release.sh b/scripts/admin/upgrade-to-engine/build-fake-release.sh index bffff52f63..6d5553bc39 100755 --- a/scripts/admin/upgrade-to-engine/build-fake-release.sh +++ b/scripts/admin/upgrade-to-engine/build-fake-release.sh @@ -10,7 +10,7 @@ set -e set -u # cd to top level dir -cd `dirname $0` +cd "`dirname "$0"`" cd ../../.. TOPDIR=$(pwd) diff --git a/scripts/generate-dev-bundle.sh b/scripts/generate-dev-bundle.sh index d9b5c7690e..3346bb6259 100755 --- a/scripts/generate-dev-bundle.sh +++ b/scripts/generate-dev-bundle.sh @@ -49,7 +49,7 @@ fi PLATFORM="${UNAME}_${ARCH}" # save off meteor checkout dir as final target -cd `dirname $0`/.. +cd "`dirname "$0"`"/.. TARGET_DIR=`pwd` # Read the bundle version from the meteor shell script. @@ -71,12 +71,12 @@ umask 022 mkdir build cd build -git clone git://github.com/joyent/node.git +git clone https://github.com/joyent/node.git cd node # When upgrading node versions, also update the values of MIN_NODE_VERSION at # the top of tools/meteor.js and tools/server/boot.js, and the text in # docs/client/concepts.html and the README in tools/bundler.js. -git checkout v0.10.26 +git checkout v0.10.28 ./configure --prefix="$DIR" make -j4 @@ -172,11 +172,11 @@ git checkout ssl-r$MONGO_VERSION # Compile MONGO_FLAGS="--ssl --release -j4 " -MONGO_FLAGS+="--cpppath $DIR/build/openssl-out/include --libpath $DIR/build/openssl-out/lib " +MONGO_FLAGS+="--cpppath=$DIR/build/openssl-out/include --libpath=$DIR/build/openssl-out/lib " if [ "$MONGO_OS" == "osx" ]; then # NOTE: '--64' option breaks the compilation, even it is on by default on x64 mac: https://jira.mongodb.org/browse/SERVER-5575 - MONGO_FLAGS+="--openssl $DIR/build/openssl-out/lib " + MONGO_FLAGS+="--openssl=$DIR/build/openssl-out/lib " /usr/local/bin/scons $MONGO_FLAGS mongo mongod elif [ "$MONGO_OS" == "linux" ]; then MONGO_FLAGS+="--no-glibc-check --prefix=./ " diff --git a/tools/bundler.js b/tools/bundler.js index 98b95f0767..2244bf0de6 100644 --- a/tools/bundler.js +++ b/tools/bundler.js @@ -159,7 +159,6 @@ var buildmessage = require('./buildmessage.js'); var fs = require('fs'); var _ = require('underscore'); var project = require(path.join(__dirname, 'project.js')); -var builder = require(path.join(__dirname, 'builder.js')); var unipackage = require(path.join(__dirname, 'unipackage.js')); var watch = require('./watch.js'); var release = require('./release.js'); @@ -1537,7 +1536,7 @@ var writeSiteArchive = function (targets, outputPath, options) { builder.write('README', { data: new Buffer( "This is a Meteor application bundle. It has only one dependency:\n" + -"Node.js 0.10.26 or newer, plus the 'fibers' module. To run the application:\n" + +"Node.js 0.10.28 or newer, plus the 'fibers' module. To run the application:\n" + "\n" + " $ rm -r programs/server/node_modules/fibers\n" + " $ npm install fibers@1.0.1\n" + diff --git a/tools/commands.js b/tools/commands.js index f5b3ccbee9..26cf0ac8ab 100644 --- a/tools/commands.js +++ b/tools/commands.js @@ -649,6 +649,7 @@ main.registerCommand({ requiresApp: true, options: { debug: { type: Boolean }, + directory: { type: Boolean }, // Undocumented 'for-deploy': { type: Boolean } } @@ -664,8 +665,9 @@ main.registerCommand({ // machines, but worth it for humans) var buildDir = path.join(options.appDir, '.meteor', 'local', 'build_tar'); - var bundlePath = path.join(buildDir, 'bundle'); var outputPath = path.resolve(options.args[0]); // get absolute path + var bundlePath = options['directory'] ? + outputPath : path.join(buildDir, 'bundle'); var bundler = require(path.join(__dirname, 'bundler.js')); var bundleResult = bundler.bundle({ @@ -682,11 +684,13 @@ main.registerCommand({ return 1; } - try { - files.createTarball(path.join(buildDir, 'bundle'), outputPath); - } catch (err) { - console.log(JSON.stringify(err)); - process.stderr.write("Couldn't create tarball\n"); + if (!options['directory']) { + try { + files.createTarball(path.join(buildDir, 'bundle'), outputPath); + } catch (err) { + console.log(JSON.stringify(err)); + process.stderr.write("Couldn't create tarball\n"); + } } files.rm_recursive(buildDir); }); @@ -719,10 +723,16 @@ main.registerCommand({ if (! mongoPort) { process.stdout.write( -"mongo: Meteor isn't running.\n" + +"mongo: Meteor isn't running a local MongoDB server.\n" + "\n" + "This command only works while Meteor is running your application\n" + -"locally. Start your application first.\n"); +"locally. Start your application first. (This error will also occur if\n" + +"you asked Meteor to use a different MongoDB server with $MONGO_URL when\n" + +"you ran your application.)\n" + +"\n" + +"If you're trying to connect to the database of an app you deployed\n" + +"with 'meteor deploy', specify your site's name with this command.\n" +); return 1; } mongoUrl = "mongodb://127.0.0.1:" + mongoPort + "/meteor"; @@ -945,8 +955,8 @@ main.registerCommand({ minArgs: 1, maxArgs: 1, options: { - add: { type: String }, - remove: { type: String }, + add: { type: String, short: "a" }, + remove: { type: String, short: "r" }, list: { type: Boolean } } }, function (options) { diff --git a/tools/deploy.js b/tools/deploy.js index d5061b3b91..0d017fb0c4 100644 --- a/tools/deploy.js +++ b/tools/deploy.js @@ -280,6 +280,21 @@ var printUnauthorizedMessage = function () { // 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) { + process.stdout.write( +"The maximum hostname length currently supported is 63 characters.\n" + +site + " is too long.\n" + +"Please try again with a shorter URL for your site.\n"); + return false; + } + var url = site; if (!url.match(':\/\/')) url = 'http://' + url; diff --git a/tools/help.txt b/tools/help.txt index da838f00fd..3dfb432fc0 100644 --- a/tools/help.txt +++ b/tools/help.txt @@ -116,7 +116,9 @@ includes everything necessary to run the application. See README in the tarball for details. Options: - --debug bundle in debug mode (don't minify, etc) + --debug bundle in debug mode (don't minify, etc) + --directory output a directory (rather than a tarball). If the output + location exists, it will be recursively deleted first. >>> mongo diff --git a/tools/main.js b/tools/main.js index 4521e2b9f5..f7cc871a4b 100644 --- a/tools/main.js +++ b/tools/main.js @@ -88,7 +88,7 @@ main.SpringboardToLatestRelease = function () {}; // - can be a basic command, like "deploy" // - can be a subcommand, like "admin grant" // (distinguished by presence of ' ') -// - can be an option that functions as a command, ilke "--arch" +// - can be an option that functions as a command, like "--arch" // (distinguished by starting with '--') // - minArgs: minimum non-option arguments that can be present (default 0) // - maxArgs: maximum non-option arguments that can be present (defaults to @@ -330,7 +330,7 @@ Fiber(function () { // Check required Node version. // This code is duplicated in tools/server/boot.js. - var MIN_NODE_VERSION = 'v0.10.26'; + var MIN_NODE_VERSION = 'v0.10.28'; if (require('semver').lt(process.version, MIN_NODE_VERSION)) { process.stderr.write( 'Meteor requires Node ' + MIN_NODE_VERSION + ' or later.\n'); diff --git a/tools/meteor-npm.js b/tools/meteor-npm.js index d4a9f670f7..beb112b2e3 100644 --- a/tools/meteor-npm.js +++ b/tools/meteor-npm.js @@ -153,7 +153,7 @@ meteorNpm.dependenciesArePortable = function (packageNpmDir) { if (itemName.match(/\.node$/)) return true; var item = path.join(dir, itemName); - if (fs.statSync(item).isDirectory()) + if (fs.lstatSync(item).isDirectory()) return search(item); }) || false; }; @@ -221,7 +221,12 @@ var updateExistingNpmDirectory = function (packageName, newPackageNpmDir, if (! fs.existsSync(nodeModulesDir)) fs.mkdirSync(nodeModulesDir); - var installedDependencies = getInstalledDependencies(packageNpmDir); + var installedDependenciesTree = getInstalledDependenciesTree(packageNpmDir); + var installedDependencies = treeToDependencies(installedDependenciesTree); + var shrinkwrappedDependenciesTree = + getShrinkwrappedDependenciesTree(packageNpmDir); + var shrinkwrappedDependencies = treeToDependencies( + shrinkwrappedDependenciesTree); // If we already have the right things installed, life is good. // XXX this check is wrong: what if we just pulled a commit that @@ -229,16 +234,23 @@ var updateExistingNpmDirectory = function (packageName, newPackageNpmDir, // while it might be "correct" to just drop this check we should // be careful not to make the common case of no changes too // slow. - if (_.isEqual(installedDependencies, npmDependencies)) - return; + if (_.isEqual(installedDependencies, npmDependencies)) { + // OK, so what we have installed matches the top-level dependencies + // specified in `Npm.depends`. But what if we just pulled a change in + // npm-shrinkwrap.json to an indirectly used module version? We'll have to + // compare more carefully. First, normalize the tree (strip irrelevant + // fields and normalize to 'version'). + var minimizedInstalled = minimizeDependencyTree(installedDependenciesTree); + // If what we have installed is the same as what we have shrinkwrapped, then + // we're done. + if (_.isEqual(minimizedInstalled, shrinkwrappedDependenciesTree)) { + return; + } + } if (! quiet) logUpdateDependencies(packageName, npmDependencies); - var shrinkwrappedDependenciesTree = - getShrinkwrappedDependenciesTree(packageNpmDir); - var shrinkwrappedDependencies = treeToDependencies( - shrinkwrappedDependenciesTree); var preservedShrinkwrap = {dependencies: {}}; _.each(shrinkwrappedDependencies, function (version, name) { if (npmDependencies[name] === version) { @@ -459,22 +471,25 @@ var installNpmModule = function (name, version, dir) { // how to silence all output (specifically the installed tree which // is printed out with `console.log`) // - // We use --force, because the NPM cache is broken! See - // https://github.com/isaacs/npm/issues/3265 Basically, switching + // We used to use --force here, because the NPM cache is broken! See + // https://github.com/npm/npm/issues/3265 Basically, switching // back and forth between a tarball fork of version X and the real - // version X can confuse NPM. But the main reason to use tarball + // version X could confuse NPM. But the main reason to use tarball // URLs is to get a fork of the latest version with some fix, so - // it's easy to trigger this! So instead, always use --force. (Even - // with --force, we still WRITE to the cache, so we can corrupt the - // cache for other invocations of npm... ah well.) + // it was easy to trigger this! + // + // We now use a forked version of npm with our PR + // https://github.com/npm/npm/pull/5137 to work around this. var result = meteorNpm._execFileSync(path.join(files.getDevBundle(), "bin", "npm"), - ["install", "--force", installArg], + ["install", installArg], {cwd: dir}); if (! result.success) { - var pkgNotFound = "404 '" + name + "' is not in the npm registry"; - var versionNotFound = "version not found: " + version; + var pkgNotFound = "404 '" + utils.quotemeta(name) + + "' is not in the npm registry"; + var versionNotFound = "version not found: " + utils.quotemeta(name) + + '@' + utils.quotemeta(version); if (result.stderr.match(new RegExp(pkgNotFound))) { buildmessage.error("there is no npm package named '" + name + "'"); } else if (result.stderr.match(new RegExp(versionNotFound))) { @@ -498,11 +513,10 @@ var installFromShrinkwrap = function (dir) { ensureConnected(); - // `npm install`, which reads npm-shrinkwrap.json. See above for why - // --force. + // `npm install`, which reads npm-shrinkwrap.json. var result = meteorNpm._execFileSync(path.join(files.getDevBundle(), "bin", "npm"), - ["install", "--force"], {cwd: dir}); + ["install"], {cwd: dir}); if (! result.success) { // XXX include this in the buildmessage.error instead @@ -562,7 +576,18 @@ var shrinkwrap = function (dir) { // versions in the "version" field. var minimizeShrinkwrap = function (dir) { var topLevel = getShrinkwrappedDependenciesTree(dir); + var minimized = minimizeDependencyTree(topLevel); + fs.writeFileSync( + path.join(dir, 'npm-shrinkwrap.json'), + // Matches the formatting done by 'npm shrinkwrap'. + JSON.stringify(minimized, null, 2) + '\n'); +}; + +// Reduces a dependency tree (as read from a just-made npm-shrinkwrap.json or +// from npm ls --json) to just the versions we want. Returns an object that does +// not share state with its input +var minimizeDependencyTree = function (tree) { var minimizeModule = function (module) { var version; if (module.resolved && @@ -585,15 +610,10 @@ var minimizeShrinkwrap = function (dir) { }; var newTopLevelDependencies = {}; - _.each(topLevel.dependencies, function (module, name) { + _.each(tree.dependencies, function (module, name) { newTopLevelDependencies[name] = minimizeModule(module); }); - - fs.writeFileSync( - path.join(dir, 'npm-shrinkwrap.json'), - // Matches the formatting done by 'npm shrinkwrap'. - JSON.stringify({dependencies: newTopLevelDependencies}, null, 2) - + '\n'); + return {dependencies: newTopLevelDependencies}; }; var logUpdateDependencies = function (packageName, npmDependencies) { diff --git a/tools/packages.js b/tools/packages.js index 8118226445..b1f996d054 100644 --- a/tools/packages.js +++ b/tools/packages.js @@ -11,6 +11,7 @@ var meteorNpm = require('./meteor-npm.js'); var archinfo = require(path.join(__dirname, 'archinfo.js')); var linker = require(path.join(__dirname, 'linker.js')); var unipackage = require('./unipackage.js'); +var utils = require('./utils.js'); var fs = require('fs'); var sourcemap = require('source-map'); @@ -28,12 +29,6 @@ var sourcemap = require('source-map'); // update BUILT_BY, though you will need to quit and rerun "meteor run".) exports.BUILT_BY = 'meteor/10'; -// Like Perl's quotemeta: quotes all regexp metacharacters. See -// https://github.com/substack/quotemeta/blob/master/index.js -var quotemeta = function (str) { - return String(str).replace(/(\W)/g, '\\$1'); -}; - var rejectBadPath = function (p) { if (p.match(/\.\./)) throw new Error("bad path: " + p); @@ -285,6 +280,23 @@ _.extend(Slice.prototype, { }); }; + if (self.nodeModulesPath) { + // If this slice has node modules, we should consider the shrinkwrap file + // to be part of its inputs. (This is a little racy because there's no + // guarantee that what we read here is precisely the version that's used, + // but it's better than nothing at all.) + // + // Note that this also means that npm modules used by plugins will get + // this npm-shrinkwrap.json in their pluginDependencies (including for all + // packages that depend on us)! This is good: this means that a tweak to + // an indirect dependency of the coffee-script npm module used by the + // coffeescript package will correctly cause packages with *.coffee files + // to be rebuilt. + var shrinkwrapPath = self.nodeModulesPath.replace( + /node_modules$/, 'npm-shrinkwrap.json'); + watch.readAndWatchFile(self.watchSet, shrinkwrapPath); + } + _.each(self.getSourcesFunc(), function (source) { var relPath = source.relPath; var fileOptions = _.clone(source.fileOptions) || {}; @@ -1017,8 +1029,10 @@ _.extend(Package.prototype, { _ensurePluginsInitialized: function () { var self = this; - if (! self.pluginsBuilt) - throw new Error("running plugins of unbuilt package?"); + if (! self.pluginsBuilt) { + throw new Error("Running plugins of unbuilt package: " + self.name + + ". Do you have a circular dependency?"); + } if (self._pluginsInitialized) return; @@ -1749,7 +1763,7 @@ _.extend(Package.prototype, { // Determine source files slice.getSourcesFunc = function () { var sourceInclude = _.map(slice.registeredExtensions(), function (ext) { - return new RegExp('\\.' + quotemeta(ext) + '$'); + return new RegExp('\\.' + utils.quotemeta(ext) + '$'); }); var sourceExclude = [/^\./].concat(ignoreFiles); diff --git a/tools/run-all.js b/tools/run-all.js index 0f00937549..ba7f14f22c 100644 --- a/tools/run-all.js +++ b/tools/run-all.js @@ -91,7 +91,7 @@ _.extend(Runner.prototype, { self.proxy.start(); // print the banner only once we've successfully bound the port - if (! self.quiet & ! self.stopped) { + if (! self.quiet && ! self.stopped) { runLog.log("[[[[[ " + self.banner + " ]]]]]\n"); runLog.log("=> Started proxy."); } diff --git a/tools/run-mongo.js b/tools/run-mongo.js index faf2b6d511..450d73aedb 100644 --- a/tools/run-mongo.js +++ b/tools/run-mongo.js @@ -50,9 +50,14 @@ var findMongoPids = function (appDir, port) { var child_process = require('child_process'); child_process.exec( 'ps ax', + // we don't want this to randomly fail just because you're running lots of + // processes. 10MB should be more than ps ax will ever spit out; the default + // is 200K, which at least one person hit (#2158). + {maxBuffer: 1024 * 1024 * 10}, function (error, stdout, stderr) { if (error) { - fut['throw'](new Error("Couldn't run ps ax: " + JSON.stringify(error))); + fut['throw'](new Error("Couldn't run ps ax: " + JSON.stringify(error) + + "; " + error.message)); return; } diff --git a/tools/selftest.js b/tools/selftest.js index 494fe53ea2..0d84ac73e8 100644 --- a/tools/selftest.js +++ b/tools/selftest.js @@ -464,13 +464,16 @@ _.extend(Sandbox.prototype, { fs.writeFileSync(path.join(self.cwd, filename), contents, 'utf8'); }, - // Reads a file in the sandbox as a utf8 string. 'filename' is a path - // intepreted relative to the Sandbox's cwd. throws if the file does not - // exist. - // XXX maybe it should return null if the file does not exist? + // Reads a file in the sandbox as a utf8 string. 'filename' is a + // path intepreted relative to the Sandbox's cwd. Returns null if + // file does not exist. read: function (filename) { var self = this; - return fs.readFileSync(path.join(self.cwd, filename), 'utf8'); + var file = path.join(self.cwd, filename); + if (!fs.existsSync(file)) + return null; + else + return fs.readFileSync(path.join(self.cwd, filename), 'utf8'); }, // Delete a file in the sandbox. 'filename' is as in write(). diff --git a/tools/server/boot.js b/tools/server/boot.js index 6f401f4140..2076c92c2a 100644 --- a/tools/server/boot.js +++ b/tools/server/boot.js @@ -6,7 +6,7 @@ var _ = require('underscore'); var sourcemap_support = require('source-map-support'); // This code is duplicated in tools/main.js. -var MIN_NODE_VERSION = 'v0.10.26'; +var MIN_NODE_VERSION = 'v0.10.28'; if (require('semver').lt(process.version, MIN_NODE_VERSION)) { process.stderr.write( diff --git a/tools/tests/login.js b/tools/tests/login.js index 876eeffe7c..0e793bf3f5 100644 --- a/tools/tests/login.js +++ b/tools/tests/login.js @@ -19,6 +19,7 @@ selftest.define("login", ['net'], function () { // even if you are already logged in. for (var i = 0; i < 2; i++) { run = s.run("login"); + run.waitSecs(commandTimeoutSecs); run.matchErr("Username:"); run.write("test\n"); run.matchErr("Password:"); @@ -31,6 +32,7 @@ selftest.define("login", ['net'], function () { // Leaving username blank, or getting the password wrong, doesn't // reprompt. It also doesn't log you out. run = s.run("login"); + run.waitSecs(commandTimeoutSecs); run.matchErr("Username:"); run.write("\n"); run.matchErr("Password:"); @@ -40,6 +42,7 @@ selftest.define("login", ['net'], function () { run.expectExit(1); run = s.run("login"); + run.waitSecs(commandTimeoutSecs); run.matchErr("Username:"); run.write("test\n"); run.matchErr("Password:"); @@ -49,6 +52,7 @@ selftest.define("login", ['net'], function () { run.expectExit(1); run = s.run('login'); + run.waitSecs(commandTimeoutSecs); run.matchErr("Username:"); run.write("test\n"); run.matchErr("Password:"); @@ -80,6 +84,7 @@ selftest.define("login", ['net'], function () { // Test login failure run = s.run("login"); + run.waitSecs(commandTimeoutSecs); run.matchErr("Username:"); run.write("test\n"); run.matchErr("Password:"); @@ -91,6 +96,7 @@ selftest.define("login", ['net'], function () { // Logging in with a capitalized username should work (usernames are // case-insensitive). run = s.run("login"); + run.waitSecs(commandTimeoutSecs); run.matchErr("Username:"); run.write("TeSt\n"); run.matchErr("Password:"); @@ -107,6 +113,7 @@ selftest.define("login", ['net'], function () { // Logging in with a capitalized password should NOT work (can't be // too safe...) run = s.run("login"); + run.waitSecs(commandTimeoutSecs); run.matchErr("Username:"); run.write("test\n"); run.matchErr("Password:"); diff --git a/tools/tests/old/cli-test.sh b/tools/tests/old/cli-test.sh index b6f870d3a8..779bacb541 100755 --- a/tools/tests/old/cli-test.sh +++ b/tools/tests/old/cli-test.sh @@ -11,7 +11,7 @@ set -e -x -cd `dirname $0`/../../.. +cd "`dirname "$0"`"/../../.. # METEOR_TOOL_PATH is the path to the 'meteor' that we will use for # our tests. There is a vestigal capability to default to running the diff --git a/tools/tests/old/test-bundler-npm.js b/tools/tests/old/test-bundler-npm.js index bc67ad612c..7300035e15 100644 --- a/tools/tests/old/test-bundler-npm.js +++ b/tools/tests/old/test-bundler-npm.js @@ -84,7 +84,7 @@ var _assertCorrectPackageNpmDir = function (deps) { var expected = JSON.stringify({ dependencies: expectedMeteorNpmShrinkwrapDependencies}, null, /*indentation, the way npm does it*/2) + '\n'; - assert.equal(actual, expected); + assert.equal(actual, expected, actual + " == " + expected); assert.equal( fs.readFileSync(path.join(testPackageDir, ".npm", "package", ".gitignore"), 'utf8'), @@ -203,7 +203,7 @@ var runTest = function () { // just remove all of the .npm directory) var bareExecFileSync = meteorNpm._execFileSync; meteorNpm._execFileSync = function (file, args, opts) { - if (args.length > 2 && args[0] === 'install' && args[1] === '--force') + if (args.length > 1 && args[0] === 'install') assert.fail("shouldn't be installing specific npm packages: " + args[1]); return bareExecFileSync(file, args, opts); }; diff --git a/tools/tests/run.js b/tools/tests/run.js index 80e4544544..cc114a024e 100644 --- a/tools/tests/run.js +++ b/tools/tests/run.js @@ -180,7 +180,7 @@ selftest.define("run --once", function () { s.cd("onceapp"); s.set("RUN_ONCE_OUTCOME", "mongo"); run = s.run("--once"); - run.waitSecs(15); + run.waitSecs(30); run.expectExit(86); }); @@ -199,7 +199,7 @@ selftest.define("run errors", function () { var run = s.run("-p", proxyPort); _.times(3, function () { - run.waitSecs(3); + run.waitSecs(30); run.match("Unexpected mongo exit code 48. Restarting."); }); run.waitSecs(3); diff --git a/tools/utils.js b/tools/utils.js index 198cdc8897..80ae7ab6e2 100644 --- a/tools/utils.js +++ b/tools/utils.js @@ -146,3 +146,8 @@ exports.validEmail = function (address) { return /^[^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*@([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}$/.test(address); } +// Like Perl's quotemeta: quotes all regexp metacharacters. See +// https://github.com/substack/quotemeta/blob/master/index.js +exports.quotemeta = function (str) { + return String(str).replace(/(\W)/g, '\\$1'); +};