Merge branch 'release-0.8.2'

This commit is contained in:
Emily Stark
2014-06-23 08:14:17 -07:00
146 changed files with 3404 additions and 1537 deletions

View File

@@ -12,6 +12,7 @@ GITHUB: aldeed <eric@dairystatedesigns.com>
GITHUB: AlexeyMK <alexey@alexeymk.com>
GITHUB: apendua <apendua@gmail.com>
GITHUB: arbesfeld <arbesfeld@gmail.com>
GITHUB: Cangit <fredricendrerud@gmail.com>
GITHUB: DenisGorbachev <Denis.Gorbachev@faster-than-wind.ru>
GITHUB: EOT <eot@gmx.at>
GITHUB: FooBarWidget <honglilai@gmail.com>
@@ -20,15 +21,21 @@ GITHUB: OyoKooN <nathan@sxnlabs.com>
GITHUB: RobertLowe <robert@iblargz.com>
GITHUB: ansman <nicklas@ansman.se>
GITHUB: awwx <andrew.wilcox@gmail.com>
GITHUB: babenzele <tim.p.phillips@gmail.com>
GITHUB: cmather <mather.chris@gmail.com>
GITHUB: codeinthehole <david.winterbottom@gmail.com>
GITHUB: dandv <ddascalescu+github@gmail.com>
GITHUB: davegonzalez <gonzalez.dalex@gmail.com>
GITHUB: ducdigital <duc@ducdigital.com>
GITHUB: emgee3 <hello@gravitronic.com>
GITHUB: felixrabe <felix@rabe.io>
GITHUB: FredericoC <frederico.carvalho@3stack.com.au>
GITHUB: icellan <icellan@icellan.com>
GITHUB: jacott <geoffjacobsen@gmail.com>
GITHUB: jfhamlin <jfhamlin@gmail.com>
GITHUB: jbruni <contato@jbruni.com.br>
GITHUB: justinsb <justin@fathomdb.com>
GITHUB: kentonv <temporal@gmail.com>
GITHUB: marcandre <github@marc-andre.ca>
GITHUB: mart-jansink <m.jansink@gmail.com>
GITHUB: meawoppl <meawoppl@gmail.com>
@@ -47,7 +54,10 @@ GITHUB: rgould <rwgould@gmail.com>
GITHUB: ryw <ry@rywalker.com>
GITHUB: rzymek <rzymek@gmail.com>
GITHUB: sdarnell <stephen@darnell.plus.com>
GITHUB: subhog <hubert@orlikarnia.com>
GITHUB: tbjers <torgny@xorcode.com>
GITHUB: timhaines <tmhaines@gmail.com>
GITHUB: tmeasday <tom@thesnail.org>
GITHUB: yeputons <egor.suvorov@gmail.com>
GITHUB: zol <zol@percolatestudio.com>
@@ -65,4 +75,3 @@ METEOR: sixolet <naomi@meteor.com>
METEOR: Slava <slava@meteor.com>
METEOR: stubailo <sashko@mit.edu>
METEOR: ekatek <ekate@meteor.com>

View File

@@ -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: <plaintext
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

View File

@@ -1 +1 @@
0.8.1.3
0.8.2

View File

@@ -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>

View File

@@ -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: [

View File

@@ -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

View File

@@ -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

View File

@@ -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"},

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -1 +1 @@
0.8.1.3
0.8.2

View File

@@ -1 +1 @@
0.8.1.3
0.8.2

View File

@@ -1 +1 @@
0.8.1.3
0.8.2

View File

@@ -1 +1 @@
0.8.1.3
0.8.2

View File

@@ -1 +1 @@
0.8.1.3
0.8.2

2
meteor
View File

@@ -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.

View File

@@ -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

View File

@@ -10,7 +10,7 @@ if (Meteor.isClient) {
var credentialRequestCompleteCallback =
Accounts.oauth.credentialRequestCompleteHandler(callback);
MeteorDeveloperAccounts.requestCredential(credentialRequestCompleteCallback);
MeteorDeveloperAccounts.requestCredential(options, credentialRequestCompleteCallback);
};
} else {
Accounts.addAutopublishFields({

View File

@@ -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']);

View File

@@ -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});
};

View File

@@ -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)

View File

@@ -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);

View File

@@ -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");
}
});

View File

@@ -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>

View File

@@ -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

View File

@@ -14,6 +14,7 @@ var VALID_KEYS = [
'resetPasswordToken',
'enrollAccountToken',
'justVerifiedEmail',
'justResetPassword',
'configureLoginServiceDialogVisible',
'configureLoginServiceDialogServiceName',

View File

@@ -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 {

View File

@@ -3,5 +3,6 @@ Package.describe({
});
Package.on_use(function (api) {
api.use('jquery', 'client');
api.add_files('amplify.js', 'client');
});

View File

@@ -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();
});

View File

@@ -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";
}
}
});

View File

@@ -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);
});

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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');

View File

@@ -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);
});
}
}
];

View File

@@ -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.

View File

@@ -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" +

View File

@@ -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.

View File

@@ -6,6 +6,7 @@ Package.describe({
Package.on_use(function (api) {
api.use('htmljs');
api.imply('htmljs');
api.export('HTMLTools');

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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"
}
}
}
}
}

View File

@@ -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));

View File

@@ -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();

View File

@@ -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();
}
},

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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;
};

View File

@@ -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) {

View File

@@ -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"
}
}
}

View File

@@ -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;

View File

@@ -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"
});

View File

@@ -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');

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 = {};

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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

View File

@@ -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');
};

View File

@@ -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']);
});

1
packages/sha/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.build*

9
packages/sha/package.js Normal file
View File

@@ -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']);
});

View File

@@ -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

1
packages/spacebars-common/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.build*

View File

@@ -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');
});

View File

@@ -0,0 +1 @@
Spacebars = {};

View File

@@ -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;

View File

@@ -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']);

View File

@@ -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])];

View File

@@ -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', '=']]});
});

View File

@@ -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];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 });
});

View File

@@ -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>

View File

@@ -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");
}
);

View File

@@ -0,0 +1,7 @@
var path = Npm.require("path");
Meteor.methods({
getAsset: function (filename) {
return Assets.getText(path.join("assets", filename));
}
});

View File

@@ -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>

View File

@@ -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);
}
});
};

View File

@@ -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>

View File

@@ -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, {});
}
);

View File

@@ -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");
});

View File

@@ -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;

View File

@@ -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']);
});

View File

@@ -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);
};

View File

@@ -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");
});

View File

@@ -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"
}
}
}

View File

@@ -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) {

View File

@@ -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(' ')+'>';

View File

@@ -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');

Some files were not shown because too many files have changed in this diff Show More