Merge remote-tracking branch 'origin/follower-node0.10' into follower

Conflicts:
	docs/client/concepts.html
	meteor
	packages/follower-livedata/follower.js
	packages/livedata/livedata_server.js
	packages/webapp/.npm/package/npm-shrinkwrap.json
	packages/webapp/package.js
	packages/webapp/webapp_server.js
	scripts/generate-dev-bundle.sh
	tools/bundler.js
	tools/meteor.js
	tools/server/boot.js
This commit is contained in:
Emily Stark
2013-10-31 15:44:15 -07:00
151 changed files with 5615 additions and 1639 deletions

35
.mailmap Normal file
View File

@@ -0,0 +1,35 @@
# This makes it easier to find GitHub usernames for History.md.
#
# This controls 'git shortlog'. eg, run:
# git shortlog -s release/0.6.5.1..HEAD
# to get a sorted list of all committers to revisions in HEAD but not
# in 0.6.5.1.
#
# For any emails that show up in the shortlog that aren't in one of
# these lists, figure out their GitHub username and add them.
GITHUB: ansman <nicklas@ansman.se>
GITHUB: awwx <andrew.wilcox@gmail.com>
GITHUB: codeinthehole <david.winterbottom@gmail.com>
GITHUB: jacott <geoffjacobsen@gmail.com>
GITHUB: Maxhodges <Max@whiterabbitpress.com>
GITHUB: meawoppl <meawoppl@gmail.com>
GITHUB: mitar <mitar.git@tnode.com>
GITHUB: mizzao <mizzao@gmail.com>
GITHUB: mquandalle <maxime.quandalle@gmail.com>
GITHUB: nathan-muir <ndmuir@gmail.com>
GITHUB: RobertLowe <robert@iblargz.com>
GITHUB: ryw <ry@rywalker.com>
GITHUB: sdarnell <stephen@darnell.plus.com>
GITHUB: timhaines <tmhaines@gmail.com>
METEOR: avital <avital@thewe.net>
METEOR: debergalis <matt@meteor.com>
METEOR: dgreensp <dgreenspan@alum.mit.edu>
METEOR: estark37 <emily@meteor.com>
METEOR: estark37 <estark37@gmail.com>
METEOR: glasser <glasser@meteor.com>
METEOR: gschmidt <geoff@geoffschmidt.com>
METEOR: n1mmy <nim@meteor.com>
METEOR: sixolet <naomi@meteor.com>
METEOR: Slava <slava@meteor.com>

View File

@@ -1,7 +1,248 @@
## vNEXT
* Write dates to Mongo as ISODate rather than Integer; existing data can be
converted by passing it through `new Date()`. #1228
* Fix `meteor run` with settings files containing non-ASCII characters. #1497
* Support `EJSON.clone` for `Meteor.Error`. As a result, they are properly
stringified in DDP even if thrown through a `Future`. #1482
* Fail explicitly when publishing non-cursors.
* Implement `$each`, `$sort`, and `$slice` options for minimongo's `$push`
modifier. #1492
* Increase the maximum size spiderable will return for a page from 200kB
to 5MB.
* Upgraded dependencies:
* SockJS server from 0.3.7 to 0.3.8
Patches contributed by GitHub user mcbain.
## v0.6.6.2
* Upgrade Node from 0.10.20 to 0.10.21 (security update).
## v0.6.6.1
* Fix file watching on OSX. Work around Node issue #6251 by not using
fs.watch. #1483
## v0.6.6
#### Security
* Add `browser-policy` package for configuring and sending
Content-Security-Policy and X-Frame-Options HTTP headers.
[See the docs](http://docs.meteor.com/#browserpolicy) for more.
* Use cryptographically strong pseudorandom number generators when available.
#### MongoDB
* Add upsert support. `Collection.update` now supports the `{upsert:
true}` option. Additionally, add a `Collection.upsert` method which
returns the newly inserted object id if applicable.
* `update` and `remove` now return the number of documents affected. #1046
* `$near` operator for `2d` and `2dsphere` indices.
* The `fields` option to the collection methods `find` and `findOne` now works
on the client as well. (Operators such as `$elemMatch` and `$` are not yet
supported in `fields` projections.) #1287
* Pass an index and the cursor itself to the callbacks in `cursor.forEach` and
`cursor.map`, just like the corresponding `Array` methods. #63
* Support `c.find(query, {limit: N}).count()` on the client. #654
* Improve behavior of `$ne`, `$nin`, and `$not` selectors with objects containing
arrays. #1451
* Fix various bugs if you had two documents with the same _id field in
String and ObjectID form.
#### Accounts
* [Behavior Change] Expire login tokens periodically. Defaults to 90
days. Use `Accounts.config({loginExpirationInDays: null})` to disable
token expiration.
* [Behavior Change] Write dates generated by Meteor Accounts to Mongo as
Date instead of number; existing data can be converted by passing it
through `new Date()`. #1228
* Log out and close connections for users if they are deleted from the
database.
* Add Meteor.logoutOtherClients() for logging out other connections
logged in as the current user.
* `restrictCreationByEmailDomain` option in `Accounts.config` to restrict new
users to emails of specific domain (eg. only users with @meteor.com emails) or
a custom validator. #1332
* Support OAuth1 services that require request token secrets as well as
authentication token secrets. #1253
* Warn if `Accounts.config` is only called on the client. #828
* Fix bug where callbacks to login functions could be called multiple
times when the client reconnects.
#### DDP
* Fix infinite loop if a client disconnects while a long yielding method is
running.
* Unfinished code to support DDP session resumption has been removed. Meteor
servers now stop processing messages from clients and reclaim memory
associated with them as soon as they are disconnected instead of a few minutes
later.
#### Tools
* The pre-0.6.5 `Package.register_extension` API has been removed. Use
`Package._transitional_registerBuildPlugin` instead, which was introduced in
0.6.5. (A bug prevented the 0.6.5 reimplementation of `register_extension`
from working properly anyway.)
* Support using an HTTP proxy in the `meteor` command line tool. This
allows the `update`, `deploy`, `logs`, and `mongo` commands to work
behind a proxy. Use the standard `http_proxy` environment variable to
specify your proxy endpoint. #429, #689, #1338
* Build Linux binaries on an older Linux machine. Meteor now supports
running on Linux machines with glibc 2.9 or newer (Ubuntu 10.04+, RHEL
and CentOS 6+, Fedora 10+, Debian 6+). Improve error message when running
on Linux with unsupported glibc, and include Mongo stderr if it fails
to start.
* Install NPM modules with `--force` to avoid corrupted local caches.
* Rebuild NPM modules in packages when upgrading to a version of Meteor that
uses a different version of Node.
* Disable the Mongo http interface. This lets you run meteor on two ports
differing by 1000 at the same time.
#### Misc
* [Known issue] Breaks support for pre-release OSX 10.9 'Mavericks'.
Will be addressed shortly. See issues:
https://github.com/joyent/node/issues/6251
https://github.com/joyent/node/issues/6296
* `EJSON.stringify` now takes options:
- `canonical` causes objects keys to be stringified in sorted order
- `indent` allows formatting control over the EJSON stringification
* EJSON now supports `Infinity`, `-Infinity` and `NaN`.
* Check that the argument to `EJSON.parse` is a string. #1401
* Better error from functions that use `Meteor._wrapAsync` (eg collection write
methods and `HTTP` methods) and in DDP server message processing. #1387
* Support `appcache` on Chrome for iOS.
* Support literate CoffeeScript files with the extension `.coffee.md` (in
addition to the already-supported `.litcoffee` extension). #1407
* Make `madewith` package work again (broken in 0.6.5). #1448
* Better error when passing a string to `{{#each}}`. #722
* Add support for JSESSIONID cookies for sticky sessions. Set the
`USE_JSESSIONID` environment variable to enable placing a JSESSIONID
cookie on sockjs requests.
* Simplify the static analysis used to detect package-scope variables.
* Upgraded dependencies:
* Node from 0.8.24 to 0.10.20
* MongoDB from 2.4.4 to 2.4.6
* MongoDB driver from 1.3.17 to 1.3.19
* http-proxy from 0.10.1 to a pre-release of 1.0.0
* stylus from 0.30.1 to 0.37.0
* nib from 0.8.2 to 1.0.0
* optimist from 0.3.5 to 0.6.0
* semver from 1.1.0 to 2.1.0
* request from 2.12.0 to 2.27.0
* keypress from 0.1.0 to 0.2.1
* underscore from 1.5.1 to 1.5.2
* fstream from 0.1.21 to 0.1.24
* tar from 0.1.14 to 0.1.18
* source-map from 0.1.26 to 0.1.30
* source-map-support from a fork of 0.1.8 to 0.2.3
* escope from a fork of 0.0.15 to 1.0.0
* estraverse from 1.1.2-1 to 1.3.1
* simplesmtp from 0.1.25 to 0.3.10
* stream-buffers from 0.2.3 to 0.2.5
* websocket from 1.0.7 to 1.0.8
* cli-color from 0.2.2 to 0.2.3
* clean-css from 1.0.11 to 1.1.2
* UglifyJS2 from a fork of 2.3.6 to a different fork of 2.4.0
* connect from 2.7.10 to 2.9.0
* send from 0.1.0 to 0.1.4
* useragent from 2.0.1 to 2.0.7
* replaced byline with eachline 2.3.3
Patches contributed by GitHub users ansman, awwx, codeinthehole, jacott,
Maxhodges, meawoppl, mitar, mizzao, mquandalle, nathan-muir, RobertLowe, ryw,
sdarnell, and timhaines.
## v0.6.5.1
* Fix syntax errors on lines that end with a backslash. #1326
* Fix serving static files with special characters in their name. #1339
* Upgrade `esprima` JavaScript parser to fix bug parsing complex regexps.
* Export `Spiderable` from `spiderable` package to allow users to set
`Spiderable.userAgentRegExps` to control what user agents are treated
as spiders.
* Add EJSON to standard-app-packages. #1343
* Fix bug in d3 tab character parsing.
* Fix regression when using Mongo ObjectIDs in Spark templates.
* Increase the maximum size spiderable will return for a page from 200kB
to 5MB.
## v0.6.5.2
* Upgrade Node from 0.8.24 to 0.8.26 (security patch)
## v0.6.5.1
* Fix syntax errors on lines that end with a backslash. #1326
* Fix serving static files with special characters in their name. #1339
* Upgrade `esprima` JavaScript parser to fix bug parsing complex regexps.
* Export `Spiderable` from `spiderable` package to allow users to set
`Spiderable.userAgentRegExps` to control what user agents are treated
as spiders.
* Add EJSON to standard-app-packages. #1343
* Fix bug in d3 tab character parsing.
* Fix regression when using Mongo ObjectIDs in Spark templates.
* Increase the maximum size spiderable will return for a page from 200kB
to 5MB.

View File

@@ -90,6 +90,7 @@ github-url-from-git: https://github.com/visionmedia/node-github-url-from-git
pause: https://github.com/visionmedia/node-pause
range-parser: https://github.com/visionmedia/node-range-parser
send: https://github.com/visionmedia/send
methods: https://github.com/visionmedia/node-methods
----------
Copyright (c) 2010 TJ Holowaychuk <tj@vision-media.ca>
@@ -191,13 +192,6 @@ rbytes: https://github.com/akdubya/rbytes
Copyright (c) 2010 Aleksander Williams
----------
formidable: https://github.com/felixge/node-formidable
----------
By Felix Geisendörfer and Tim Koschuetzki, Debuggable, Ltd.
----------
colors: https://github.com/Marak/colors.js
----------
@@ -245,9 +239,11 @@ Copyright (c) 2012 Nathan Rajlich <nathan@tootallnate.net>
----------
faye-websocket: https://github.com/faye/faye-websocket-node
websocket-driver: https://github.com/faye/websocket-driver-node
----------
Copyright (c) 2009-2012 James Coglan
Copyright (c) 2009-2013 James Coglan
Copyright (c) 2010-2013 James Coglan
----------
@@ -291,6 +287,7 @@ archy: https://github.com/substack/node-archy
shell-quote: https://github.com/substack/node-shell-quote
deep-equal: https://github.com/substack/node-deep-equal
editor: https://github.com/substack/node-editor
minimist: https://github.com/substack/node-minimist
----------
Copyright 2010, 2011, 2012, 2013 James Halliday (mail@substack.net)
@@ -328,6 +325,7 @@ Felix Geisendörfer (felix@debuggable.com)
----------
form-data: https://github.com/felixge/node-form-data
multiparty: https://github.com/superjoe30/node-multiparty
----------
Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors
@@ -377,13 +375,6 @@ buffer-crc32: https://github.com/brianloveswords/buffer-crc32
Copyright (c) 2013 Brian J. Brennan
----------
byline: https://github.com/jahewson/node-byline
----------
node-byline (C) 2011-2013 John Hewson
----------
child-process-close: https://github.com/piscisaureus/child-process-close
----------
@@ -475,6 +466,8 @@ Copyright (c) 2011 SDA Software Associates Inc.
----------
sha: https://github.com/ForbesLindesay/sha
type-of: https://github.com/ForbesLindesay/type-of
uglify-to-browserify: https://github.com/ForbesLindesay/uglify-to-browserify
----------
Copyright (c) 2013 Forbes Lindesay
@@ -516,6 +509,38 @@ Copyright 2011, Robert Mustacchi. All rights reserved.
Copyright 2011, Joyent, Inc. All rights reserved.
----------
eachline: https://github.com/williamwicks/node-eachline
----------
Copyright (c) 2013 William Wicks
----------
eventemitter2: https://github.com/hij1nx/EventEmitter2
----------
Copyright (c) 2011 hij1nx http://www.twitter.com/hij1nx
----------
stream-counter: https://github.com/superjoe30/node-stream-counter
----------
Copyright (c) 2013 Andrew Kelley
----------
uid2: https://github.com/coreh/uid2
----------
Copyright (c) 2013 Marco Aurelio
----------
geojson-utils: https://github.com/maxogden/geojson-js-utils
----------
Copyright (c) 2010 Max Ogden
==============
Apache License
==============
@@ -531,7 +556,8 @@ Unless required by applicable law or agreed to in writing, software distributed
"""
----------
mongo-drivers: https://github.com/mongodb/node-mongodb-native
mongodb: (Node driver) https://github.com/mongodb/node-mongodb-native
kerberos: https://github.com/christkv/kerberos
----------
Copyright 2009 - 2010 Christian Amor Kvalheim.
@@ -1309,7 +1335,7 @@ Other
=====
----------
mimelib: https://github.com/andris9/mimelib
mimelib-noiconv: https://github.com/andris9/mimelib
mailcomposer: https://github.com/andris9/mailcomposer
simplesmtp: https://github.com/andris9/simplesmtp
rai: https://github.com/andris9/rai
@@ -1407,7 +1433,7 @@ For more information, please refer to <http://unlicense.org/>
----------
mongodb: http://www.mongodb.org/
MongoDB: http://www.mongodb.org/
----------
LICENSE
@@ -1711,6 +1737,7 @@ The externally maintained libraries used by libuv are:
----------
nodejs: http://nodejs.org/
readable-stream: https://github.com/isaacs/readable-stream/
----------

View File

@@ -56,8 +56,8 @@ From your checkout, you can read the docs locally. The `/docs` directory is a
meteor application, so simply change into the `/docs` directory and launch
the app:
cd docs/
meteor
cd docs/
../meteor
You'll then be able to read the docs locally in your browser at
`http://localhost:3000/`

View File

@@ -1 +1 @@
galaxy-appconfig-2
galaxy-follower-5

View File

@@ -62,9 +62,9 @@ that Meteor will call each time a client subscribes to the name.
Publish functions can return a
[`Collection.Cursor`](#meteor_collection_cursor), in which case Meteor
will publish that cursor's documents. You can also return an array of
`Collection.Cursor`s, in which case Meteor will publish all of the
cursors.
will publish that cursor's documents to each subscribed client. You can
also return an array of `Collection.Cursor`s, in which case Meteor will
publish all of the cursors.
{{#warning}}
If you return multiple cursors in an array, they currently must all be from
@@ -92,16 +92,15 @@ different collections. We hope to lift this restriction in a future release.
];
});
Otherwise, the publish function should call the functions
[`added`](#publish_added) (when a new document is added to the published record
set), [`changed`](#publish_changed) (when some fields on a document in the
record set are changed or cleared), and [`removed`](#publish_removed) (when
documents are removed from the published record set) to inform subscribers about
documents. These methods are provided by `this` in your publish function.
<!-- TODO discuss ready -->
Alternatively, a publish function can directly control its published
record set by calling the functions [`added`](#publish_added) (to add a
new document to the published record set), [`changed`](#publish_changed)
(to change or clear some fields on a document already in the published
record set), and [`removed`](#publish_removed) (to remove documents from
the published record set). Publish functions that use these functions
should also call [`ready`](#publish_ready) once the initial record set
is complete. These methods are provided by `this` in your publish
function.
Example:
@@ -634,7 +633,7 @@ In this release, Minimongo has some limitations:
of selectors.
* `$` to denote the matched array position is not
supported in modifier.
* `findAndModify`, upsert, aggregate functions, and
* `findAndModify`, aggregate functions, and
map/reduce aren't supported.
All of these will be addressed in a future release. For full
@@ -723,24 +722,27 @@ handlers and a browser's JavaScript console.
`multi` to true, and can use an arbitrary [Mongo
selector](#selectors) to find the documents to modify. It bypasses
any access control rules set up by [`allow`](#allow) and
[`deny`](#deny).
[`deny`](#deny). The number of affected documents will be returned
from the `update` call if you don't pass a callback.
- Untrusted code can only modify a single document at once, specified
by its `_id`. The modification is allowed only after checking any
applicable [`allow`](#allow) and [`deny`](#deny) rules.
applicable [`allow`](#allow) and [`deny`](#deny) rules. The number
of affected documents will be returned to the callback. Untrusted
code cannot perform upserts, except in insecure mode.
On the server, if you don't provide a callback, then `update` blocks
until the database acknowledges the write, or throws an exception if
something went wrong. If you do provide a callback, `update` returns
immediately. Once the update completes, the callback is called with a
single error argument in the case of failure, or no arguments if the
update was successful.
single error argument in the case of failure, or a second argument
indicating the number of affected documents if the update was successful.
On the client, `update` never blocks. If you do not provide a callback
and the update fails on the server, then Meteor will log a warning to
the console. If you provide a callback, Meteor will call that function
with an error argument if there was an error, or no arguments if the
update was successful.
with an error argument if there was an error, or a second argument
indicating the number of affected documents if the update was successful.
Client example:
@@ -766,9 +768,18 @@ Server example:
}
});
{{#warning}}
The Mongo `upsert` feature is not implemented.
{{/warning}}
You can use `update` to perform a Mongo upsert by setting the `upsert`
option to true. You can also use the [`upsert`](#upsert) method to perform an
upsert that returns the _id of the document that was inserted (if there was one)
in addition to the number of affected documents.
{{> api_box upsert}}
Modify documents that match `selector` according to `modifier`, or insert
a document if no documents were modified. `upsert` is the same as calling
`update` with the `upsert` option set to true, except that the return
value of `upsert` is an object that contain the keys `numberAffected`
and `insertedId`. (`update` returns only the number of affected documents.)
{{> api_box remove}}
@@ -784,7 +795,8 @@ handlers and a browser's JavaScript console.
find the documents to remove, and can remove more than one document
at once by passing a selector that matches multiple documents. It
bypasses any access control rules set up by [`allow`](#allow) and
[`deny`](#deny).
[`deny`](#deny). The number of removed documents will be returned
from `remove` if you don't pass a callback.
As a safety measure, if `selector` is omitted (or is `undefined`),
no documents will be removed. Set `selector` to `{}` if you really
@@ -792,20 +804,22 @@ handlers and a browser's JavaScript console.
- Untrusted code can only remove a single document at a time,
specified by its `_id`. The document is removed only after checking
any applicable [`allow`](#allow) and [`deny`](#deny) rules.
any applicable [`allow`](#allow) and [`deny`](#deny) rules. The
number of removed documents will be returned to the callback.
On the server, if you don't provide a callback, then `remove` blocks
until the database acknowledges the write, or throws an exception if
until the database acknowledges the write and then returns the number
of removed documents, or throws an exception if
something went wrong. If you do provide a callback, `remove` returns
immediately. Once the remove completes, the callback is called with a
single error argument in the case of failure, or no arguments if the
remove was successful.
single error argument in the case of failure, or a second argument
indicating the number of removed documents if the remove was successful.
On the client, `remove` never blocks. If you do not provide a callback
and the remove fails on the server, then Meteor will log a warning to
the console. If you provide a callback, Meteor will call that function
with an error argument if there was an error, or no arguments if the
remove was successful.
and the remove fails on the server, then Meteor will log a warning to the
console. If you provide a callback, Meteor will call that function with an
error argument if there was an error, or a second argument indicating the number
of removed documents if the remove was successful.
Client example:
@@ -966,6 +980,8 @@ cursor, use [`forEach`](#foreach), [`map`](#map), or [`fetch`](#fetch).
{{> api_box cursor_foreach}}
This interface is compatible with [Array.forEach](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach).
When called from a reactive computation, `forEach` registers dependencies on
the matching documents.
@@ -981,6 +997,8 @@ Examples:
{{> api_box cursor_map}}
This interface is compatible with [Array.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map).
When called from a reactive computation, `map` registers dependencies on
the matching documents.
@@ -1258,11 +1276,26 @@ is a dictionary whose keys are field names and whose values are `0`.
// Users.find({}, {fields: {password: 0, hash: 0}})
To return an object that only includes the specified field, use `1` as
the value. The `_id` field is still included in the result.
the value. The `_id` field is still included in the result.
// Users.find({}, {fields: {firstname: 1, lastname: 1}})
It is not possible to mix inclusion and exclusion styles.
It is not possible to mix inclusion and exclusion styles (except for the cases
when `_id` is included by default or explicitly excluded). Field operators such
as `$` and `$elemMatch` are not available on the client side yet.
More advanced example:
Users.insert({ alterEgos: [{ name: "Kira", alliance: "murderer" },
{ name: "L", alliance: "police" }],
name: "Yagami Light" });
Users.findOne({}, { fields: { 'alterEgos.name': 1, _id: 0 } });
// returns { alterEgos: [{ name: "Kira" }, { name: "L" }] }
See <a href="http://docs.mongodb.org/manual/tutorial/project-fields-from-query-results/#projection">
the MongoDB docs</a> for details of the nested field rules and array behavior.
{{/api_box_inline}}
@@ -1482,6 +1515,12 @@ animation while the login request is being processed.
{{> api_box logout}}
{{> api_box logoutOtherClients}}
For example, when called in a user's browser, connections in that browser
remain logged in, but any other browsers or DDP clients logged in as that user
will be logged out.
{{> api_box loginWithPassword}}
This function is provided by the `accounts-password` package. See the
@@ -2834,17 +2873,18 @@ these functions, add the HTTP package to your project with `$ meteor add http`.
{{> api_box httpcall}}
This function initiates an HTTP request to a remote server. It returns
a result object with the contents of the HTTP response. The result
object is detailed below.
This function initiates an HTTP request to a remote server.
On the server, this function can be run either synchronously or
asynchronously. If the callback is omitted, it runs synchronously,
and the results are returned once the request completes. This is
asynchronously. If the callback is omitted, it runs synchronously
and the results are returned once the request completes successfully.
If the request was not successful, an error is thrown.
This is
useful when making server-to-server HTTP API calls from within Meteor
methods, as the method can succeed or fail based on the results of the
synchronous HTTP call. In this case, consider using
[`this.unblock()`](#method_unblock) to allow other methods to run in
[`this.unblock()`](#method_unblock) to allow other methods on the same
connection to run in
the mean time. On the client, this function must be used
asynchronously by passing a callback.
@@ -2864,7 +2904,8 @@ standard `x-www-form-urlencoded` content type, unless the `content`
or `data` option is used to specify a body, in which case the
parameters will be appended to the URL instead.
The callback receives two arguments, `error` and `result`. The
When run in asynchronous mode, the callback receives two arguments,
`error` and `result`. The
`error` argument will contain an Error if the request fails in any
way, including a network error, time-out, or an HTTP status code in
the 400 or 500 range. In case of a 4xx/5xx HTTP status code, the
@@ -2899,11 +2940,14 @@ Example server method:
Meteor.methods({checkTwitter: function (userId) {
check(userId, String);
this.unblock();
var result = HTTP.call("GET", "http://api.twitter.com/xyz",
{params: {user: userId}});
if (result.statusCode === 200)
return true
return false;
try {
var result = HTTP.call("GET", "http://api.twitter.com/xyz",
{params: {user: userId}});
return true;
} catch (e) {
// Got a network error, time-out or HTTP error in the 400 or 500 range.
return false;
}
}});
Example asynchronous HTTP call:
@@ -2911,7 +2955,7 @@ Example asynchronous HTTP call:
HTTP.call("POST", "http://api.twitter.com/xyz",
{data: {some: "json", stuff: 1}},
function (error, result) {
if (result.statusCode === 200) {
if (!error) {
Session.set("twizzled", true);
}
});

View File

@@ -73,8 +73,7 @@ Template.api.release = {
descr: ["`Meteor.release` is a string containing the name of the " +
"[release](#meteorupdate) with which the project was built (for " +
"example, `\"" +
// Put the current release in the docs as the example)
(Meteor.release ? Meteor.release : '0.6.0') +
Meteor.release +
"\"`). It is `undefined` if the project was built using a git " +
"checkout of Meteor."]
};
@@ -89,9 +88,17 @@ Template.api.ejsonParse = {
Template.api.ejsonStringify = {
id: "ejson_stringify",
name: "EJSON.stringify(val)",
name: "EJSON.stringify(val, [options])",
locus: "Anywhere",
args: [ {name: "val", type: "EJSON-compatible value", descr: "A value to stringify."} ],
options: [
{name: "indent",
type: "Boolean, Integer, or String",
descr: "Indents objects and arrays for easy readability. When `true`, indents by 2 spaces; when an integer, indents by that number of spaces; and when a string, uses the string as the indentation pattern."},
{name: "canonical",
type: "Boolean",
descr: "When `true`, stringifies keys in an object in sorted order."}
],
descr: ["Serialize a value to a string.\n\nFor EJSON values, the serialization " +
"fully represents the value. For non-EJSON values, serializes the " +
"same way as `JSON.stringify`."]
@@ -116,10 +123,15 @@ Template.api.ejsonToJSONValue = {
Template.api.ejsonEquals = {
id: "ejson_equals",
name: "EJSON.equals(a, b)", //doc options?
name: "EJSON.equals(a, b, [options])",
locus: "Anywhere",
args: [ {name: "a", type: "EJSON-compatible object"},
{name: "b", type: "EJSON-compatible object"} ],
options: [
{name: "keyOrderSensitive",
type: "Boolean",
descr: "Compare in key sensitive order, if supported by the JavaScript implementation. For example, `{a: 1, b: 2}` is equal to `{b: 2, a: 1}` only when `keyOrderSensitive` is `false`. The default is `false`."}
],
descr: ["Return true if `a` and `b` are equal to each other. Return false otherwise." +
" Uses the `equals` method on `a` if present, otherwise performs a deep comparison."]
},
@@ -482,7 +494,7 @@ Template.api.meteor_collection = {
options: [
{name: "connection",
type: "Object",
descr: "The Meteor connection that will manage this collection. Uses the default connection if not specified. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection."
descr: "The server connection that will manage this collection. Uses the default connection if not specified. Pass the return value of calling [`DDP.connect`](#ddp_connect) to specify a different server. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection."
},
{name: "idGeneration",
type: "String",
@@ -585,7 +597,7 @@ Template.api.update = {
id: "update",
name: "<em>collection</em>.update(selector, modifier, [options], [callback])",
locus: "Anywhere",
descr: ["Modify one or more documents in the collection"],
descr: ["Modify one or more documents in the collection. Returns the number of affected documents."],
args: [
{name: "selector",
type: "Mongo selector, or object id",
@@ -597,7 +609,37 @@ Template.api.update = {
descr: "Specifies how to modify the documents"},
{name: "callback",
type: "Function",
descr: "Optional. If present, called with an error object as its argument."}
descr: "Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second."}
],
options: [
{name: "multi",
type: "Boolean",
descr: "True to modify all matching documents; false to only modify one of the matching documents (the default)."},
{name: "upsert",
type: "Boolean",
descr: "True to insert a document if no matching documents are found."}
]
};
Template.api.upsert = {
id: "upsert",
name: "<em>collection</em>.upsert(selector, modifier, [options], [callback])",
locus: "Anywhere",
descr: ["Modify one or more documents in the collection, or insert one if no matching documents were found. " +
"Returns an object with keys `numberAffected` (the number of documents modified) " +
" and `insertedId` (the unique _id of the document that was inserted, if any)."],
args: [
{name: "selector",
type: "Mongo selector, or object id",
type_link: "selectors",
descr: "Specifies which documents to modify"},
{name: "modifier",
type: "Mongo modifier",
type_link: "modifiers",
descr: "Specifies how to modify the documents"},
{name: "callback",
type: "Function",
descr: "Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second."}
],
options: [
{name: "multi",
@@ -606,6 +648,7 @@ Template.api.update = {
]
};
Template.api.remove = {
id: "remove",
name: "<em>collection</em>.remove(selector, [callback])",
@@ -675,25 +718,31 @@ Template.api.cursor_fetch = {
Template.api.cursor_foreach = {
id: "foreach",
name: "<em>cursor</em>.forEach(callback)",
name: "<em>cursor</em>.forEach(callback, [thisArg])",
locus: "Anywhere",
descr: ["Call `callback` once for each matching document, sequentially and synchronously."],
args: [
{name: "callback",
type: "Function",
descr: "Function to call."}
descr: "Function to call. It will be called with three arguments: the document, a 0-based index, and <em>cursor</em> itself."},
{name: "thisArg",
type: "Any",
descr: "An object which will be the value of `this` inside `callback`."}
]
};
Template.api.cursor_map = {
id: "map",
name: "<em>cursor</em>.map(callback)",
name: "<em>cursor</em>.map(callback, [thisArg])",
locus: "Anywhere",
descr: ["Map callback over all matching documents. Returns an Array."],
args: [
{name: "callback",
type: "Function",
descr: "Function to call."}
descr: "Function to call. It will be called with three arguments: the document, a 0-based index, and <em>cursor</em> itself."},
{name: "thisArg",
type: "Any",
descr: "An object which will be the value of `this` inside `callback`."}
]
};
@@ -1026,6 +1075,20 @@ Template.api.logout = {
]
};
Template.api.logoutOtherClients = {
id: "meteor_logoutotherclients",
name: "Meteor.logoutOtherClients([callback])",
locus: "Client",
descr: ["Log out other clients logged in as the current user, but does not log out the client that calls this function."],
args: [
{
name: "callback",
type: "Function",
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
}
]
};
Template.api.loginWithPassword = {
id: "meteor_loginwithpassword",
@@ -1100,6 +1163,16 @@ Template.api.accounts_config = {
name: "forbidClientAccountCreation",
type: "Boolean",
descr: "Calls to [`createUser`](#accounts_createuser) from the client will be rejected. In addition, if you are using [accounts-ui](#accountsui), the \"Create account\" link will not be available."
},
{
name: "restrictCreationByEmailDomain",
type: "String Or Function",
descr: "If set, only allow new users with an email in the specified domain or if the predicate function returns true. Works with password-based sign-in and external services that expose email addresses (Google, Facebook, GitHub). All existing users still can log in after enabling this option. Example: `Accounts.config({ restrictCreationByEmailDomain: 'school.edu' })`."
},
{
name: "loginExpirationInDays",
type: "Number",
descr: "The number of days from when a user logs in until their token expires and they are logged out. Defaults to 90. Set to `null` to disable login expiration."
}
]
};

View File

@@ -824,9 +824,9 @@ To get started, run
$ meteor bundle myapp.tgz
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.8 and a
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.8.26.) You can then run the application by invoking node, specifying the HTTP
0.10.21.) 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
[MongoHQ](http://mongohq.com).
@@ -913,8 +913,7 @@ quick tips:
an example. Build plugins are fully-fledged Meteor programs in their
own right and have their own namespace, package dependencies, source
files and npm requirements. The old `register_extension` API is
deprecated and should not be used as it will prevent your package
from being cached, slowing down builds.
removed.
* It is possible to create weak dependencies between packages. If
package A has a weak dependency on package B, it means that

View File

@@ -1,8 +1,5 @@
Template.headline.release = function () {
// XXX This is commented out because for now galaxy apps have to be on a
// different Meteor release that has a bug fix.
return "0.6.5";
// return Meteor.release || "(checkout)";
return Meteor.release || "(checkout)";
};
@@ -155,6 +152,7 @@ var toc = [
{instance: "collection", name: "findOne"},
{instance: "collection", name: "insert"},
{instance: "collection", name: "update"},
{instance: "collection", name: "upsert"},
{instance: "collection", name: "remove"},
{instance: "collection", name: "allow"},
{instance: "collection", name: "deny"}
@@ -191,6 +189,7 @@ var toc = [
"Meteor.users",
"Meteor.loggingIn",
"Meteor.logout",
"Meteor.logoutOtherClients",
"Meteor.loginWithPassword",
{name: "Meteor.loginWithFacebook", id: "meteor_loginwithexternalservice"},
{name: "Meteor.loginWithGithub", id: "meteor_loginwithexternalservice"},
@@ -335,6 +334,7 @@ var toc = [
"audit-argument-checks",
"backbone",
"bootstrap",
"browser-policy",
"coffeescript",
"d3",
"force-ssl",

View File

@@ -22,6 +22,7 @@ and removed with:
{{> pkg_audit_argument_checks}}
{{> pkg_backbone}}
{{> pkg_bootstrap}}
{{> pkg_browser_policy}}
{{> pkg_coffeescript}}
{{> pkg_d3}}
{{> pkg_force_ssl}}

View File

@@ -44,7 +44,8 @@ You can also disable the application cache for specific browsers:
});
The supported browsers that can be enabled or disabled are `android`,
`chrome`, `chromium`, `firefox`, `ie`, `mobileSafari` and `safari`.
`chrome`, `chromium`, `chromeMobileIOS`, `firefox`, `ie`,
`mobileSafari` and `safari`.
Browsers limit the amount of data they will put in the application
cache, which can vary due to factors such as how much disk space is

View File

@@ -0,0 +1,165 @@
<template name="pkg_browser_policy">
{{#better_markdown}}
## `browser-policy`
The `browser-policy` package lets you set security-related policies that will be
enforced by newer browsers. These policies help you prevent and mitigate common
attacks like cross-site scripting and clickjacking.
When you add `browser-policy` to your app, you get default configurations for
the HTTP headers X-Frame-Options and Content-Security-Policy. X-Frame-Options
tells the browser which websites are allowed to frame your app. You should only
let trusted websites frame your app, because malicious sites could harm your
users with <a href="https://www.owasp.org/index.php/Clickjacking">clickjacking
attacks</a>.
<a href="https://developer.mozilla.org/en-US/docs/Security/CSP/Introducing_Content_Security_Policy">Content-Security-Policy</a>
tells the browser where your app can load content from, which encourages safe
practices and mitigates the damage of a cross-site-scripting attack.
`browser-policy` also provides functions for you to configure these policies if
the defaults are not suitable.
If you only want to use Content-Security-Policy or X-Frame-Options but not both,
you can add the individual packages `browser-policy-content` or
`browser-policy-framing` instead of `browser-policy`.
For most apps, we recommend that you take the following steps:
* Add `browser-policy` to your app to enable a starter policy. With this starter
policy, your app's client code will be able to load content (images, scripts,
fonts, etc.) only from its own origin, except that XMLHttpRequests and WebSocket
connections can go to any origin. Further, your app's client code will not be
able to use functions such as `eval()` that convert strings to code. Users'
browsers will only let your app be framed by web pages on the same origin as
your app.
* You can use the functions described below to customize the policies. If your
app does not need any inline Javascript such as inline `<script>` tags, we
recommend that you modify the policy by calling
`BrowserPolicy.content.disallowInlineScripts()` in server code. This will result
in one extra round trip when your app is loaded, but will help prevent
cross-site scripting attacks by disabling all scripts except those loaded from a
`script src` attribute.
Meteor determines the browser policy when the server starts up, so you should
call `BrowserPolicy` functions in top-level application code or in
`Meteor.startup`.
#### Frame options
By default, if you add `browser-policy` or `browser-policy-framing`, only web
pages on the same origin as your app are allowed to frame your app. You can use
the following functions to modify this policy.
<dl class="callbacks">
{{#dtdd "BrowserPolicy.framing.disallow()"}}
Your app will never render inside a frame or iframe.
{{/dtdd}}
{{#dtdd "BrowserPolicy.framing.restrictToOrigin(origin)"}}
Your app will only render inside frames loaded by `origin`. You can only call
this function once with a single origin, and cannot use wildcards or specify
multiple origins that are allowed to frame your app. (This is a limitation of
the X-Frame-Options header.) Example values of `origin` include
"http://example.com" and "https://foo.example.com".
{{#warning}}
This value of the X-Frame-Options header is not yet supported in Chrome or
Safari and will be ignored in those browsers.
{{/warning}}
{{/dtdd}}
{{#dtdd "BrowserPolicy.framing.allowAll()"}}
This unsets the X-Frame-Options header, so that your app can be framed by
any webpage.
{{/dtdd}}
</dl>
#### Content options
You can use the functions in this section to control how different types of
content can be loaded on your site.
You can use the following functions to adjust policies on where Javascript and
CSS can be run:
<dl class="callbacks">
{{#dtdd "BrowserPolicy.content.allowInlineScripts()"}}
Allows inline `<script>` tags, `javascript:` URLs, and inline event handlers.
The default policy already allows inline scripts.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.disallowInlineScripts()"}}
Disallows inline Javascript. Calling this function results in an extra
round-trip on page load to retrieve Meteor runtime configuration that is usually
part of an inline script tag.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.allowEval()"}}
Allows the creation of Javascript code from strings using function such as `eval()`.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.disallowEval()"}}
Disallows eval and related functions. The default policy already disallows eval.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.allowInlineStyles()"}}
Allows inline style tags and style attributes. The default policy already allows
inline styles.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.disallowInlineStyles()"}}
Disallows inline CSS.
{{/dtdd}}
</dl>
Finally, you can configure a whitelist of allowed requests that various types of
content can make. The following functions are defined for the content types
script, object, image, media, font, and connect.
<dl class="callbacks">
{{#dtdd "BrowserPolicy.content.allow&lt;ContentType&gt;Origin(origin)"}}
Allows this type of content to be loaded from the given origin. `origin` is a
string and can include an optional scheme (such as `http` or `https`), an
optional wildcard at the beginning, and an optional port which can be a
wildcard. Examples include `example.com`, `https://*.example.com`, and
`example.com:*`. You can call these functions multiple times with different
origins to specify a whitelist of allowed origins.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.allow&lt;ContentType&gt;DataUrl()"}}
Allows this type of content to be loaded from a `data:` URL.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.allow&lt;ContentType&gt;SameOrigin()"}}
Allows this type of content to be loaded from the same origin as your app.
{{/dtdd}}
{{#dtdd "BrowserPolicy.content.disallow&lt;ContentType&gt;()"}}
Disallows this type of content on your app.
{{/dtdd}}
</dl>
You can also set policies for all these types of content at once, using these
functions:
* `BrowserPolicy.content.allowSameOriginForAll()`,
* `BrowserPolicy.content.allowDataUrlForAll()`,
* `BrowserPolicy.content.allowOriginForAll(origin)`
* `BrowserPolicy.content.disallowAll()`
For example, if you want to allow the
origin `https://foo.com` for all types of content but you want to disable
`<object>` tags, you can call
`BrowserPolicy.content.allowOriginForAll("https://foo.com")` followed by
`BrowserPolicy.content.disallowObject()`.
Other examples of using the `BrowserPolicy.content` API:
* `BrowserPolicy.content.disallowFont()` causes the browser to disallow all
`<font>` tags.
* `BrowserPolicy.content.allowImageOrigin("https://example.com")`
allows images to have their `src` attributes point to images served from
`https://example.com`.
* `BrowserPolicy.content.allowConnectOrigin("https://example.com")` allows XMLHttpRequest
and WebSocket connections to `https://example.com`.
{{/better_markdown}}
</template>

View File

@@ -3,8 +3,11 @@
## `random`
The `random` package provides several functions for generating random
numbers. It uses a Meteor-provided random number generator that does not depend
on the browser's facilities.
numbers. It uses a cryptographically strong pseudorandom number generator when
possible, but falls back to a weaker random number generator when
cryptographically strong randomness is not available (on older browsers or on
servers that don't have enough entropy to seed the cryptographically strong
generator).
<dl class="callbacks">
{{#dtdd "Random.id()"}}
@@ -25,10 +28,5 @@ Returns a random string of `n` hexadecimal digits.
{{/dtdd}}
</dl>
{{#note}}
In the current implementation, random values do not come from a
cryptographically strong pseudorandom number generator. Future releases will
improve this, particularly on the server.
{{/note}}
{{/better_markdown}}
</template>

View File

@@ -0,0 +1,5 @@
// While galaxy apps are on their own special meteor releases, override
// Meteor.release here.
if (Meteor.isClient) {
Meteor.release = Meteor.release ? "0.6.6.2" : undefined;
}

View File

@@ -7,4 +7,3 @@ standard-app-packages
autopublish
insecure
preserve-inputs
random

View File

@@ -1 +1 @@
0.6.5
0.6.6.2

View File

@@ -1 +1 @@
0.6.5
0.6.6.2

View File

@@ -3,13 +3,16 @@
Meteor.subscribe("directory");
Meteor.subscribe("parties");
// If no party selected, select one.
// If no party selected, or if the selected party was deleted, select one.
Meteor.startup(function () {
Deps.autorun(function () {
if (! Session.get("selected")) {
var selected = Session.get("selected");
if (! selected || ! Parties.findOne(selected)) {
var party = Parties.findOne();
if (party)
Session.set("selected", party._id);
else
Session.set("selected", null);
}
});
});
@@ -213,19 +216,17 @@ Template.createDialog.events({
var coords = Session.get("createCoords");
if (title.length && description.length) {
Meteor.call('createParty', {
var id = createParty({
title: title,
description: description,
x: coords.x,
y: coords.y,
public: public
}, function (error, party) {
if (! error) {
Session.set("selected", party);
if (! public && Meteor.users.find().count() > 1)
openInviteDialog();
}
});
Session.set("selected", id);
if (! public && Meteor.users.find().count() > 1)
openInviteDialog();
Session.set("showCreateDialog", false);
} else {
Session.set("createError",

View File

@@ -52,6 +52,12 @@ var Coordinate = Match.Where(function (x) {
return x >= 0 && x <= 1;
});
createParty = function (options) {
var id = Random.id();
Meteor.call('createParty', _.extend({ _id: id }, options));
return id;
};
Meteor.methods({
// options should include: title, description, x, y, public
createParty: function (options) {
@@ -60,7 +66,8 @@ Meteor.methods({
description: NonEmptyString,
x: Coordinate,
y: Coordinate,
public: Match.Optional(Boolean)
public: Match.Optional(Boolean),
_id: Match.Optional(NonEmptyString)
});
if (options.title.length > 100)
@@ -70,7 +77,9 @@ Meteor.methods({
if (! this.userId)
throw new Meteor.Error(403, "You must be logged in");
return Parties.insert({
var id = options._id || Random.id();
Parties.insert({
_id: id,
owner: this.userId,
x: options.x,
y: options.y,
@@ -80,6 +89,7 @@ Meteor.methods({
invited: [],
rsvps: []
});
return id;
},
invite: function (partyId, userId) {

View File

@@ -1 +1 @@
0.6.5
0.6.6.2

View File

@@ -7,4 +7,3 @@ standard-app-packages
insecure
jquery
preserve-inputs
random

View File

@@ -1 +1 @@
0.6.5
0.6.6.2

View File

@@ -132,9 +132,8 @@ Template.scratchpad.events({
'click button, keyup input': function (evt) {
var textbox = $('#scratchpad input');
// if we clicked the button or hit enter
if (evt.type === "click" ||
(evt.type === "keyup" && evt.which === 13)) {
if ((evt.type === "click" || (evt.type === "keyup" && evt.which === 13))
&& textbox.val()) {
var word_id = Words.insert({player_id: Session.get('player_id'),
game_id: game() && game()._id,
word: textbox.val().toUpperCase(),

View File

@@ -102,9 +102,12 @@ Meteor.methods({
var word = Words.findOne(word_id);
var game = Games.findOne(word.game_id);
// client and server can both check: must be at least three chars
// long, not already used, and possible to make on the board.
if (word.length < 3
// client and server can both check that the game has time remaining, and
// that the word is at least three chars, isn't already used, and is
// possible to make on the board.
if (game.clock === 0
|| !word.word
|| word.word.length < 3
|| Words.find({game_id: word.game_id, word: word.word}).count() > 1
|| paths_for_word(game.board, word.word).length === 0) {
Words.update(word._id, {$set: {score: 0, state: 'bad'}});
@@ -127,8 +130,8 @@ Meteor.methods({
if (Meteor.isServer) {
DICTIONARY = {};
_.each(Assets.getText("enable2k.txt").split("\n"), function (line) {
// Skip comment lines
if (line.indexOf("//") !== 0) {
// Skip blanks and comment lines
if (line && line.indexOf("//") !== 0) {
DICTIONARY[line] = true;
}
});

2
meteor
View File

@@ -1,6 +1,6 @@
#!/bin/bash
BUNDLE_VERSION=0.3.21
BUNDLE_VERSION=0.3.23
# OS Check. Put here because here is where we download the precompiled
# bundles that are arch specific.

View File

@@ -70,6 +70,8 @@ Accounts.callLoginMethod = function (options) {
if (!options[f])
options[f] = function () {};
});
// make sure we only call the user's callback once.
var onceUserCallback = _.once(options.userCallback);
var reconnected = false;
@@ -91,20 +93,37 @@ Accounts.callLoginMethod = function (options) {
if (err || !result || !result.token) {
Meteor.connection.onReconnect = null;
} else {
Meteor.connection.onReconnect = function() {
Meteor.connection.onReconnect = function () {
reconnected = true;
Accounts.callLoginMethod({
methodArguments: [{resume: result.token}],
// Reconnect quiescence ensures that the user doesn't see an
// intermediate state before the login method finishes. So we don't
// need to show a logging-in animation.
_suppressLoggingIn: true,
userCallback: function (error) {
if (error) {
makeClientLoggedOut();
}
options.userCallback(error);
}});
// If our token was updated in storage, use the latest one.
var storedToken = storedLoginToken();
if (storedToken) {
result = {
token: storedToken,
tokenExpires: storedLoginTokenExpires()
};
}
if (! result.tokenExpires)
result.tokenExpires = Accounts._tokenExpiration(new Date());
if (Accounts._tokenExpiresSoon(result.tokenExpires)) {
makeClientLoggedOut();
} else {
Accounts.callLoginMethod({
methodArguments: [{resume: result.token}],
// Reconnect quiescence ensures that the user doesn't see an
// intermediate state before the login method finishes. So we don't
// need to show a logging-in animation.
_suppressLoggingIn: true,
userCallback: function (error) {
if (error) {
makeClientLoggedOut();
}
// Possibly a weird callback to call, but better than nothing if
// there is a reconnect between "login result received" and "data
// ready".
onceUserCallback(error);
}});
}
};
}
};
@@ -128,19 +147,19 @@ Accounts.callLoginMethod = function (options) {
if (error || !result) {
error = error || new Error(
"No result from call to " + options.methodName);
options.userCallback(error);
onceUserCallback(error);
return;
}
try {
options.validateResult(result);
} catch (e) {
options.userCallback(e);
onceUserCallback(e);
return;
}
// Make the client logged in. (The user data should already be loaded!)
makeClientLoggedIn(result.id, result.token);
options.userCallback();
makeClientLoggedIn(result.id, result.token, result.tokenExpires);
onceUserCallback();
};
if (!options._suppressLoggingIn)
@@ -158,8 +177,8 @@ makeClientLoggedOut = function() {
Meteor.connection.onReconnect = null;
};
makeClientLoggedIn = function(userId, token) {
storeLoginToken(userId, token);
makeClientLoggedIn = function(userId, token, tokenExpires) {
storeLoginToken(userId, token, tokenExpires);
Meteor.connection.setUserId(userId);
};
@@ -174,6 +193,26 @@ Meteor.logout = function (callback) {
});
};
Meteor.logoutOtherClients = function (callback) {
// Our connection is going to be closed, but we don't want to call the
// onReconnect handler until the result comes back for this method, because
// the token will have been deleted on the server. Instead, wait until we get
// a new token and call the reconnect handler with that.
// XXX this is messy.
// XXX what if login gets called before the callback runs?
var origOnReconnect = Meteor.connection.onReconnect;
var userId = Meteor.userId();
Meteor.connection.onReconnect = null;
Meteor.apply('logoutOtherClients', [], { wait: true },
function (error, result) {
Meteor.connection.onReconnect = origOnReconnect;
if (! error)
storeLoginToken(userId, result.token, result.tokenExpires);
Meteor.connection.onReconnect();
callback && callback(error);
});
};
///
/// LOGIN SERVICES
///

View File

@@ -4,6 +4,18 @@ Accounts = {};
// and accounts-ui-unstyled.
Accounts._options = {};
// how long (in days) until a login token expires
var DEFAULT_LOGIN_EXPIRATION_DAYS = 90;
// Clients don't try to auto-login with a token that is going to expire within
// .1 * DEFAULT_LOGIN_EXPIRATION_DAYS, capped at MIN_TOKEN_LIFETIME_CAP_SECS.
// Tries to avoid abrupt disconnects from expiring tokens.
var MIN_TOKEN_LIFETIME_CAP_SECS = 3600; // one hour
// how often (in milliseconds) we check for expired tokens
EXPIRE_TOKENS_INTERVAL_MS = 600 * 1000; // 10 minutes
// how long we wait before logging out clients when Meteor.logoutOtherClients is
// called
CONNECTION_CLOSE_DELAY_MS = 10 * 1000;
// Set up config for the accounts system. Call this on both the client
// and the server.
//
@@ -19,10 +31,31 @@ Accounts._options = {};
// client signups.
// - forbidClientAccountCreation {Boolean}
// Do not allow clients to create accounts directly.
// - restrictCreationByEmailDomain {Function or String}
// Require created users to have an email matching the function or
// having the string as domain.
// - loginExpirationInDays {Number}
// Number of days since login until a user is logged out (login token
// expires).
//
Accounts.config = function(options) {
// We don't want users to accidentally only call Accounts.config on the
// client, where some of the options will have partial effects (eg removing
// the "create account" button from accounts-ui if forbidClientAccountCreation
// is set, or redirecting Google login to a specific-domain page) without
// having their full effects.
if (Meteor.isServer) {
__meteor_runtime_config__.accountsConfigCalled = true;
} else if (!__meteor_runtime_config__.accountsConfigCalled) {
// XXX would be nice to "crash" the client and replace the UI with an error
// message, but there's no trivial way to do this.
Meteor._debug("Accounts.config was called on the client but not on the " +
"server; some configuration options may not take effect.");
}
// validate option keys
var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation"];
var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation",
"restrictCreationByEmailDomain", "loginExpirationInDays"];
_.each(_.keys(options), function (key) {
if (!_.contains(VALID_KEYS, key)) {
throw new Error("Accounts.config: Invalid key: " + key);
@@ -39,6 +72,11 @@ Accounts.config = function(options) {
}
}
});
// If the user set loginExpirationInDays to null, then we need to clear the
// timer that periodically expires tokens.
if (Meteor.isServer)
maybeStopExpireTokensInterval();
};
// Users table. Don't use the normal autopublish, since we want to hide
@@ -66,3 +104,21 @@ Accounts.LoginCancelledError.numericError = 0x8acdc2f;
Accounts.LoginCancelledError.prototype = new Error();
Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError';
getTokenLifetimeMs = function () {
return (Accounts._options.loginExpirationInDays ||
DEFAULT_LOGIN_EXPIRATION_DAYS) * 24 * 60 * 60 * 1000;
};
Accounts._tokenExpiration = function (when) {
// We pass when through the Date constructor for backwards compatibility;
// `when` used to be a number.
return new Date((new Date(when)).getTime() + getTokenLifetimeMs());
};
Accounts._tokenExpiresSoon = function (when) {
var minLifetimeMs = .1 * getTokenLifetimeMs();
var minLifetimeCapMs = MIN_TOKEN_LIFETIME_CAP_SECS * 1000;
if (minLifetimeMs > minLifetimeCapMs)
minLifetimeMs = minLifetimeCapMs;
return new Date() > (new Date(when) - minLifetimeMs);
};

View File

@@ -34,7 +34,11 @@ Meteor.user = function () {
// @param handler {Function} A function that receives an options object
// (as passed as an argument to the `login` method) and returns one of:
// - `undefined`, meaning don't handle;
// - {id: userId, token: *}, if the user logged in successfully.
// - {id: userId, token: *, tokenExpires: *}, if the user logged in
// successfully. tokenExpires is optional and intends to provide a hint to the
// client as to when the token will expire. If not provided, the client will
// call Accounts._tokenExpiration, passing it the date that it received the
// token.
// - throw an error, if the user failed to log in.
//
Accounts.registerLoginHandler = function(handler) {
@@ -74,15 +78,64 @@ Meteor.methods({
var result = tryAllLoginHandlers(options);
if (result !== null) {
this.setUserId(result.id);
this._sessionData.loginToken = result.token;
this._setLoginToken(result.token);
}
return result;
},
logout: function() {
if (this._sessionData.loginToken && this.userId)
removeLoginToken(this.userId, this._sessionData.loginToken);
var token = this._getLoginToken();
this._setLoginToken(null);
if (token && this.userId)
removeLoginToken(this.userId, token);
this.setUserId(null);
},
// Delete all the current user's tokens and close all open connections logged
// in as this user. Returns a fresh new login token that this client can
// use. Tests set Accounts._noConnectionCloseDelayForTest to delete tokens
// immediately instead of using a delay.
//
// @returns {Object} Object with token and tokenExpires keys.
logoutOtherClients: function () {
var self = this;
var user = Meteor.users.findOne(self.userId, {
fields: {
"services.resume.loginTokens": true
}
});
if (user) {
// Save the current tokens in the database to be deleted in
// CONNECTION_CLOSE_DELAY_MS ms. This gives other connections in the
// caller's browser time to find the fresh token in localStorage. We save
// the tokens in the database in case we crash before actually deleting
// them.
var tokens = user.services.resume.loginTokens;
var newToken = Accounts._generateStampedLoginToken();
var userId = self.userId;
Meteor.users.update(self.userId, {
$set: {
"services.resume.loginTokensToDelete": tokens,
"services.resume.haveLoginTokensToDelete": true
},
$push: { "services.resume.loginTokens": newToken }
});
Meteor.setTimeout(function () {
// The observe on Meteor.users will take care of closing the connections
// associated with `tokens`.
deleteSavedTokens(userId, tokens);
}, Accounts._noConnectionCloseDelayForTest ? 0 :
CONNECTION_CLOSE_DELAY_MS);
// We do not set the login token on this connection, but instead the
// observe closes the connection and the client will reconnect with the
// new token.
return {
token: newToken.token,
tokenExpires: Accounts._tokenExpiration(newToken.when)
};
} else {
throw new Error("You are not logged in.");
}
}
});
@@ -100,11 +153,23 @@ Accounts.registerLoginHandler(function(options) {
var user = Meteor.users.findOne({
"services.resume.loginTokens.token": ""+options.resume
});
if (!user)
throw new Meteor.Error(403, "Couldn't find login token");
if (!user) {
throw new Meteor.Error(403, "You've been logged out by the server. " +
"Please login again.");
}
var token = _.find(user.services.resume.loginTokens, function (token) {
return token.token === options.resume;
});
var tokenExpires = Accounts._tokenExpiration(token.when);
if (new Date() >= tokenExpires)
throw new Meteor.Error(403, "Your session has expired. Please login again.");
return {
token: options.resume,
tokenExpires: tokenExpires,
id: user._id
};
});
@@ -115,7 +180,9 @@ Accounts._generateStampedLoginToken = function () {
return {token: Random.id(), when: (new Date)};
};
removeLoginToken = function (userId, loginToken) {
// Deletes the given loginToken from the database. This will cause all
// connections associated with the token to be closed.
var removeLoginToken = function (userId, loginToken) {
Meteor.users.update(userId, {
$pull: {
"services.resume.loginTokens": { "token": loginToken }
@@ -123,6 +190,64 @@ removeLoginToken = function (userId, loginToken) {
});
};
///
/// TOKEN EXPIRATION
///
var expireTokenInterval;
// Deletes expired tokens from the database and closes all open connections
// associated with these tokens.
//
// Exported for tests. Also, the arguments are only used by
// tests. oldestValidDate is simulate expiring tokens without waiting
// for them to actually expire. userId is used by tests to only expire
// tokens for the test user.
var expireTokens = Accounts._expireTokens = function (oldestValidDate, userId) {
var tokenLifetimeMs = getTokenLifetimeMs();
// when calling from a test with extra arguments, you must specify both!
if ((oldestValidDate && !userId) || (!oldestValidDate && userId)) {
throw new Error("Bad test. Must specify both oldestValidDate and userId.");
}
oldestValidDate = oldestValidDate ||
(new Date(new Date() - tokenLifetimeMs));
var userFilter = userId ? {_id: userId} : {};
// Backwards compatible with older versions of meteor that stored login token
// timestamps as numbers.
Meteor.users.update(_.extend(userFilter, {
$or: [
{ "services.resume.loginTokens.when": { $lt: oldestValidDate } },
{ "services.resume.loginTokens.when": { $lt: +oldestValidDate } }
]
}), {
$pull: {
"services.resume.loginTokens": {
$or: [
{ when: { $lt: oldestValidDate } },
{ when: { $lt: +oldestValidDate } }
]
}
}
}, { multi: true });
// The observe on Meteor.users will take care of closing connections for
// expired tokens.
};
maybeStopExpireTokensInterval = function () {
if (_.has(Accounts._options, "loginExpirationInDays") &&
Accounts._options.loginExpirationInDays === null &&
expireTokenInterval) {
Meteor.clearInterval(expireTokenInterval);
expireTokenInterval = null;
}
};
expireTokenInterval = Meteor.setInterval(expireTokens,
EXPIRE_TOKENS_INTERVAL_MS);
///
/// CREATE USER HOOKS
@@ -164,6 +289,7 @@ Accounts.insertUserDoc = function (options, user) {
if (options.generateLoginToken) {
var stampedToken = Accounts._generateStampedLoginToken();
result.token = stampedToken.token;
result.tokenExpires = Accounts._tokenExpiration(stampedToken.when);
Meteor._ensure(user, 'services', 'resume');
if (_.has(user.services.resume, 'loginTokens'))
user.services.resume.loginTokens.push(stampedToken);
@@ -213,6 +339,49 @@ Accounts.validateNewUser = function (func) {
validateNewUserHooks.push(func);
};
// XXX Find a better place for this utility function
// Like Perl's quotemeta: quotes all regexp metacharacters. See
// https://github.com/substack/quotemeta/blob/master/index.js
var quotemeta = function (str) {
return String(str).replace(/(\W)/g, '\\$1');
};
// Helper function: returns false if email does not match company domain from
// the configuration.
var testEmailDomain = function (email) {
var domain = Accounts._options.restrictCreationByEmailDomain;
return !domain ||
(_.isFunction(domain) && domain(email)) ||
(_.isString(domain) &&
(new RegExp('@' + quotemeta(domain) + '$', 'i')).test(email));
};
// Validate new user's email or Google/Facebook/GitHub account's email
Accounts.validateNewUser(function (user) {
var domain = Accounts._options.restrictCreationByEmailDomain;
if (!domain)
return true;
var emailIsGood = false;
if (!_.isEmpty(user.emails)) {
emailIsGood = _.any(user.emails, function (email) {
return testEmailDomain(email.address);
});
} else if (!_.isEmpty(user.services)) {
// Find any email of any service and check it
emailIsGood = _.any(user.services, function (service) {
return service.email && testEmailDomain(service.email);
});
}
if (emailIsGood)
return true;
if (_.isString(domain))
throw new Meteor.Error(403, "@" + domain + " email required");
else
throw new Meteor.Error(403, "Email doesn't match the criteria.");
});
///
/// MANAGING USER OBJECTS
@@ -280,7 +449,11 @@ Accounts.updateOrCreateUserFromExternalService = function(
user._id,
{$set: setAttrs,
$push: {'services.resume.loginTokens': stampedToken}});
return {token: stampedToken.token, id: user._id};
return {
token: stampedToken.token,
id: user._id,
tokenExpires: Accounts._tokenExpiration(stampedToken.when)
};
} else {
// Create a new user with the service data. Pass other options through to
// insertUserDoc.
@@ -426,3 +599,72 @@ Meteor.users._ensureIndex('username', {unique: 1, sparse: 1});
Meteor.users._ensureIndex('emails.address', {unique: 1, sparse: 1});
Meteor.users._ensureIndex('services.resume.loginTokens.token',
{unique: 1, sparse: 1});
// For taking care of logoutOtherClients calls that crashed before the tokens
// were deleted.
Meteor.users._ensureIndex('services.resume.haveLoginTokensToDelete',
{ sparse: 1 });
// For expiring login tokens
Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: 1 });
///
/// CLEAN UP FOR `logoutOtherClients`
///
var deleteSavedTokens = function (userId, tokensToDelete) {
if (tokensToDelete) {
Meteor.users.update(userId, {
$unset: {
"services.resume.haveLoginTokensToDelete": 1,
"services.resume.loginTokensToDelete": 1
},
$pullAll: {
"services.resume.loginTokens": tokensToDelete
}
});
}
};
Meteor.startup(function () {
// If we find users who have saved tokens to delete on startup, delete them
// now. It's possible that the server could have crashed and come back up
// before new tokens are found in localStorage, but this shouldn't happen very
// often. We shouldn't put a delay here because that would give a lot of power
// to an attacker with a stolen login token and the ability to crash the
// server.
var users = Meteor.users.find({
"services.resume.haveLoginTokensToDelete": true
}, {
"services.resume.loginTokensToDelete": 1
});
users.forEach(function (user) {
deleteSavedTokens(user._id, user.services.resume.loginTokensToDelete);
});
});
///
/// LOGGING OUT DELETED USERS
///
var closeTokensForUser = function (userTokens) {
Meteor.server._closeAllForTokens(_.map(userTokens, function (token) {
return token.token;
}));
};
Meteor.users.find({}, { fields: { "services.resume": 1 }}).observe({
changed: function (newUser, oldUser) {
var removedTokens = [];
if (newUser.services && newUser.services.resume &&
oldUser.services && oldUser.services.resume) {
removedTokens = _.difference(oldUser.services.resume.loginTokens || [],
newUser.services.resume.loginTokens || []);
} else if (oldUser.services && oldUser.services.resume) {
removedTokens = oldUser.services.resume.loginTokens || [];
}
closeTokensForUser(removedTokens);
},
removed: function (oldUser) {
if (oldUser.services && oldUser.services.resume)
closeTokensForUser(oldUser.services.resume.loginTokens || []);
}
});

View File

@@ -178,3 +178,33 @@ Tinytest.add('accounts - insertUserDoc email', function (test) {
Meteor.users.remove(result.id);
Meteor.users.remove(result3.id);
});
// More token expiration tests are in accounts-password
Tinytest.addAsync('accounts - expire numeric token', function (test, onComplete) {
var userIn = { username: Random.id() };
var result = Accounts.insertUserDoc({ profile: {
name: 'Foo Bar'
} }, userIn);
var date = new Date(new Date() - 5000);
Meteor.users.update(result.id, {
$set: {
"services.resume.loginTokens": [{
token: Random.id(),
when: date
}, {
token: Random.id(),
when: +date
}]
}
});
var observe = Meteor.users.find(result.id).observe({
changed: function (newUser) {
if (newUser.services && newUser.services.resume &&
_.isEmpty(newUser.services.resume.loginTokens)) {
observe.stop();
onComplete();
}
}
});
Accounts._expireTokens(new Date(), result.id);
});

View File

@@ -27,6 +27,7 @@ Accounts._enableAutoLogin = function () {
// Key names to use in localStorage
var loginTokenKey = "Meteor.loginToken";
var loginTokenExpiresKey = "Meteor.loginTokenExpires";
var userIdKey = "Meteor.userId";
// Call this from the top level of the test file for any test that does
@@ -37,9 +38,12 @@ Accounts._isolateLoginTokenForTest = function () {
userIdKey = userIdKey + Random.id();
};
storeLoginToken = function(userId, token) {
storeLoginToken = function(userId, token, tokenExpires) {
Meteor._localStorage.setItem(userIdKey, userId);
Meteor._localStorage.setItem(loginTokenKey, token);
if (! tokenExpires)
tokenExpires = Accounts._tokenExpiration(new Date());
Meteor._localStorage.setItem(loginTokenExpiresKey, tokenExpires);
// to ensure that the localstorage poller doesn't end up trying to
// connect a second time
@@ -49,6 +53,7 @@ storeLoginToken = function(userId, token) {
unstoreLoginToken = function() {
Meteor._localStorage.removeItem(userIdKey);
Meteor._localStorage.removeItem(loginTokenKey);
Meteor._localStorage.removeItem(loginTokenExpiresKey);
// to ensure that the localstorage poller doesn't end up trying to
// connect a second time
@@ -58,14 +63,23 @@ unstoreLoginToken = function() {
// This is private, but it is exported for now because it is used by a
// test in accounts-password.
//
var storedLoginToken = Accounts._storedLoginToken = function() {
storedLoginToken = Accounts._storedLoginToken = function() {
return Meteor._localStorage.getItem(loginTokenKey);
};
storedLoginTokenExpires = function () {
return Meteor._localStorage.getItem(loginTokenExpiresKey);
};
var storedUserId = function() {
return Meteor._localStorage.getItem(userIdKey);
};
var unstoreLoginTokenIfExpiresSoon = function () {
var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey);
if (tokenExpires && Accounts._tokenExpiresSoon(new Date(tokenExpires)))
unstoreLoginToken();
};
///
/// AUTO-LOGIN
@@ -74,6 +88,7 @@ var storedUserId = function() {
if (autoLoginEnabled) {
// Immediately try to log in via local storage, so that any DDP
// messages are sent after we have established our user account
unstoreLoginTokenIfExpiresSoon();
var token = storedLoginToken();
if (token) {
// On startup, optimistically present us as logged in while the

View File

@@ -1,5 +1,5 @@
Package.describe({
summary: "Password support for accounts."
summary: "Password support for accounts"
});
Package.on_use(function(api) {

View File

@@ -98,7 +98,12 @@ Accounts.registerLoginHandler(function (options) {
Meteor.users.update(
userId, {$push: {'services.resume.loginTokens': stampedLoginToken}});
return {token: stampedLoginToken.token, id: userId, HAMK: serialized.HAMK};
return {
token: stampedLoginToken.token,
tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when),
id: userId,
HAMK: serialized.HAMK
};
});
// Handler to login with plaintext password.
@@ -137,7 +142,11 @@ Accounts.registerLoginHandler(function (options) {
Meteor.users.update(
user._id, {$push: {'services.resume.loginTokens': stampedLoginToken}});
return {token: stampedLoginToken.token, id: user._id};
return {
token: stampedLoginToken.token,
tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when),
id: user._id
};
});
@@ -307,21 +316,40 @@ Meteor.methods({resetPassword: function (token, newVerifier) {
var stampedLoginToken = Accounts._generateStampedLoginToken();
// Update the user record by:
// - Changing the password verifier to the new one
// - Replacing all valid login tokens with new ones (changing
// password should invalidate existing sessions).
// - Forgetting about the reset token that was just used
// - Verifying their email, since they got the password reset via email.
Meteor.users.update({_id: user._id, 'emails.address': email}, {
$set: {'services.password.srp': newVerifier,
'services.resume.loginTokens': [stampedLoginToken],
'emails.$.verified': true},
$unset: {'services.password.reset': 1}
});
// 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
// of having a bad token set if things fail.
var oldToken = this._getLoginToken();
this._setLoginToken(null);
try {
// Update the user record by:
// - Changing the password verifier to the new one
// - Replacing all valid login tokens with new ones (changing
// password should invalidate existing sessions).
// - Forgetting about the reset token that was just used
// - Verifying their email, since they got the password reset via email.
Meteor.users.update({_id: user._id, 'emails.address': email}, {
$set: {'services.password.srp': newVerifier,
'services.resume.loginTokens': [stampedLoginToken],
'emails.$.verified': true},
$unset: {'services.password.reset': 1}
});
} catch (err) {
// update failed somehow. reset to old token.
this._setLoginToken(oldToken);
throw err;
}
this._setLoginToken(stampedLoginToken.token);
this.setUserId(user._id);
return {token: stampedLoginToken.token, id: user._id};
return {
token: stampedLoginToken.token,
tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when),
id: user._id
};
}});
///
@@ -408,7 +436,12 @@ Meteor.methods({verifyEmail: function (token) {
$push: {'services.resume.loginTokens': stampedLoginToken}});
this.setUserId(user._id);
return {token: stampedLoginToken.token, id: user._id};
this._setLoginToken(stampedLoginToken.token);
return {
token: stampedLoginToken.token,
tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when),
id: user._id
};
}});
@@ -482,6 +515,7 @@ Meteor.methods({createUser: function (options) {
// client gets logged in as the new user afterwards.
this.setUserId(result.id);
this._setLoginToken(result.token);
return result;
}});
@@ -516,5 +550,5 @@ Accounts.createUser = function (options, callback) {
///
Meteor.users._ensureIndex('emails.validationTokens.token',
{unique: 1, sparse: 1});
Meteor.users._ensureIndex('emails.password.reset.token',
Meteor.users._ensureIndex('services.password.reset.token',
{unique: 1, sparse: 1});

View File

@@ -1,3 +1,5 @@
Accounts._noConnectionCloseDelayForTest = true;
if (Meteor.isClient) (function () {
// XXX note, only one test can do login/logout things at once! for
@@ -17,40 +19,30 @@ if (Meteor.isClient) (function () {
test.equal(Meteor.user().username, someUsername);
});
};
var waitForLoggedOutStep = function (test, expect) {
pollUntil(expect, function () {
return Meteor.userId() === null;
}, 10 * 1000, 100);
};
// declare variable outside the testAsyncMulti, so we can refer to
// them from multiple tests, but initialize them to new values inside
// the test so when we use the 'debug' link in the tests, they get new
// values and the tests don't fail.
var username, username2, username3;
var userId1, userId3;
var email;
var password, password2, password3;
testAsyncMulti("passwords - basic login with password", [
function (test, expect) {
// setup
this.username = Random.id();
this.email = Random.id() + '-intercept@example.com';
this.password = 'password';
testAsyncMulti("passwords - long series", [
function (test, expect) {
username = Random.id();
username2 = Random.id();
username3 = Random.id();
// use -intercept so that we don't print to the console
email = Random.id() + '-intercept@example.com';
password = 'password';
password2 = 'password2';
password3 = 'password3';
},
function (test, expect) {
Accounts.createUser(
{username: username, email: email, password: password},
loggedInAs(username, test, expect));
{username: this.username, email: this.email, password: this.password},
loggedInAs(this.username, test, expect));
},
function (test, expect) {
userId1 = Meteor.userId();
test.notEqual(userId1, null);
test.notEqual(Meteor.userId(), null);
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword(username, password,
loggedInAs(username, test, expect));
Meteor.loginWithPassword(this.username, this.password,
loggedInAs(this.username, test, expect));
},
logoutStep,
// This next step tests reactive contexts which are reactive on
@@ -65,7 +57,7 @@ if (Meteor.isClient) (function () {
});
// At the beginning, we're not logged in.
test.isFalse(loaded);
Meteor.loginWithPassword(username, password, expect(function (error) {
Meteor.loginWithPassword(this.username, this.password, expect(function (error) {
test.equal(error, undefined);
test.notEqual(Meteor.userId(), null);
// By the time of the login callback, the user should be loaded.
@@ -78,18 +70,43 @@ if (Meteor.isClient) (function () {
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword({username: username}, password,
loggedInAs(username, test, expect));
Meteor.loginWithPassword({username: this.username}, this.password,
loggedInAs(this.username, test, expect));
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword(email, password,
loggedInAs(username, test, expect));
Meteor.loginWithPassword(this.email, this.password,
loggedInAs(this.username, test, expect));
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword({email: email}, password,
loggedInAs(username, test, expect));
Meteor.loginWithPassword({email: this.email}, this.password,
loggedInAs(this.username, test, expect));
},
logoutStep
]);
testAsyncMulti("passwords - plain text passwords", [
function (test, expect) {
// setup
this.username = Random.id();
this.email = Random.id() + '-intercept@example.com';
this.password = 'password';
// create user with raw password (no API, need to invoke callLoginMethod
// directly)
Accounts.callLoginMethod({
methodName: 'createUser',
methodArguments: [{username: this.username, password: this.password}],
userCallback: loggedInAs(this.username, test, expect)
});
},
logoutStep,
// check can login normally with this password.
function(test, expect) {
Meteor.loginWithPassword({username: this.username}, this.password,
loggedInAs(this.username, test, expect));
},
logoutStep,
// plain text password. no API for this, have to invoke callLoginMethod
@@ -97,7 +114,7 @@ if (Meteor.isClient) (function () {
function (test, expect) {
Accounts.callLoginMethod({
// wrong password
methodArguments: [{user: {email: email}, password: password2}],
methodArguments: [{user: {username: this.username}, password: 'wrong'}],
userCallback: expect(function (error) {
test.isTrue(error);
test.isFalse(Meteor.user());
@@ -106,138 +123,127 @@ if (Meteor.isClient) (function () {
function (test, expect) {
Accounts.callLoginMethod({
// right password
methodArguments: [{user: {email: email}, password: password}],
userCallback: loggedInAs(username, test, expect)
methodArguments: [{user: {username: this.username},
password: this.password}],
userCallback: loggedInAs(this.username, test, expect)
});
},
logoutStep
]);
testAsyncMulti("passwords - changing passwords", [
function (test, expect) {
// setup
this.username = Random.id();
this.email = Random.id() + '-intercept@example.com';
this.password = 'password';
this.password2 = 'password2';
Accounts.createUser(
{username: this.username, email: this.email, password: this.password},
loggedInAs(this.username, test, expect));
},
// change password with bad old password. we stay logged in.
function (test, expect) {
Accounts.changePassword(password2, password2, expect(function (error) {
var self = this;
Accounts.changePassword('wrong', 'doesntmatter', expect(function (error) {
test.isTrue(error);
test.equal(Meteor.user().username, username);
test.equal(Meteor.user().username, self.username);
}));
},
// change password with good old password.
function (test, expect) {
Accounts.changePassword(password, password2,
loggedInAs(username, test, expect));
Accounts.changePassword(this.password, this.password2,
loggedInAs(this.username, test, expect));
},
logoutStep,
// old password, failed login
function (test, expect) {
Meteor.loginWithPassword(email, password, expect(function (error) {
Meteor.loginWithPassword(this.email, this.password, expect(function (error) {
test.isTrue(error);
test.isFalse(Meteor.user());
}));
},
// new password, success
function (test, expect) {
Meteor.loginWithPassword(email, password2,
loggedInAs(username, test, expect));
Meteor.loginWithPassword(this.email, this.password2,
loggedInAs(this.username, test, expect));
},
logoutStep,
// create user with raw password (no API, need to invoke callLoginMethod
// directly)
logoutStep
]);
testAsyncMulti("passwords - new user hooks", [
function (test, expect) {
Accounts.callLoginMethod({
methodName: 'createUser',
methodArguments: [{username: username2, password: password2}],
userCallback: loggedInAs(username2, test, expect)
});
// setup
this.username = Random.id();
this.email = Random.id() + '-intercept@example.com';
this.password = 'password';
},
logoutStep,
function(test, expect) {
Meteor.loginWithPassword({username: username2}, password2,
loggedInAs(username2, test, expect));
},
logoutStep,
// test Accounts.validateNewUser
function(test, expect) {
Accounts.createUser({username: username3, password: password3,
// should fail the new user validators
profile: {invalid: true}},
expect(function (error) {
test.equal(error.error, 403);
test.equal(
error.reason,
"User validation failed");
}));
Accounts.createUser(
{username: this.username, password: this.password,
// should fail the new user validators
profile: {invalid: true}},
expect(function (error) {
test.equal(error.error, 403);
test.equal(error.reason, "User validation failed");
}));
},
logoutStep,
function(test, expect) {
Accounts.createUser({username: username3, password: password3,
// should fail the new user validator with a special
// exception
profile: {invalidAndThrowException: true}},
expect(function (error) {
test.equal(
error.reason,
"An exception thrown within Accounts.validateNewUser");
}));
Accounts.createUser(
{username: this.username, password: this.password,
// should fail the new user validator with a special
// exception
profile: {invalidAndThrowException: true}},
expect(function (error) {
test.equal(
error.reason,
"An exception thrown within Accounts.validateNewUser");
}));
},
// test Accounts.onCreateUser
function(test, expect) {
Accounts.createUser(
{username: username3, password: password3,
{username: this.username, password: this.password,
testOnCreateUserHook: true},
loggedInAs(username3, test, expect));
loggedInAs(this.username, test, expect));
},
function(test, expect) {
test.equal(Meteor.user().profile.touchedByOnCreateUser, true);
},
logoutStep
]);
testAsyncMulti("passwords - Meteor.user()", [
function (test, expect) {
// setup
this.username = Random.id();
this.password = 'password';
Accounts.createUser(
{username: this.username, password: this.password,
testOnCreateUserHook: true},
loggedInAs(this.username, test, expect));
},
// test Meteor.user(). This test properly belongs in
// accounts-base/accounts_tests.js, but this is where the tests that
// actually log in are.
function(test, expect) {
var self = this;
var clientUser = Meteor.user();
Meteor.call('testMeteorUser', expect(function (err, result) {
test.equal(result._id, clientUser._id);
test.equal(result.username, clientUser.username);
test.equal(result.username, self.username);
test.equal(result.profile.touchedByOnCreateUser, true);
test.equal(err, undefined);
}));
},
// test the default Meteor.users allow rule. This test properly belongs in
// accounts-base/accounts_tests.js, but this is where the tests that
// actually log in are.
function(test, expect) {
userId3 = Meteor.userId();
test.notEqual(userId3, null);
// Can't update fields other than profile.
Meteor.users.update(
userId3, {$set: {disallowed: true, 'profile.updated': 42}},
expect(function (err) {
test.isTrue(err);
test.equal(err.error, 403);
test.isFalse(_.has(Meteor.user(), 'disallowed'));
test.isFalse(_.has(Meteor.user().profile, 'updated'));
}));
},
function(test, expect) {
// Can't update another user.
Meteor.users.update(
userId1, {$set: {'profile.updated': 42}},
expect(function (err) {
test.isTrue(err);
test.equal(err.error, 403);
}));
},
function(test, expect) {
// Can't update using a non-ID selector. (This one is thrown client-side.)
test.throws(function () {
Meteor.users.update(
{username: username3}, {$set: {'profile.updated': 42}});
});
test.isFalse(_.has(Meteor.user().profile, 'updated'));
},
function(test, expect) {
// Can update own profile using ID.
Meteor.users.update(
userId3, {$set: {'profile.updated': 42}},
expect(function (err) {
test.isFalse(err);
test.equal(42, Meteor.user().profile.updated);
}));
},
function(test, expect) {
// Test that even with no published fields, we still have a document.
Meteor.call('clearUsernameAndProfile', expect(function() {
@@ -250,27 +256,221 @@ if (Meteor.isClient) (function () {
function(test, expect) {
var clientUser = Meteor.user();
test.equal(clientUser, null);
test.equal(Meteor.userId(), null);
Meteor.call('testMeteorUser', expect(function (err, result) {
test.equal(err, undefined);
test.equal(result, null);
}));
}
]);
testAsyncMulti("passwords - allow rules", [
// create a second user to have an id for in a later test
function (test, expect) {
this.otherUsername = Random.id();
Accounts.createUser(
{username: this.otherUsername, password: 'dontcare',
testOnCreateUserHook: true},
loggedInAs(this.otherUsername, test, expect));
},
function (test, expect) {
this.otherUserId = Meteor.userId();
},
function (test, expect) {
// real setup
this.username = Random.id();
this.password = 'password';
Accounts.createUser(
{username: this.username, password: this.password,
testOnCreateUserHook: true},
loggedInAs(this.username, test, expect));
},
// test the default Meteor.users allow rule. This test properly belongs in
// accounts-base/accounts_tests.js, but this is where the tests that
// actually log in are.
function(test, expect) {
this.userId = Meteor.userId();
test.notEqual(this.userId, null);
test.notEqual(this.userId, this.otherUserId);
// Can't update fields other than profile.
Meteor.users.update(
this.userId, {$set: {disallowed: true, 'profile.updated': 42}},
expect(function (err) {
test.isTrue(err);
test.equal(err.error, 403);
test.isFalse(_.has(Meteor.user(), 'disallowed'));
test.isFalse(_.has(Meteor.user().profile, 'updated'));
}));
},
function(test, expect) {
// Can't update another user.
Meteor.users.update(
this.otherUserId, {$set: {'profile.updated': 42}},
expect(function (err) {
test.isTrue(err);
test.equal(err.error, 403);
}));
},
function(test, expect) {
// Can't update using a non-ID selector. (This one is thrown client-side.)
test.throws(function () {
Meteor.users.update(
{username: this.username}, {$set: {'profile.updated': 42}});
});
test.isFalse(_.has(Meteor.user().profile, 'updated'));
},
function(test, expect) {
// Can update own profile using ID.
Meteor.users.update(
this.userId, {$set: {'profile.updated': 42}},
expect(function (err) {
test.isFalse(err);
test.equal(42, Meteor.user().profile.updated);
}));
},
logoutStep
]);
testAsyncMulti("passwords - tokens", [
function (test, expect) {
// setup
this.username = Random.id();
this.password = 'password';
Accounts.createUser(
{username: this.username, password: this.password},
loggedInAs(this.username, test, expect));
},
function(test, expect) {
// test logging out invalidates our token
var expectLoginError = expect(function (err) {
test.isTrue(err);
});
Meteor.loginWithPassword(username, password2, function (error) {
test.equal(error, undefined);
test.equal(Meteor.user().username, username);
var token = Accounts._storedLoginToken();
Meteor.logout(function () {
Meteor.loginWithToken(token, expectLoginError);
});
var token = Accounts._storedLoginToken();
test.isTrue(token);
Meteor.logout(function () {
Meteor.loginWithToken(token, expectLoginError);
});
}
},
function(test, expect) {
var self = this;
// Test that login tokens get expired. We should get logged out when a
// token expires, and not be able to log in again with the same token.
var expectNoError = expect(function (err) {
test.isFalse(err);
});
Meteor.loginWithPassword(this.username, this.password, function (error) {
self.token = Accounts._storedLoginToken();
test.isTrue(self.token);
expectNoError(error);
Meteor.call("expireTokens");
});
},
waitForLoggedOutStep,
function (test, expect) {
var token = Accounts._storedLoginToken();
test.isFalse(token);
},
function (test, expect) {
// Test that once expireTokens is finished, we can't login again with our
// previous token.
Meteor.loginWithToken(this.token, expect(function (err, result) {
test.isTrue(err);
test.equal(Meteor.userId(), null);
}));
},
function (test, expect) {
var self = this;
// copied from livedata/client_convenience.js
var ddpUrl = '/';
if (typeof __meteor_runtime_config__ !== "undefined") {
if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL)
ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL;
}
// XXX can we get the url from the existing connection somehow
// instead?
// Test that Meteor.logoutOtherClients logs out a second authenticated
// connection while leaving Meteor.connection logged in.
var token;
var userId;
self.secondConn = DDP.connect(ddpUrl);
var expectLoginError = expect(function (err) {
test.isTrue(err);
});
var expectValidToken = expect(function (err, result) {
test.isFalse(err);
test.isTrue(result);
self.tokenFromLogoutOthers = result.token;
});
var expectSecondConnLoggedIn = expect(function (err, result) {
test.equal(result.token, token);
test.isFalse(err);
// This test will fail if an unrelated reconnect triggers before the
// connection is logged out. In general our tests aren't resilient to
// mid-test reconnects.
self.secondConn.onReconnect = function () {
self.secondConn.call("login", { resume: token }, expectLoginError);
};
Meteor.call("logoutOtherClients", expectValidToken);
});
Meteor.loginWithPassword(this.username, this.password, expect(function (err) {
test.isFalse(err);
token = Accounts._storedLoginToken();
self.beforeLogoutOthersToken = token;
test.isTrue(token);
userId = Meteor.userId();
self.secondConn.call("login", { resume: token },
expectSecondConnLoggedIn);
}));
},
// Test that logoutOtherClients logged out Meteor.connection and that the
// previous token is no longer valid.
waitForLoggedOutStep,
function (test, expect) {
var self = this;
var token = Accounts._storedLoginToken();
test.isFalse(token);
this.secondConn.close();
Meteor.loginWithToken(
self.beforeLogoutOthersToken,
expect(function (err) {
test.isTrue(err);
test.isFalse(Meteor.userId());
})
);
},
// Test that logoutOtherClients returned a new token that we can use to
// log in.
function (test, expect) {
var self = this;
Meteor.loginWithToken(
self.tokenFromLogoutOthers,
expect(function (err) {
test.isFalse(err);
test.isTrue(Meteor.userId());
})
);
},
logoutStep,
function (test, expect) {
var self = this;
// Test that deleting a user logs out that user's connections.
Meteor.loginWithPassword(this.username, this.password, function (err) {
test.isFalse(err);
Meteor.call("removeUser", self.username);
});
},
waitForLoggedOutStep
]);
}) ();

View File

@@ -32,8 +32,6 @@ Accounts.config({
});
// This test properly belongs in accounts-base/accounts_tests.js, but
// this is where the tests that actually log in are.
Meteor.methods({
testMeteorUser: function () { return Meteor.user(); },
clearUsernameAndProfile: function () {
@@ -41,5 +39,12 @@ Meteor.methods({
throw new Error("Not logged in!");
Meteor.users.update(this.userId,
{$unset: {profile: 1, username: 1}});
},
expireTokens: function () {
Accounts._expireTokens(new Date(), this.userId);
},
removeUser: function (username) {
Meteor.users.remove({ "username": username });
}
});

View File

@@ -1,5 +1,5 @@
Package.describe({
summary: "Simple templates to add login widgets to an app."
summary: "Simple templates to add login widgets to an app"
});
Package.on_use(function (api) {

View File

@@ -1,5 +1,5 @@
Package.describe({
summary: "API for Persistant Storage, PubSub and Request"
summary: "API for Persistent Storage, PubSub and Request"
});
Package.on_use(function (api) {

View File

@@ -6,6 +6,7 @@ var knownBrowsers = [
'android',
'chrome',
'chromium',
'chromeMobileIOS',
'firefox',
'ie',
'mobileSafari',
@@ -16,6 +17,7 @@ var browsersEnabledByDefault = [
'android',
'chrome',
'chromium',
'chromeMobileIOS',
'ie',
'mobileSafari',
'safari'

View File

@@ -1,5 +1,5 @@
Package.describe({
summary: "enable the application cache in the browser"
summary: "Enable the application cache in the browser"
});
Package.on_use(function (api) {

View File

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

View File

@@ -0,0 +1,27 @@
BrowserPolicy = {};
var inTest = false;
BrowserPolicy._runningTest = function () {
return inTest;
};
BrowserPolicy._setRunningTest = function () {
inTest = true;
};
WebApp.connectHandlers.use(function (req, res, next) {
// Never set headers inside tests because they could break other tests.
if (BrowserPolicy._runningTest())
return next();
var xFrameOptions = BrowserPolicy.framing &&
BrowserPolicy.framing._constructXFrameOptions();
var csp = BrowserPolicy.content &&
BrowserPolicy.content._constructCsp();
if (xFrameOptions)
res.setHeader("X-Frame-Options", xFrameOptions);
if (csp)
res.setHeader("Content-Security-Policy", csp);
next();
});

View File

@@ -0,0 +1,10 @@
Package.describe({
summary: "Common code for browser-policy packages",
internal: true
});
Package.on_use(function (api) {
api.use('webapp', 'server');
api.add_files('browser-policy-common.js', 'server');
api.export('BrowserPolicy', 'server');
});

View File

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

View File

@@ -0,0 +1,238 @@
// 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).
//
// Apps should call BrowserPolicy.content.disallowInlineScripts() if they are
// not using any inline script tags and are willing to accept an extra round
// trip on page load.
//
// BrowserPolicy.content functions for tweaking CSP:
// allowInlineScripts()
// disallowInlineScripts(): adds extra round-trip to page load time
// allowInlineStyles()
// disallowInlineStyles()
// allowEval()
// disallowEval()
//
// For each type of content (script, object, image, media, font, connect,
// style), there are the following functions:
// allow<content type>Origin(origin): allows the type of content to be loaded
// from the given origin
// allow<content type>DataUrl(): allows the content to be loaded from data: URLs
// allow<content type>SameOrigin(): allows the content to be loaded from the
// same origin
// disallow<content type>(): disallows this type of content all together (can't
// be called for script)
//
// The following functions allow you to set rules for all types of content at
// once:
// allowAllContentOrigin(origin)
// allowAllContentDataUrl()
// allowAllContentSameOrigin()
// disallowAllContent()
//
var cspSrcs;
var cachedCsp; // Avoid constructing the header out of cspSrcs when possible.
// CSP keywords have to be single-quoted.
var unsafeInline = "'unsafe-inline'";
var unsafeEval = "'unsafe-eval'";
var selfKeyword = "'self'";
var noneKeyword = "'none'";
BrowserPolicy.content = {};
var parseCsp = function (csp) {
var policies = csp.split("; ");
cspSrcs = {};
_.each(policies, function (policy) {
if (policy[policy.length - 1] === ";")
policy = policy.substring(0, policy.length - 1);
var srcs = policy.split(" ");
var directive = srcs[0];
if (_.indexOf(srcs, noneKeyword) !== -1)
cspSrcs[directive] = null;
else
cspSrcs[directive] = srcs.slice(1);
});
if (cspSrcs["default-src"] === undefined)
throw new Error("Content Security Policies used with " +
"browser-policy must specify a default-src.");
// Copy default-src sources to other directives.
_.each(cspSrcs, function (sources, directive) {
cspSrcs[directive] = _.union(sources || [], cspSrcs["default-src"] || []);
});
};
var removeCspSrc = function (directive, src) {
cspSrcs[directive] = _.without(cspSrcs[directive] || [], src);
};
// Prepare for a change to cspSrcs. Ensure that we have a key in the dictionary
// and clear any cached CSP.
var prepareForCspDirective = function (directive) {
cspSrcs = cspSrcs || {};
cachedCsp = null;
if (! _.has(cspSrcs, directive))
cspSrcs[directive] = _.clone(cspSrcs["default-src"]);
};
var setDefaultPolicy = function () {
// By default, unsafe inline scripts and styles are allowed, since we expect
// many apps will use them for analytics, etc. Unsafe eval is disallowed, and
// the only allowable content source is the same origin or data, except for
// connect which allows anything (since meteor.com apps make websocket
// connections to a lot of different origins).
BrowserPolicy.content.setPolicy("default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"connect-src *; " +
"img-src data: 'self'; " +
"style-src 'self' 'unsafe-inline';");
};
var setWebAppInlineScripts = function (value) {
if (! BrowserPolicy._runningTest())
WebAppInternals.setInlineScriptsAllowed(value);
};
_.extend(BrowserPolicy.content, {
// Exported for tests and browser-policy-common.
_constructCsp: function () {
if (! cspSrcs || _.isEmpty(cspSrcs))
return null;
if (cachedCsp)
return cachedCsp;
var header = _.map(cspSrcs, function (srcs, directive) {
srcs = srcs || [];
if (_.isEmpty(srcs))
srcs = [noneKeyword];
var directiveCsp = _.uniq(srcs).join(" ");
return directive + " " + directiveCsp + ";";
});
header = header.join(" ");
cachedCsp = header;
return header;
},
_reset: function () {
cachedCsp = null;
setDefaultPolicy();
},
setPolicy: function (csp) {
cachedCsp = null;
parseCsp(csp);
setWebAppInlineScripts(
BrowserPolicy.content._keywordAllowed("script-src", unsafeInline)
);
},
_keywordAllowed: function (directive, keyword) {
return (cspSrcs[directive] &&
_.indexOf(cspSrcs[directive], keyword) !== -1);
},
// Helpers for creating content security policies
allowInlineScripts: function () {
prepareForCspDirective("script-src");
cspSrcs["script-src"].push(unsafeInline);
setWebAppInlineScripts(true);
},
disallowInlineScripts: function () {
prepareForCspDirective("script-src");
removeCspSrc("script-src", unsafeInline);
setWebAppInlineScripts(false);
},
allowEval: function () {
prepareForCspDirective("script-src");
cspSrcs["script-src"].push(unsafeEval);
},
disallowEval: function () {
prepareForCspDirective("script-src");
removeCspSrc("script-src", unsafeEval);
},
allowInlineStyles: function () {
prepareForCspDirective("style-src");
cspSrcs["style-src"].push(unsafeInline);
},
disallowInlineStyles: function () {
prepareForCspDirective("style-src");
removeCspSrc("style-src", unsafeInline);
},
// Functions for setting defaults
allowSameOriginForAll: function () {
BrowserPolicy.content.allowOriginForAll(selfKeyword);
},
allowDataUrlForAll: function () {
BrowserPolicy.content.allowOriginForAll("data:");
},
allowOriginForAll: function (origin) {
prepareForCspDirective("default-src");
_.each(_.keys(cspSrcs), function (directive) {
cspSrcs[directive].push(origin);
});
},
disallowAll: function () {
cachedCsp = null;
cspSrcs = {
"default-src": []
};
setWebAppInlineScripts(false);
}
});
// allow<Resource>Origin, allow<Resource>Data, allow<Resource>self, and
// disallow<Resource> methods for each type of resource.
_.each(["script", "object", "img", "media",
"font", "connect", "style"],
function (resource) {
var directive = resource + "-src";
var methodResource;
if (resource !== "img") {
methodResource = resource.charAt(0).toUpperCase() +
resource.slice(1);
} else {
methodResource = "Image";
}
var allowMethodName = "allow" + methodResource + "Origin";
var disallowMethodName = "disallow" + methodResource;
var allowDataMethodName = "allow" + methodResource + "DataUrl";
var allowSelfMethodName = "allow" + methodResource + "SameOrigin";
var disallow = function () {
cachedCsp = null;
cspSrcs[directive] = [];
};
BrowserPolicy.content[allowMethodName] = function (src) {
prepareForCspDirective(directive);
cspSrcs[directive].push(src);
};
if (resource === "script") {
BrowserPolicy.content[disallowMethodName] = function () {
disallow();
setWebAppInlineScripts(false);
};
} else {
BrowserPolicy.content[disallowMethodName] = disallow;
}
BrowserPolicy.content[allowDataMethodName] = function () {
prepareForCspDirective(directive);
cspSrcs[directive].push("data:");
};
BrowserPolicy.content[allowSelfMethodName] = function () {
prepareForCspDirective(directive);
cspSrcs[directive].push(selfKeyword);
};
});
setDefaultPolicy();

View File

@@ -0,0 +1,9 @@
Package.describe({
summary: "Configure content security policies"
});
Package.on_use(function (api) {
api.imply(["browser-policy-common"], "server");
api.add_files("browser-policy-content.js", "server");
api.use(["underscore", "browser-policy-common", "webapp"], "server");
});

View File

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

View File

@@ -0,0 +1,39 @@
// By adding this package, you get a default policy where only web pages on the
// same origin as your app can frame your app.
//
// For controlling which origins can frame this app,
// BrowserPolicy.framing.disallow()
// BrowserPolicy.framing.restrictToOrigin(origin)
// BrowserPolicy.framing.allowByAnyOrigin()
var defaultXFrameOptions = "SAMEORIGIN";
var xFrameOptions = defaultXFrameOptions;
BrowserPolicy.framing = {};
_.extend(BrowserPolicy.framing, {
// Exported for tests and browser-policy-common.
_constructXFrameOptions: function () {
return xFrameOptions;
},
_reset: function () {
xFrameOptions = defaultXFrameOptions;
},
disallow: function () {
xFrameOptions = "DENY";
},
// ALLOW-FROM not supported in Chrome or Safari.
restrictToOrigin: function (origin) {
// Trying to specify two allow-from throws to prevent users from
// accidentally overwriting an allow-from origin when they think they are
// adding multiple origins.
if (xFrameOptions && xFrameOptions.indexOf("ALLOW-FROM") === 0)
throw new Error("You can only specify one origin that is allowed to" +
" frame this app.");
xFrameOptions = "ALLOW-FROM " + origin;
},
allowAll: function () {
xFrameOptions = null;
}
});

View File

@@ -0,0 +1,9 @@
Package.describe({
summary: "Restrict which websites can frame your app"
});
Package.on_use(function (api) {
api.imply(["browser-policy-common"], "server");
api.use(["underscore", "browser-policy-common"], "server");
api.add_files("browser-policy-framing.js", "server");
});

1
packages/browser-policy/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,129 @@
BrowserPolicy._setRunningTest();
var cspsEqual = function (csp1, csp2) {
var cspToObj = function (csp) {
csp = csp.substring(0, csp.length - 1);
var parts = _.map(csp.split("; "), function (part) {
return part.split(" ");
});
var keys = _.map(parts, _.first);
var values = _.map(parts, _.rest);
_.each(values, function (value) {
value.sort();
});
return _.object(keys, values);
};
return EJSON.equals(cspToObj(csp1), cspToObj(csp2));
};
// It's important to call _reset() at the beginnning of these tests; otherwise
// the headers left over at the end of the last test run will be used.
Tinytest.add("browser-policy - csp", function (test) {
var defaultCsp = "default-src 'self'; script-src 'self' 'unsafe-inline'; " +
"connect-src * 'self'; img-src data: 'self'; style-src 'self' 'unsafe-inline';"
BrowserPolicy.content._reset();
// Default policy
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(), defaultCsp));
test.isTrue(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
// Redundant whitelisting (inline scripts already allowed in default policy)
BrowserPolicy.content.allowInlineScripts();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(), defaultCsp));
// Disallow inline scripts
BrowserPolicy.content.disallowInlineScripts();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self'; script-src 'self'; " +
"connect-src * 'self'; img-src data: 'self'; style-src 'self' 'unsafe-inline';"));
test.isFalse(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
// Allow eval
BrowserPolicy.content.allowEval();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(), "default-src 'self'; script-src 'self' 'unsafe-eval'; " +
"connect-src * 'self'; img-src data: 'self'; style-src 'self' 'unsafe-inline';"));
// Disallow inline styles
BrowserPolicy.content.disallowInlineStyles();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(), "default-src 'self'; script-src 'self' 'unsafe-eval'; " +
"connect-src * 'self'; img-src data: 'self'; style-src 'self';"));
// Allow data: urls everywhere
BrowserPolicy.content.allowDataUrlForAll();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self' data:; script-src 'self' 'unsafe-eval' data:; " +
"connect-src * data: 'self'; img-src data: 'self'; style-src 'self' data:;"));
// Disallow everything
BrowserPolicy.content.disallowAll();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(), "default-src 'none';"));
test.isFalse(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
// Put inline scripts back in
BrowserPolicy.content.allowInlineScripts();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'none'; script-src 'unsafe-inline';"));
test.isTrue(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
// Add 'self' to all content types
BrowserPolicy.content.allowSameOriginForAll();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self'; script-src 'self' 'unsafe-inline';"));
test.isTrue(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
// Disallow all content except same-origin scripts
BrowserPolicy.content.disallowAll();
BrowserPolicy.content.allowScriptSameOrigin();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'none'; script-src 'self';"));
test.isFalse(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
// Starting with all content same origin, disallowScript() and then allow
// inline scripts. Result should be that that only inline scripts can execute,
// not same-origin scripts.
BrowserPolicy.content.disallowAll();
BrowserPolicy.content.allowSameOriginForAll();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(), "default-src 'self';"));
BrowserPolicy.content.disallowScript();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self'; script-src 'none';"));
test.isFalse(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
BrowserPolicy.content.allowInlineScripts();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self'; script-src 'unsafe-inline';"));
test.isTrue(BrowserPolicy.content._keywordAllowed("script-src", "'unsafe-inline'"));
// Starting with all content same origin, allow inline scripts. (Should result
// in both same origin and inline scripts allowed.)
BrowserPolicy.content.disallowAll();
BrowserPolicy.content.allowSameOriginForAll();
BrowserPolicy.content.allowInlineScripts();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self'; script-src 'self' 'unsafe-inline';"));
BrowserPolicy.content.disallowInlineScripts();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self'; script-src 'self';"));
// Allow same origin for all content, then disallow object entirely.
BrowserPolicy.content.disallowAll();
BrowserPolicy.content.allowSameOriginForAll();
BrowserPolicy.content.disallowObject();
test.isTrue(cspsEqual(BrowserPolicy.content._constructCsp(),
"default-src 'self'; object-src 'none';"));
});
Tinytest.add("browser-policy - x-frame-options", function (test) {
BrowserPolicy.framing._reset();
test.equal(BrowserPolicy.framing._constructXFrameOptions(), "SAMEORIGIN");
BrowserPolicy.framing.disallow();
test.equal(BrowserPolicy.framing._constructXFrameOptions(), "DENY");
BrowserPolicy.framing.allowAll();
test.equal(BrowserPolicy.framing._constructXFrameOptions(), null);
BrowserPolicy.framing.restrictToOrigin("foo.com");
test.equal(BrowserPolicy.framing._constructXFrameOptions(), "ALLOW-FROM foo.com");
test.throws(function () {
BrowserPolicy.framing.restrictToOrigin("bar.com");
});
});

View File

@@ -0,0 +1,13 @@
Package.describe({
summary: "Configure security policies enforced by the browser"
});
Package.on_use(function (api) {
api.use(['browser-policy-content', 'browser-policy-framing'], 'server');
api.imply(['browser-policy-common'], 'server');
});
Package.on_test(function (api) {
api.use(["tinytest", "browser-policy", "ejson"], "server");
api.add_files("browser-policy-test.js", "server");
});

View File

@@ -3,9 +3,10 @@ Tinytest.add("coffeescript - presence", function(test) {
});
Tinytest.add("literate coffeescript - presence", function(test) {
test.isTrue(Meteor.__LITCOFFEESCRIPT_PRESENT);
test.isTrue(Meteor.__COFFEEMDSCRIPT_PRESENT);
});
Tinytest.add("coffeescript - exported variable", function(test) {
test.equal(COFFEESCRIPT_EXPORTED, 123);
test.equal(Package['coffeescript-test-helper'].COFFEESCRIPT_EXPORTED, 123);
});
});

View File

@@ -16,9 +16,10 @@ Package.on_test(function (api) {
api.use(['coffeescript-test-helper'], ['client', 'server']);
api.add_files([
'coffeescript_test_setup.js',
'coffeescript_tests.coffee',
'coffeescript_strict_tests.coffee',
'litcoffeescript_tests.litcoffee',
'tests/coffeescript_tests.coffee',
'tests/coffeescript_strict_tests.coffee',
'tests/litcoffeescript_tests.litcoffee',
'tests/litcoffeescript_tests.coffee.md',
'coffeescript_tests.js'
], ['client', 'server']);
});

View File

@@ -113,13 +113,14 @@ var addSharedHeader = function (source, sourceMap) {
};
};
var handler = function (compileStep) {
var handler = function (compileStep, isLiterate) {
var source = compileStep.read().toString('utf8');
var outputFile = compileStep.inputPath + ".js";
var options = {
bare: true,
filename: compileStep.inputPath,
literate: path.extname(compileStep.inputPath) === '.litcoffee',
literate: !!isLiterate,
// Return a source map.
sourceMap: true,
// Include the original source in the source map (sourcesContent field).
@@ -152,6 +153,11 @@ var handler = function (compileStep) {
});
};
Plugin.registerSourceHandler("coffee", handler);
Plugin.registerSourceHandler("litcoffee", handler);
var literateHandler = function (compileStep) {
return handler(compileStep, true);
}
Plugin.registerSourceHandler("coffee", handler);
Plugin.registerSourceHandler("litcoffee", literateHandler);
Plugin.registerSourceHandler("coffee.md", literateHandler);

View File

@@ -0,0 +1,6 @@
This file is just the same as `coffeescript_tests.coffee`, first we set a
property, which we check for in `coffeescript_tests.js`, and then a trivial
testcase.
Meteor.__COFFEEMDSCRIPT_PRESENT = true
Tinytest.add "markdown coffeescript - compile", (test) -> test.isTrue true

View File

@@ -1,10 +1,13 @@
{
"dependencies": {
"optimist": {
"version": "0.4.0",
"version": "0.6.0",
"dependencies": {
"wordwrap": {
"version": "0.0.2"
},
"minimist": {
"version": "0.0.5"
}
}
}

View File

@@ -1,8 +1,9 @@
Package.describe({
summary: "Helpers for control programs"
summary: "Helpers for control programs",
internal: true
});
Npm.depends({optimist: '0.4.0'});
Npm.depends({optimist: '0.6.0'});
Package.on_use(function (api) {
api.use(['underscore', 'livedata', 'mongo-livedata', 'follower-livedata'], 'server');

View File

@@ -1,5 +1,6 @@
Package.describe({
summary: "Default control program for an application"
summary: "Default control program for an application",
internal: true
});
Package.on_use(function (api) {

View File

@@ -1,5 +1,5 @@
Package.describe({
summary: "Library for manipulating documents based on data."
summary: "Library for manipulating documents based on data"
});
Package.on_use(function (api) {

View File

@@ -18,6 +18,10 @@ EJSON.addType = function (name, factory) {
customTypes[name] = factory;
};
var isInfOrNan = function (obj) {
return _.isNaN(obj) || obj === Infinity || obj === -Infinity;
};
var builtinConverters = [
{ // Date
matchJSONValue: function (obj) {
@@ -33,6 +37,26 @@ var builtinConverters = [
return new Date(obj.$date);
}
},
{ // NaN, Inf, -Inf. (These are the only objects with typeof !== 'object'
// which we match.)
matchJSONValue: function (obj) {
return _.has(obj, '$InfNaN') && _.size(obj) === 1;
},
matchObject: isInfOrNan,
toJSONValue: function (obj) {
var sign;
if (_.isNaN(obj))
sign = 0;
else if (obj === Infinity)
sign = 1;
else
sign = -1;
return {$InfNaN: sign};
},
fromJSONValue: function (obj) {
return obj.$InfNaN/0;
}
},
{ // Binary
matchJSONValue: function (obj) {
return _.has(obj, '$binary') && _.size(obj) === 1;
@@ -104,14 +128,23 @@ EJSON._isCustomType = function (obj) {
// for both arrays and objects, in-place modification.
var adjustTypesToJSONValue =
EJSON._adjustTypesToJSONValue = function (obj) {
// Is it an atom that we need to adjust?
if (obj === null)
return null;
var maybeChanged = toJSONValueHelper(obj);
if (maybeChanged !== undefined)
return maybeChanged;
// Other atoms are unchanged.
if (typeof obj !== 'object')
return obj;
// Iterate over array or object structure.
_.each(obj, function (value, key) {
if (typeof value !== 'object' && value !== undefined)
if (typeof value !== 'object' && value !== undefined &&
!isInfOrNan(value))
return; // continue
var changed = toJSONValueHelper(value);
if (changed) {
obj[key] = changed;
@@ -158,6 +191,11 @@ EJSON._adjustTypesFromJSONValue = function (obj) {
var maybeChanged = fromJSONValueHelper(obj);
if (maybeChanged !== obj)
return maybeChanged;
// Other atoms are unchanged.
if (typeof obj !== 'object')
return obj;
_.each(obj, function (value, key) {
if (typeof value === 'object') {
var changed = fromJSONValueHelper(value);
@@ -206,11 +244,18 @@ EJSON.fromJSONValue = function (item) {
}
};
EJSON.stringify = function (item) {
return JSON.stringify(EJSON.toJSONValue(item));
EJSON.stringify = function (item, options) {
var json = EJSON.toJSONValue(item);
if (options && (options.canonical || options.indent)) {
return EJSON._canonicalStringify(json, options);
} else {
return JSON.stringify(json);
}
};
EJSON.parse = function (item) {
if (typeof item !== 'string')
throw new Error("EJSON.parse argument should be a string");
return EJSON.fromJSONValue(JSON.parse(item));
};
@@ -224,6 +269,9 @@ EJSON.equals = function (a, b, options) {
var keyOrderSensitive = !!(options && options.keyOrderSensitive);
if (a === b)
return true;
if (_.isNaN(a) && _.isNaN(b))
return true; // This differs from the IEEE spec for NaN equality, b/c we don't want
// anything ever with a NaN to be poisoned from becoming equal to anything.
if (!a || !b) // if either one is falsy, they'd have to be === to be equal
return false;
if (!(typeof a === 'object' && typeof b === 'object'))
@@ -305,6 +353,7 @@ EJSON.clone = function (v) {
}
return ret;
}
// XXX: Use something better than underscore's isArray
if (_.isArray(v) || _.isArguments(v)) {
// For some reason, _.map doesn't work in this context on Opera (weird test
// failures).

View File

@@ -52,6 +52,31 @@ Tinytest.add("ejson - equality and falsiness", function (test) {
test.isFalse(EJSON.equals({foo: "foo"}, undefined));
});
Tinytest.add("ejson - NaN and Inf", function (test) {
test.equal(EJSON.parse("{\"$InfNaN\": 1}"), Infinity);
test.equal(EJSON.parse("{\"$InfNaN\": -1}"), -Infinity);
test.isTrue(_.isNaN(EJSON.parse("{\"$InfNaN\": 0}")));
test.equal(EJSON.parse(EJSON.stringify(Infinity)), Infinity);
test.equal(EJSON.parse(EJSON.stringify(-Infinity)), -Infinity);
test.isTrue(_.isNaN(EJSON.parse(EJSON.stringify(NaN))));
test.isTrue(EJSON.equals(NaN, NaN));
test.isTrue(EJSON.equals(Infinity, Infinity));
test.isTrue(EJSON.equals(-Infinity, -Infinity));
test.isFalse(EJSON.equals(Infinity, -Infinity));
test.isFalse(EJSON.equals(Infinity, NaN));
test.isFalse(EJSON.equals(Infinity, 0));
test.isFalse(EJSON.equals(NaN, 0));
test.isTrue(EJSON.equals(
EJSON.parse("{\"a\": {\"$InfNaN\": 1}}"),
{a: Infinity}
));
test.isTrue(EJSON.equals(
EJSON.parse("{\"a\": {\"$InfNaN\": 0}}"),
{a: NaN}
));
});
Tinytest.add("ejson - clone", function (test) {
var cloneTest = function (x, identical) {
var y = EJSON.clone(x);
@@ -72,3 +97,90 @@ Tinytest.add("ejson - clone", function (test) {
};
testCloneArgs(1, 2, "foo", [4]);
});
Tinytest.add("ejson - stringify", function (test) {
test.equal(EJSON.stringify(null), "null");
test.equal(EJSON.stringify(true), "true");
test.equal(EJSON.stringify(false), "false");
test.equal(EJSON.stringify(123), "123");
test.equal(EJSON.stringify("abc"), "\"abc\"");
test.equal(EJSON.stringify([1, 2, 3]),
"[1,2,3]"
);
test.equal(EJSON.stringify([1, 2, 3], {indent: true}),
"[\n 1,\n 2,\n 3\n]"
);
test.equal(EJSON.stringify([1, 2, 3], {canonical: false}),
"[1,2,3]"
);
test.equal(EJSON.stringify([1, 2, 3], {indent: true, canonical: false}),
"[\n 1,\n 2,\n 3\n]"
);
test.equal(EJSON.stringify([1, 2, 3], {indent: 4}),
"[\n 1,\n 2,\n 3\n]"
);
test.equal(EJSON.stringify([1, 2, 3], {indent: '--'}),
"[\n--1,\n--2,\n--3\n]"
);
test.equal(
EJSON.stringify(
{b: [2, {d: 4, c: 3}], a: 1},
{canonical: true}
),
"{\"a\":1,\"b\":[2,{\"c\":3,\"d\":4}]}"
);
test.equal(
EJSON.stringify(
{b: [2, {d: 4, c: 3}], a: 1},
{
indent: true,
canonical: true
}
),
"{\n" +
" \"a\": 1,\n" +
" \"b\": [\n" +
" 2,\n" +
" {\n" +
" \"c\": 3,\n" +
" \"d\": 4\n" +
" }\n" +
" ]\n" +
"}"
);
test.equal(
EJSON.stringify(
{b: [2, {d: 4, c: 3}], a: 1},
{canonical: false}
),
"{\"b\":[2,{\"d\":4,\"c\":3}],\"a\":1}"
);
test.equal(
EJSON.stringify(
{b: [2, {d: 4, c: 3}], a: 1},
{indent: true, canonical: false}
),
"{\n" +
" \"b\": [\n" +
" 2,\n" +
" {\n" +
" \"d\": 4,\n" +
" \"c\": 3\n" +
" }\n" +
" ],\n" +
" \"a\": 1\n" +
"}"
);
});
Tinytest.add("ejson - parse", function (test) {
test.equal(EJSON.parse("[1,2,3]"), [1,2,3]);
test.throws(
function () { EJSON.parse(null) },
/argument should be a string/
);
});

View File

@@ -8,6 +8,7 @@ Package.on_use(function (api) {
api.export('EJSON');
api.export('EJSONTest', {testOnly: true});
api.add_files('ejson.js', ['client', 'server']);
api.add_files('stringify.js', ['client', 'server']);
api.add_files('base64.js', ['client', 'server']);
});

118
packages/ejson/stringify.js Normal file
View File

@@ -0,0 +1,118 @@
// Based on json2.js from https://github.com/douglascrockford/JSON-js
//
// json2.js
// 2012-10-08
//
// Public Domain.
//
// NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
function quote(string) {
return JSON.stringify(string);
}
var str = function (key, holder, singleIndent, outerIndent, canonical) {
// Produce a string from holder[key].
var i; // The loop counter.
var k; // The member key.
var v; // The member value.
var length;
var innerIndent = outerIndent;
var partial;
var value = holder[key];
// What happens next depends on the value's type.
switch (typeof value) {
case 'string':
return quote(value);
case 'number':
// JSON numbers must be finite. Encode non-finite numbers as null.
return isFinite(value) ? String(value) : 'null';
case 'boolean':
return String(value);
// If the type is 'object', we might be dealing with an object or an array or
// null.
case 'object':
// Due to a specification blunder in ECMAScript, typeof null is 'object',
// so watch out for that case.
if (!value) {
return 'null';
}
// Make an array to hold the partial results of stringifying this object value.
innerIndent = outerIndent + singleIndent;
partial = [];
// Is the value an array?
if (_.isArray(value) || _.isArguments(value)) {
// The value is an array. Stringify every element. Use null as a placeholder
// for non-JSON values.
length = value.length;
for (i = 0; i < length; i += 1) {
partial[i] = str(i, value, singleIndent, innerIndent, canonical) || 'null';
}
// Join all of the elements together, separated with commas, and wrap them in
// brackets.
if (partial.length === 0) {
v = '[]';
} else if (innerIndent) {
v = '[\n' + innerIndent + partial.join(',\n' + innerIndent) + '\n' + outerIndent + ']';
} else {
v = '[' + partial.join(',') + ']';
}
return v;
}
// Iterate through all of the keys in the object.
var keys = _.keys(value);
if (canonical)
keys = keys.sort();
_.each(keys, function (k) {
v = str(k, value, singleIndent, innerIndent, canonical);
if (v) {
partial.push(quote(k) + (innerIndent ? ': ' : ':') + v);
}
});
// Join all of the member texts together, separated with commas,
// and wrap them in braces.
if (partial.length === 0) {
v = '{}';
} else if (innerIndent) {
v = '{\n' + innerIndent + partial.join(',\n' + innerIndent) + '\n' + outerIndent + '}';
} else {
v = '{' + partial.join(',') + '}';
}
return v;
}
}
// If the JSON object does not yet have a stringify method, give it one.
EJSON._canonicalStringify = function (value, options) {
// Make a fake root object containing our value under the key of ''.
// Return the result of stringifying the value.
options = _.extend({
indent: "",
canonical: false
}, options);
if (options.indent === true) {
options.indent = " ";
} else if (typeof options.indent === 'number') {
var newIndent = "";
for (var i = 0; i < options.indent; i++) {
newIndent += ' ';
}
options.indent = newIndent;
}
return str('', {'': value}, options.indent, "", options.canonical);
};

View File

@@ -9,7 +9,7 @@
}
},
"simplesmtp": {
"version": "0.1.25",
"version": "0.3.10",
"dependencies": {
"rai": {
"version": "0.1.7"
@@ -20,7 +20,7 @@
}
},
"stream-buffers": {
"version": "0.2.3"
"version": "0.2.5"
}
}
}

View File

@@ -2,9 +2,12 @@ Package.describe({
summary: "Send email messages"
});
// Pinned at older version. 0.1.16+ uses mimelib, not mimelib-noiconv
// which is much bigger. We need a better solution.
Npm.depends({mailcomposer: "0.1.15", simplesmtp: "0.1.25", "stream-buffers": "0.2.3"});
Npm.depends({
// Pinned at older version. 0.1.16+ uses mimelib, not mimelib-noiconv which is
// much bigger. We need a better solution.
mailcomposer: "0.1.15",
simplesmtp: "0.3.10",
"stream-buffers": "0.2.5"});
Package.on_use(function (api) {
api.use('underscore', 'server');

View File

@@ -1,4 +1,5 @@
var fs = Npm.require('fs');
var Future = Npm.require('fibers/future');
var readFile = Meteor._wrapAsync(fs.readFile);
@@ -38,12 +39,15 @@ Follower = {
makeElectorTries(urlSet, { priority: 0, reset: true });
}
var tryingUrl = null;
var outstandingGetElectorate = false;
var conn = null;
var leader = null;
var connected = null;
var intervalHandle = null;
// Used to defer all method calls until we're sure that we connected to the
// right leadership group.
var connectedToLeadershipGroup = new Future();
var findFewestTries = function () {
var min = 10000;
var minElector = null;
@@ -74,11 +78,14 @@ Follower = {
};
var tryElector = function (url) {
if (tryingUrl) {
electorTries[tryingUrl]++;
}
url = url || findFewestTries();
//console.log("trying", url, electorTries, tryingUrl);
// console.log("trying", url, electorTries, tryingUrl);
// Don't keep trying the same url as fast as we can if it's not working.
if (electorTries[url] > 2) {
Meteor._sleepForMs(3 * 1000);
}
if (conn) {
conn._reconnectImpl({
url: url
@@ -87,21 +94,26 @@ Follower = {
conn = DDP.connect(url);
conn._reconnectImpl = conn.reconnect;
}
tryingUrl = url;
if (!outstandingGetElectorate) {
outstandingGetElectorate = true;
if (tryingUrl) {
electorTries[tryingUrl]++;
tryingUrl = url;
} else {
tryingUrl = url;
conn.call('getElectorate', options.group, function (err, res) {
outstandingGetElectorate = false;
connected = tryingUrl;
tryingUrl = null;
if (err) {
electorTries[url]++;
tryElector();
return;
}
tryingUrl = null;
if (! connectedToLeadershipGroup.isResolved()) {
connectedToLeadershipGroup["return"]();
}
// we got an answer! Connected!
electorTries[url] = 0;
if (res.leader === connected) {
if (res.leader === url) {
// we're good.
} else {
@@ -109,6 +121,7 @@ Follower = {
// is connectable.
if (electorTries[res.leader] == 0) {
tryElector(res.leader);
} else {
// XXX: leader is probably down, we're probably going to elect
// soon. Wait for the next round.
@@ -117,29 +130,28 @@ Follower = {
}
updateElectorate(res);
});
}
};
};
tryElector();
var checkConnection = function () {
if (conn.status().status !== 'connected' || connected !== leader) {
tryElector();
} else {
conn.call('getElectorate', options.group, function (err, res) {
if (err) {
electorTries[connected]++;
tryElector();
} else if (res.leader !== leader) {
updateElectorate(res);
tryElector(res.leader);
} else {
//console.log("updating electorate with", res);
updateElectorate(res);
if (conn.status().status !== 'connected' || connected !== leader) {
tryElector();
} else {
conn.call('getElectorate', options.group, function (err, res) {
if (err) {
electorTries[connected]++;
tryElector();
} else {
if (! connectedToLeadershipGroup.isResolved()) {
connectedToLeadershipGroup["return"]();
}
});
}
//console.log("updating electorate with", res);
updateElectorate(res);
}
});
}
};
var monitorConnection = function () {
@@ -176,6 +188,15 @@ Follower = {
return electorTries;
};
// Assumes that `call` is implemented in terms of `apply`. All method calls
// should be deferred until we are sure we've connected to the right
// leadership group.
conn._applyImpl = conn.apply;
conn.apply = function (/* arguments */) {
connectedToLeadershipGroup.wait();
return conn._applyImpl.apply(conn, arguments);
};
return conn;
}

1
packages/geojson-utils/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,380 @@
(function () {
var gju = {};
// Export the geojson object for **CommonJS**
if (typeof module !== 'undefined' && module.exports) {
module.exports = gju;
}
// adapted from http://www.kevlindev.com/gui/math/intersection/Intersection.js
gju.lineStringsIntersect = function (l1, l2) {
var intersects = [];
for (var i = 0; i <= l1.coordinates.length - 2; ++i) {
for (var j = 0; j <= l2.coordinates.length - 2; ++j) {
var a1 = {
x: l1.coordinates[i][1],
y: l1.coordinates[i][0]
},
a2 = {
x: l1.coordinates[i + 1][1],
y: l1.coordinates[i + 1][0]
},
b1 = {
x: l2.coordinates[j][1],
y: l2.coordinates[j][0]
},
b2 = {
x: l2.coordinates[j + 1][1],
y: l2.coordinates[j + 1][0]
},
ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x),
ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x),
u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);
if (u_b != 0) {
var ua = ua_t / u_b,
ub = ub_t / u_b;
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
intersects.push({
'type': 'Point',
'coordinates': [a1.x + ua * (a2.x - a1.x), a1.y + ua * (a2.y - a1.y)]
});
}
}
}
}
if (intersects.length == 0) intersects = false;
return intersects;
}
// Bounding Box
function boundingBoxAroundPolyCoords (coords) {
var xAll = [], yAll = []
for (var i = 0; i < coords[0].length; i++) {
xAll.push(coords[0][i][1])
yAll.push(coords[0][i][0])
}
xAll = xAll.sort(function (a,b) { return a - b })
yAll = yAll.sort(function (a,b) { return a - b })
return [ [xAll[0], yAll[0]], [xAll[xAll.length - 1], yAll[yAll.length - 1]] ]
}
gju.pointInBoundingBox = function (point, bounds) {
return !(point.coordinates[1] < bounds[0][0] || point.coordinates[1] > bounds[1][0] || point.coordinates[0] < bounds[0][1] || point.coordinates[0] > bounds[1][1])
}
// Point in Polygon
// http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html#Listing the Vertices
function pnpoly (x,y,coords) {
var vert = [ [0,0] ]
for (var i = 0; i < coords.length; i++) {
for (var j = 0; j < coords[i].length; j++) {
vert.push(coords[i][j])
}
vert.push([0,0])
}
var inside = false
for (var i = 0, j = vert.length - 1; i < vert.length; j = i++) {
if (((vert[i][0] > y) != (vert[j][0] > y)) && (x < (vert[j][1] - vert[i][1]) * (y - vert[i][0]) / (vert[j][0] - vert[i][0]) + vert[i][1])) inside = !inside
}
return inside
}
gju.pointInPolygon = function (p, poly) {
var coords = (poly.type == "Polygon") ? [ poly.coordinates ] : poly.coordinates
var insideBox = false
for (var i = 0; i < coords.length; i++) {
if (gju.pointInBoundingBox(p, boundingBoxAroundPolyCoords(coords[i]))) insideBox = true
}
if (!insideBox) return false
var insidePoly = false
for (var i = 0; i < coords.length; i++) {
if (pnpoly(p.coordinates[1], p.coordinates[0], coords[i])) insidePoly = true
}
return insidePoly
}
gju.numberToRadius = function (number) {
return number * Math.PI / 180;
}
gju.numberToDegree = function (number) {
return number * 180 / Math.PI;
}
// written with help from @tautologe
gju.drawCircle = function (radiusInMeters, centerPoint, steps) {
var center = [centerPoint.coordinates[1], centerPoint.coordinates[0]],
dist = (radiusInMeters / 1000) / 6371,
// convert meters to radiant
radCenter = [gju.numberToRadius(center[0]), gju.numberToRadius(center[1])],
steps = steps || 15,
// 15 sided circle
poly = [[center[0], center[1]]];
for (var i = 0; i < steps; i++) {
var brng = 2 * Math.PI * i / steps;
var lat = Math.asin(Math.sin(radCenter[0]) * Math.cos(dist)
+ Math.cos(radCenter[0]) * Math.sin(dist) * Math.cos(brng));
var lng = radCenter[1] + Math.atan2(Math.sin(brng) * Math.sin(dist) * Math.cos(radCenter[0]),
Math.cos(dist) - Math.sin(radCenter[0]) * Math.sin(lat));
poly[i] = [];
poly[i][1] = gju.numberToDegree(lat);
poly[i][0] = gju.numberToDegree(lng);
}
return {
"type": "Polygon",
"coordinates": [poly]
};
}
// assumes rectangle starts at lower left point
gju.rectangleCentroid = function (rectangle) {
var bbox = rectangle.coordinates[0];
var xmin = bbox[0][0],
ymin = bbox[0][1],
xmax = bbox[2][0],
ymax = bbox[2][1];
var xwidth = xmax - xmin;
var ywidth = ymax - ymin;
return {
'type': 'Point',
'coordinates': [xmin + xwidth / 2, ymin + ywidth / 2]
};
}
// from http://www.movable-type.co.uk/scripts/latlong.html
gju.pointDistance = function (pt1, pt2) {
var lon1 = pt1.coordinates[0],
lat1 = pt1.coordinates[1],
lon2 = pt2.coordinates[0],
lat2 = pt2.coordinates[1],
dLat = gju.numberToRadius(lat2 - lat1),
dLon = gju.numberToRadius(lon2 - lon1),
a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(gju.numberToRadius(lat1))
* Math.cos(gju.numberToRadius(lat2)) * Math.pow(Math.sin(dLon / 2), 2),
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
// Earth radius is 6371 km
return (6371 * c) * 1000; // returns meters
},
// checks if geometry lies entirely within a circle
// works with Point, LineString, Polygon
gju.geometryWithinRadius = function (geometry, center, radius) {
if (geometry.type == 'Point') {
return gju.pointDistance(geometry, center) <= radius;
} else if (geometry.type == 'LineString' || geometry.type == 'Polygon') {
var point = {};
var coordinates;
if (geometry.type == 'Polygon') {
// it's enough to check the exterior ring of the Polygon
coordinates = geometry.coordinates[0];
} else {
coordinates = geometry.coordinates;
}
for (var i in coordinates) {
point.coordinates = coordinates[i];
if (gju.pointDistance(point, center) > radius) {
return false;
}
}
}
return true;
}
// adapted from http://paulbourke.net/geometry/polyarea/javascript.txt
gju.area = function (polygon) {
var area = 0;
// TODO: polygon holes at coordinates[1]
var points = polygon.coordinates[0];
var j = points.length - 1;
var p1, p2;
for (var i = 0; i < points.length; j = i++) {
var p1 = {
x: points[i][1],
y: points[i][0]
};
var p2 = {
x: points[j][1],
y: points[j][0]
};
area += p1.x * p2.y;
area -= p1.y * p2.x;
}
area /= 2;
return area;
},
// adapted from http://paulbourke.net/geometry/polyarea/javascript.txt
gju.centroid = function (polygon) {
var f, x = 0,
y = 0;
// TODO: polygon holes at coordinates[1]
var points = polygon.coordinates[0];
var j = points.length - 1;
var p1, p2;
for (var i = 0; i < points.length; j = i++) {
var p1 = {
x: points[i][1],
y: points[i][0]
};
var p2 = {
x: points[j][1],
y: points[j][0]
};
f = p1.x * p2.y - p2.x * p1.y;
x += (p1.x + p2.x) * f;
y += (p1.y + p2.y) * f;
}
f = gju.area(polygon) * 6;
return {
'type': 'Point',
'coordinates': [y / f, x / f]
};
},
gju.simplify = function (source, kink) { /* source[] array of geojson points */
/* kink in metres, kinks above this depth kept */
/* kink depth is the height of the triangle abc where a-b and b-c are two consecutive line segments */
kink = kink || 20;
source = source.map(function (o) {
return {
lng: o.coordinates[0],
lat: o.coordinates[1]
}
});
var n_source, n_stack, n_dest, start, end, i, sig;
var dev_sqr, max_dev_sqr, band_sqr;
var x12, y12, d12, x13, y13, d13, x23, y23, d23;
var F = (Math.PI / 180.0) * 0.5;
var index = new Array(); /* aray of indexes of source points to include in the reduced line */
var sig_start = new Array(); /* indices of start & end of working section */
var sig_end = new Array();
/* check for simple cases */
if (source.length < 3) return (source); /* one or two points */
/* more complex case. initialize stack */
n_source = source.length;
band_sqr = kink * 360.0 / (2.0 * Math.PI * 6378137.0); /* Now in degrees */
band_sqr *= band_sqr;
n_dest = 0;
sig_start[0] = 0;
sig_end[0] = n_source - 1;
n_stack = 1;
/* while the stack is not empty ... */
while (n_stack > 0) {
/* ... pop the top-most entries off the stacks */
start = sig_start[n_stack - 1];
end = sig_end[n_stack - 1];
n_stack--;
if ((end - start) > 1) { /* any intermediate points ? */
/* ... yes, so find most deviant intermediate point to
either side of line joining start & end points */
x12 = (source[end].lng() - source[start].lng());
y12 = (source[end].lat() - source[start].lat());
if (Math.abs(x12) > 180.0) x12 = 360.0 - Math.abs(x12);
x12 *= Math.cos(F * (source[end].lat() + source[start].lat())); /* use avg lat to reduce lng */
d12 = (x12 * x12) + (y12 * y12);
for (i = start + 1, sig = start, max_dev_sqr = -1.0; i < end; i++) {
x13 = source[i].lng() - source[start].lng();
y13 = source[i].lat() - source[start].lat();
if (Math.abs(x13) > 180.0) x13 = 360.0 - Math.abs(x13);
x13 *= Math.cos(F * (source[i].lat() + source[start].lat()));
d13 = (x13 * x13) + (y13 * y13);
x23 = source[i].lng() - source[end].lng();
y23 = source[i].lat() - source[end].lat();
if (Math.abs(x23) > 180.0) x23 = 360.0 - Math.abs(x23);
x23 *= Math.cos(F * (source[i].lat() + source[end].lat()));
d23 = (x23 * x23) + (y23 * y23);
if (d13 >= (d12 + d23)) dev_sqr = d23;
else if (d23 >= (d12 + d13)) dev_sqr = d13;
else dev_sqr = (x13 * y12 - y13 * x12) * (x13 * y12 - y13 * x12) / d12; // solve triangle
if (dev_sqr > max_dev_sqr) {
sig = i;
max_dev_sqr = dev_sqr;
}
}
if (max_dev_sqr < band_sqr) { /* is there a sig. intermediate point ? */
/* ... no, so transfer current start point */
index[n_dest] = start;
n_dest++;
} else { /* ... yes, so push two sub-sections on stack for further processing */
n_stack++;
sig_start[n_stack - 1] = sig;
sig_end[n_stack - 1] = end;
n_stack++;
sig_start[n_stack - 1] = start;
sig_end[n_stack - 1] = sig;
}
} else { /* ... no intermediate points, so transfer current start point */
index[n_dest] = start;
n_dest++;
}
}
/* transfer last point */
index[n_dest] = n_source - 1;
n_dest++;
/* make return array */
var r = new Array();
for (var i = 0; i < n_dest; i++)
r.push(source[index[i]]);
return r.map(function (o) {
return {
type: "Point",
coordinates: [o.lng, o.lat]
}
});
}
// http://www.movable-type.co.uk/scripts/latlong.html#destPoint
gju.destinationPoint = function (pt, brng, dist) {
dist = dist/6371; // convert dist to angular distance in radians
brng = gju.numberToRadius(brng);
var lat1 = gju.numberToRadius(pt.coordinates[0]);
var lon1 = gju.numberToRadius(pt.coordinates[1]);
var lat2 = Math.asin( Math.sin(lat1)*Math.cos(dist) +
Math.cos(lat1)*Math.sin(dist)*Math.cos(brng) );
var lon2 = lon1 + Math.atan2(Math.sin(brng)*Math.sin(dist)*Math.cos(lat1),
Math.cos(dist)-Math.sin(lat1)*Math.sin(lat2));
lon2 = (lon2+3*Math.PI) % (2*Math.PI) - Math.PI; // normalise to -180..+180º
return {
'type': 'Point',
'coordinates': [gju.numberToDegree(lat2), gju.numberToDegree(lon2)]
};
};
})();

View File

@@ -0,0 +1,102 @@
var gju = GeoJSON;
Tinytest.add("geojson-utils - line intersects", function (test) {
var diagonalUp = { "type": "LineString","coordinates": [
[0, 0], [10, 10]
]}
var diagonalDown = { "type": "LineString","coordinates": [
[10, 0], [0, 10]
]}
var farAway = { "type": "LineString","coordinates": [
[100, 100], [110, 110]
]}
test.isTrue(gju.lineStringsIntersect(diagonalUp, diagonalDown));
test.isFalse(gju.lineStringsIntersect(diagonalUp, farAway));
});
// Used by two tests
var box = {
"type": "Polygon",
"coordinates": [
[ [0, 0], [10, 0], [10, 10], [0, 10] ]
]
};
Tinytest.add("geojson-utils - inside/outside of the box", function (test) {
var inBox = {"type": "Point", "coordinates": [5, 5]}
var outBox = {"type": "Point", "coordinates": [15, 15]}
test.isTrue(gju.pointInPolygon(inBox, box));
test.isFalse(gju.pointInPolygon(outBox, box));
});
Tinytest.add("geojson-utils - drawCircle", function (test) {
test.length(gju.drawCircle(10, {"type": "Point", "coordinates": [0, 0]}).
coordinates[0], 15);
test.length(gju.drawCircle(10, {"type": "Point", "coordinates": [0, 0]}, 50).
coordinates[0], 50);
});
Tinytest.add("geojson-utils - centroid", function (test) {
var centroid = gju.rectangleCentroid(box)
test.equal(centroid.coordinates[0], 5);
test.equal(centroid.coordinates[1], 5);
});
Tinytest.add("geojson-utils - point distance", function (test) {
var fairyLand = {"type": "Point",
"coordinates": [-122.260000705719, 37.80919060818706]}
var navalBase = {"type": "Point",
"coordinates": [-122.32083320617676, 37.78774223089045]}
test.equal(Math.floor(gju.pointDistance(fairyLand, navalBase)), 5852);
});
Tinytest.add("geojson-utils - points distance generated tests", function (test) {
var floatEqual = function (a, b) {
test.isTrue(Math.abs(a - b) < 0.000001);
};
// Pairs of points we will be looking a distance between
var tests = [[[-19.416501816827804,-13.442164216190577], [8.694866622798145,-8.511979941977188]],
[[151.2841189110186,-56.14564002258703], [167.77983197313733,0.05544793023727834]],
[[100.28413630579598,-88.02313695591874], [36.48786173714325,53.44207073468715]],
[[-70.34899035631679,76.51596869179048], [154.91605914011598,-73.60970971290953]],
[[96.28682994353585,58.77288202662021], [-118.33936230326071,72.07877089688554]],
[[140.35530551429838,10.507104953983799], [-67.73368513956666,38.075836981181055]],
[[69.55582775664516,86.25450283149257], [-18.446230484172702,6.116170521359891]],
[[163.83647522330284,-65.7211532376241], [-159.2198902608361,-78.42975475382991]],
[[-178.9383797585033,-54.87420454365201], [-175.35227065649815,-84.04084282391705]],
[[-48.63219943456352,11.284161149058491], [-179.12627786491066,-51.95622375886887]],
[[140.29684206470847,-67.20720696030185], [-109.37452355003916,36.03131077555008]],
[[-154.6698773431126,58.322094617411494], [61.18583445576951,-4.3424885796848685]],
[[122.5562841903884,10.43972848681733], [-11.756078708684072,-43.86124441982247]],
[[-67.91648306301795,-86.38826347864233], [163.577536230674,12.987319261068478]],
[[91.65140007715672,17.595150742679834], [135.80393003183417,22.307532118167728]],
[[-112.70280818711035,34.45729674655013], [-127.42168210959062,-25.51327457977459]],
[[-161.55807900894433,-77.40711871231906], [-92.66313794790767,-89.12077954714186]],
[[39.966264681424946,9.890176948625594], [-159.88646019320004,40.60383598925546]],
[[-57.48232689569704,86.64061016729102], [59.53941993578337,-75.73194969259202]],
[[-142.0938081513159,80.76813141163439], [14.891517050098628,64.56322408467531]]];
// correct distance between two points
var answers = [3115066.2536578891, 6423493.2321747802, 15848950.0402601473,
18714226.5425080135, 5223022.7731127860, 13874476.3135112207,
9314403.3309389465, 1831929.5917785936, 3244710.9344544266,
13691492.4666933995, 14525055.6462231465, 13261602.4336371962,
14275427.5511620939, 11699799.3615680672, 4628773.1129429890,
6846704.0253010122, 1368055.9401701286, 14041503.0409814864,
18560499.7346975356, 3793112.6186894816];
_.each(tests, function (pair, testN) {
var distance = GeoJSON.pointDistance.apply(this, _.map(pair, toGeoJSONPoint));
test.isTrue(Math.abs(distance - answers[testN]) < 0.00000001,
"Wrong distance between points " + JSON.stringify(pair) + ": " + distance);
});
function toGeoJSONPoint (coordinates) {
return { type: "Point", coordinates: coordinates };
}
});

View File

@@ -0,0 +1,16 @@
Package.describe({
summary: 'GeoJSON utility functions (from https://github.com/maxogden/geojson-js-utils)',
internal: true
});
Package.on_use(function (api) {
api.export('GeoJSON');
api.add_files(['pre.js', 'geojson-utils.js', 'post.js']);
});
Package.on_test(function (api) {
api.use('tinytest');
api.use('geojson-utils');
api.add_files(['geojson-utils.tests.js'], 'client');
});

View File

@@ -0,0 +1,4 @@
// This exports object was created in pre.js. Now copy the `exports` object
// from it into the package-scope variable `GeoJSON`, which will get exported.
GeoJSON = module.exports;

View File

@@ -0,0 +1,4 @@
// Define an object named exports. This will cause geojson-utils.js to put `gju`
// as a field on it, instead of in the global namespace. See also post.js.
module = {exports:{}};

View File

@@ -44,6 +44,15 @@ Google.requestCredential = function (options, credentialRequestCompleteCallback)
'&access_type=' + accessType +
'&approval_prompt=' + approvalPrompt;
// Use Google's domain-specific login page if we want to restrict creation to
// a particular email domain. (Don't use it if restrictCreationByEmailDomain
// is a function.) Note that all this does is change Google's UI ---
// accounts-base/accounts_server.js still checks server-side that the server
// has the proper email address after the OAuth conversation.
if (typeof Accounts._options.restrictCreationByEmailDomain === 'string') {
loginUrl += '&hd=' + encodeURIComponent(Accounts._options.restrictCreationByEmailDomain);
}
Oauth.initiateLogin(credentialToken,
loginUrl,
credentialRequestCompleteCallback,

View File

@@ -249,11 +249,16 @@ testAsyncMulti("httpcall - methods", [
test.equal(result.statusCode, 200);
var data = result.data;
test.equal(data.url, "/foo");
// IE <= 8 turns seems to turn POSTs with no body into
// GETs, inexplicably.
if (Meteor.isClient && $.browser.msie && $.browser.version <= 8
&& meth === "POST")
meth = "GET";
//
// XXX Except now it doesn't!? Not sure what changed, but
// these lines now break the test...
// if (Meteor.isClient && $.browser.msie && $.browser.version <= 8
// && meth === "POST")
// meth = "GET";
test.equal(data.method, meth);
}));
};

View File

@@ -1,42 +1,3 @@
Tinytest.add("js-analyze - findGlobalDottedRefs", function (test) {
var R = JSAnalyze.READ;
var W = JSAnalyze.WRITE;
var run = function (source) {
return JSAnalyze.findGlobalDottedRefs(source);
};
test.equal(run('x'), {x: R});
test.equal(run('x + y'), {x: R, y: R});
test.equal(run('x = y'), {x: W, y: R});
test.equal(run('var x; x = y'), {y: R});
test.equal(run('var y; x = y'), {x: W});
test.equal(run('var x,y; x = y'), {});
test.equal(run('for (x in y);'), {x: W, y: R});
test.equal(run('for (var x in y);'), {y: R});
test.equal(run('x++'), {x: W});
test.equal(run('var x = y'), {y: R});
test.equal(run('a.b[c.d]'), {'a.b': R, 'c.d': R});
test.equal(run('foo.bar[baz][c.d].z = 3'), {'foo.bar': W, baz: R, 'c.d': R});
test.equal(run('foo.bar(baz)[c.d].z = 3'), {'foo.bar': R, baz: R, 'c.d': R});
test.equal(run('var x = y.z; x.a = y; z.b;'), {'y.z': R, 'z.b': R, 'y': R});
test.equal(run('Foo.Bar'), {'Foo.Bar': R});
test.equal(run('Foo.Bar = 3'), {'Foo.Bar': W});
test.equal(run(
'(function (a, d) { var b = a, c; return f(a.z, b.z, c.z, d.z, e.z); })()'),
{ 'f': R, 'e.z': R });
test.equal(run('try { Foo } catch (e) { e }'), {'Foo': R});
test.equal(run('try { Foo } catch (e) { Foo }'), {'Foo': R});
test.equal(run('try { Foo } catch (Foo) { Foo }'), {'Foo': R});
test.equal(run('try { e } catch (Foo) { Foo }'), {'e': R});
test.equal(run('var x = function y () { return String(y); }'), {'String': R});
test.equal(run('a[b=c] = d'), {a: W, b: W, c: R, d: R});
test.equal(run('a.a.a[b.b.b=c.c.c] = d.d.d'),
{'a.a.a': W, 'b.b.b': W, 'c.c.c': R, 'd.d.d': R});
// Without ignoreEval, this thinks J is global.
test.equal(run('function x(){var J;J=3;eval("foo");}'), {eval: R});
});
Tinytest.add("js-analyze - findAssignedGlobals", function (test) {
var run = function (source, expected) {

View File

@@ -1,5 +1,6 @@
Package.describe({
summary: "Tests for JavaScript code analysis for Meteor"
summary: "Tests for JavaScript code analysis for Meteor",
internal: true
});
// The tests are in a separate package so that it is possible to compile

View File

@@ -3,11 +3,13 @@
"esprima": {
"from": "https://github.com/ariya/esprima/tarball/2a41dbf0ddadade0b09a9a7cc9a0c8df9c434018"
},
"estraverse": {
"version": "1.1.2-1"
},
"escope": {
"from": "https://github.com/meteor/escope/tarball/fef31f03797be5718080f811f9ca6e5297c90d2a"
"version": "1.0.0",
"dependencies": {
"estraverse": {
"version": "1.3.1"
}
}
}
}
}

View File

@@ -1,14 +1,8 @@
var esprima = Npm.require('esprima');
var escope = Npm.require('escope');
var estraverse = Npm.require('estraverse');
var Syntax = estraverse.Syntax;
JSAnalyze = {};
JSAnalyze.READ = 1;
JSAnalyze.WRITE = 2;
// Like esprima.parse, but annotates any thrown error with $ParseError = true.
var esprimaParse = function (source) {
try {
@@ -22,111 +16,6 @@ var esprimaParse = function (source) {
}
};
// Analyze the JavaScript source code `source` and return a dictionary
// of all the global dotted references.
//
// A global dotted reference is an expression of the form `Foo` or
// `Foo.Bar.Baz`, where `Foo` is an access to a global variable not defined
// in `source`.
//
// The value in the dictionary is either `JSAnalyze.READ`, if the reference
// is only ever read and not written, or `JSAnalyze.WRITE` if the reference
// is written (only or in addition). A dotted reference is mapped to WRITE
// if it is every assigned to, or if it is part of a larger
// expression that is assigned to which consists of a series of dotted
// or bracketed member access expressions. For example, in
// `Foo.Bar[baz].blah = 3`, the dotted reference `Foo.Bar` is reported
// as WRITE.
JSAnalyze.findGlobalDottedRefs = function (source) {
// escope's analyzer treats vars in the top-level "Program" node as globals.
// The newline is necessary in case source ends with a comment.
source = '(function () {' + source + '\n})';
var parseTree = esprimaParse(source);
var scoper = escope.analyze(parseTree, {ignoreEval: true});
var currentScope = null;
var dottedExpressionStack = [];
var globalsFound = {};
// Add _parent pointers to the tree
estraverse.traverse(parseTree, {
enter: function (node, parent) {
node._parent = parent;
}
});
estraverse.traverse(parseTree, {
enter: function (node, parent) {
currentScope = scoper.acquire(node) || currentScope;
if (node.type === Syntax.Identifier) {
var ref = null;
// Find an `escope.Reference` in the current Scope whose
// identifier node is `===` to `node`. If found, this
// means escope determined this site to be a reference
// rather than some other identifier (like the `x` in
// `var x` or `a.x`).
for (var i = 0; i < currentScope.references.length; i++) {
if (currentScope.references[i].identifier === node) {
ref = currentScope.references[i];
break;
}
}
if (ref && ! ref.resolved) {
// global; not resolved to a local
var name = node.name;
var expr = node;
// find outer expression with dots, e.g. Foo.Bar.Baz
while (expr._parent &&
expr._parent.type === Syntax.MemberExpression &&
expr._parent.object === expr &&
! expr._parent.computed) {
expr = expr._parent;
name += '.' + expr.property.name;
}
// now expand expression to include bracketed access,
// e.g. Foo.Bar.Baz[3].blah
while (expr._parent &&
expr._parent.type === Syntax.MemberExpression &&
expr._parent.object === expr) {
expr = expr._parent;
}
var accessType;
// position of `expr`, aka `outer`, now determines whether this
// access is a READ or WRITE (which encompasses read/write)
var outer = expr;
var outerParent = expr._parent;
switch (outerParent.type) {
case Syntax.AssignmentExpression:
accessType = ((outerParent.left === outer) ? JSAnalyze.WRITE
: JSAnalyze.READ);
break;
case Syntax.UpdateExpression: // prefix or postfix `++` or `--`
accessType = JSAnalyze.WRITE;
break;
case Syntax.ForInStatement:
accessType = (outerParent.left === outer ?
JSAnalyze.WRITE : JSAnalyze.READ);
break;
default:
accessType = JSAnalyze.READ;
break;
}
globalsFound[name] = Math.max(globalsFound[name] || 0, accessType);
}
}
},
leave: function (node, parent) {
currentScope = scoper.release(node) || currentScope;
}
});
return globalsFound;
};
// Analyze the JavaScript source code `source` and return a dictionary of all
// globals which are assigned to in the package. The values in the dictionary
// are all `true`.
@@ -158,80 +47,12 @@ JSAnalyze.findAssignedGlobals = function (source) {
// But it can't pull references outward, so for our purposes it is safe to
// ignore.
var scoper = escope.analyze(parseTree, {ignoreEval: true});
var globalScope = scoper.scopes[0];
var currentScope = null;
var assignedGlobals = {};
// XXX This whole traversal is somewhat overkill. escope actually already
// calculates the implicit global variables. The following code ALMOST works,
// and doesn't even require ignoreEval:
//
// var globalScope = scoper.acquire(parseTree);
// if (globalScope.type !== 'global')
// throw new Error("Unexpected scope type " + globalScope.type);
//
// // can't use underscore because we want to have no dependencies
// for (var i = 0; i < globalScope.variables.length; ++i) {
// var variable = globalScope.variables[i];
// // Because this is the global scope, and we wrap source in a function,
// // the only variables in it should have a single implicit definition.
// if (variable.defs.length !== 1)
// throw new Error("Unexpected def length", variable.defs.length);
// if (variable.defs[0].type !== escope.Variable.ImplicitGlobalVariable)
// throw new Error("Unexpected def type", variable.defs[0].type);
// assignedGlobals[variable.name] = true;
// }
//
// Unfortunately, escope's ImplicitGlobalVariable search has several bugs.
// https://github.com/Constellation/escope/issues/17
// Traverse the tree looking for assignments to unreferenced variables.
estraverse.traverse(parseTree, {
enter: function (node, parent) {
currentScope = scoper.acquire(node) || currentScope;
// We only care about identifiers.
if (node.type !== Syntax.Identifier)
return;
// We already know this one is an assigned global.
// (Avoid using _.has here so that we have no Meteor package
// dependencies.)
if (assignedGlobals.hasOwnProperty(node.name))
return;
var ref = null;
// Find an `escope.Reference` in the current Scope whose identifier node
// is `===` to `node`. If found, this means escope determined this site
// to be a reference rather than some other identifier (like the `x` in
// `var x` or `a.x`).
for (var i = 0; i < currentScope.references.length; i++) {
if (currentScope.references[i].identifier === node) {
ref = currentScope.references[i];
break;
}
}
// If this isn't a reference at all, or it's been resolved to a local, do
// nothing.
if (!ref || ref.resolved)
return;
// OK, it's a global. But is it being assigned to? The situations where a
// global is assigned to are:
// - left-hand side of an '=' assignment
// - the `x` in `for (x in y)` (without a `var`)
// (You might think that ++, --, and the += family of operators also
// write to globals, but they can't in and of themselves conjure up
// a reference where none existed before.)
if ((parent.type === Syntax.AssignmentExpression && parent.left === node
&& parent.operator === '=')
|| (parent.type === Syntax.ForInStatement && parent.left === node)) {
assignedGlobals[node.name] = true;
}
},
leave: function (node, parent) {
currentScope = scoper.release(node) || currentScope;
}
// Underscore is not available in this package.
globalScope.implicit.variables.forEach(function (variable) {
assignedGlobals[variable.name] = true;
});
return assignedGlobals;

View File

@@ -14,10 +14,7 @@ Npm.depends({
// This code was originally written against the unreleased 1.1 branch. We can
// probably switch to a built NPM version when it gets released.
esprima: "https://github.com/ariya/esprima/tarball/2a41dbf0ddadade0b09a9a7cc9a0c8df9c434018",
estraverse: "1.1.2-1",
// Fork to add ignoreEval option.
// https://github.com/Constellation/escope/pull/18
escope: "https://github.com/meteor/escope/tarball/fef31f03797be5718080f811f9ca6e5297c90d2a"
escope: "1.0.0"
});
// This package may not depend on ANY other Meteor packages, even in the test

View File

@@ -1,5 +1,5 @@
Package.describe({
summary: "The dynamic stylesheet language."
summary: "The dynamic stylesheet language"
});
Package._transitional_registerBuildPlugin({

View File

@@ -1,18 +1,23 @@
{
"dependencies": {
"sockjs": {
"version": "0.3.7",
"version": "0.3.8",
"dependencies": {
"node-uuid": {
"version": "1.3.3"
},
"faye-websocket": {
"version": "0.4.4"
"version": "0.7.0",
"dependencies": {
"websocket-driver": {
"version": "0.3.0"
}
}
}
}
},
"websocket": {
"version": "1.0.7"
"version": "1.0.8"
}
}
}

View File

@@ -29,6 +29,10 @@ MethodInvocation = function (options) {
// reruns subscriptions
this._setUserId = options.setUserId || function () {};
// used for associating the connection with a login token so that the
// connection can be closed if the token is no longer valid
this._setLoginToken = options._setLoginToken || function () {};
// Scratch data scoped to this connection (livedata_connection on the
// client, livedata_session on the server). This is only used
// internally, but we should have real and documented API for this
@@ -48,6 +52,13 @@ _.extend(MethodInvocation.prototype, {
throw new Error("Can't call setUserId in a method after calling unblock");
self.userId = userId;
self._setUserId(userId);
},
_setLoginToken: function (token) {
this._setLoginToken(token);
this._sessionData.loginToken = token;
},
_getLoginToken: function (token) {
return this._sessionData.loginToken;
}
});

View File

@@ -212,7 +212,7 @@ _.extend(SessionCollectionView.prototype, {
/* Session */
/******************************************************************************/
var Session = function (server, version) {
var Session = function (server, version, socket) {
var self = this;
self.id = Random.id();
@@ -220,18 +220,11 @@ var Session = function (server, version) {
self.version = version;
self.initialized = false;
self.socket = null;
self.last_connect_time = 0;
self.last_detach_time = +(new Date);
self.socket = socket;
self.in_queue = [];
self.inQueue = [];
self.blocked = false;
self.worker_running = false;
self.out_queue = [];
// id of invocation => {result or error, when}
self.result_cache = {};
self.workerRunning = false;
// Sub objects for active subscriptions
self._namedSubs = {};
@@ -257,6 +250,13 @@ var Session = function (server, version) {
// when we are rerunning subscriptions, any ready messages
// we want to buffer up for when we are done rerunning subscriptions
self._pendingReady = [];
socket.send(stringifyDDP({msg: 'connected',
session: self.id}));
// On initial connect, spin up all the universal publishers.
Fiber(function () {
self.startUniversalSubs();
}).run();
};
_.extend(Session.prototype, {
@@ -340,32 +340,7 @@ _.extend(Session.prototype, {
var view = self.getCollectionView(collectionName);
view.changed(subscriptionHandle, id, fields);
},
// Connect a new socket to this session, displacing (and closing)
// any socket that was previously connected
connect: function (socket) {
var self = this;
if (self.socket) {
self.socket.close();
self.detach(self.socket);
}
self.socket = socket;
self.last_connect_time = +(new Date);
_.each(self.out_queue, function (msg) {
if (Meteor._printSentDDP)
Meteor._debug("Sent DDP", stringifyDDP(msg));
self.socket.send(stringifyDDP(msg));
});
self.out_queue = [];
// On initial connect, spin up all the universal publishers.
if (!self.initialized) {
self.initialized = true;
Fiber(function () {
self.startUniversalSubs();
}).run();
}
},
startUniversalSubs: function () {
var self = this;
@@ -378,70 +353,33 @@ _.extend(Session.prototype, {
});
},
// If 'socket' is the socket currently connected to this session,
// detach it (the session will then have no socket -- it will
// continue running and queue up its messages.) If 'socket' isn't
// the currently connected socket, just clean up the pointer that
// may have led us to believe otherwise.
detach: function (socket) {
var self = this;
if (socket === self.socket) {
self.socket = null;
self.last_detach_time = +(new Date);
}
if (socket.meteor_session === self)
socket.meteor_session = null;
},
// Should be called periodically to prune the method invocation
// replay cache.
cleanup: function () {
var self = this;
// Only prune if we're connected, and we've been connected for at
// least five minutes. That seems like enough time for the client
// to finish its reconnection. Then, keep five minutes of
// history. That seems like enough time for the client to receive
// our responses, or else for us to notice that the connection is
// gone.
var now = +(new Date);
if (!(self.socket && (now - self.last_connect_time) > 5 * 60 * 1000))
return; // not connected, or not connected long enough
var kill = [];
_.each(self.result_cache, function (info, id) {
if (now - info.when > 5 * 60 * 1000)
kill.push(id);
});
_.each(kill, function (id) {
delete self.result_cache[id];
});
},
// Destroy this session. Stop all processing and tear everything
// down. If a socket was attached, close it.
destroy: function () {
var self = this;
if (self.socket) {
self.socket.close();
self.detach(self.socket);
self.socket._meteorSession = null;
}
self._deactivateAllSubscriptions();
Meteor.defer(function () {
// stop callbacks can yield, so we defer this on destroy.
// see also _closeAllForTokens and its desire to destroy things in a loop.
self._deactivateAllSubscriptions();
});
// Drop the merge box data immediately.
self.collectionViews = {};
self.in_queue = [];
self.out_queue = [];
self.inQueue = null;
},
// Send a message (queueing it if no socket is connected right now.)
// Send a message (doing nothing if no socket is connected right now.)
// It should be a JSON object (it will be stringified.)
send: function (msg) {
var self = this;
if (Meteor._printSentDDP)
Meteor._debug("Sent DDP", stringifyDDP(msg));
if (self.socket)
if (self.socket) {
if (Meteor._printSentDDP)
Meteor._debug("Sent DDP", stringifyDDP(msg));
self.socket.send(stringifyDDP(msg));
else
self.out_queue.push(msg);
}
},
// Send a connection error.
@@ -468,20 +406,20 @@ _.extend(Session.prototype, {
// way, but it's the easiest thing that's correct. (unsub needs to
// be ordered against sub, methods need to be ordered against each
// other.)
processMessage: function (msg_in, socket) {
processMessage: function (msg_in) {
var self = this;
if (socket !== self.socket)
if (!self.inQueue) // we have been destroyed.
return;
self.in_queue.push(msg_in);
if (self.worker_running)
self.inQueue.push(msg_in);
if (self.workerRunning)
return;
self.worker_running = true;
self.workerRunning = true;
var processNext = function () {
var msg = self.in_queue.shift();
var msg = self.inQueue && self.inQueue.shift();
if (!msg) {
self.worker_running = false;
self.workerRunning = false;
return;
}
@@ -569,18 +507,6 @@ _.extend(Session.prototype, {
msg: 'updated', methods: [msg.id]});
});
// check for a replayed method (this is important during
// reconnect)
if (_.has(self.result_cache, msg.id)) {
// found -- just resend whatever we sent last time
var payload = _.clone(self.result_cache[msg.id]);
delete payload.when;
self.send(
_.extend({msg: 'result', id: msg.id}, payload));
fence.arm();
return;
}
// find the handler
var handler = self.server.method_handlers[msg.method];
if (!handler) {
@@ -595,9 +521,15 @@ _.extend(Session.prototype, {
self._setUserId(userId);
};
var setLoginToken = function (newToken) {
self._setLoginToken(newToken);
};
var invocation = new MethodInvocation({
isSimulation: false,
userId: self.userId, setUserId: setUserId,
userId: self.userId,
setUserId: setUserId,
_setLoginToken: setLoginToken,
unblock: unblock,
sessionData: self.sessionData
});
@@ -622,7 +554,6 @@ _.extend(Session.prototype, {
var payload =
exception ? {error: exception} : (result !== undefined ?
{result: result} : {});
self.result_cache[msg.id] = _.extend({when: +(new Date)}, payload);
self.send(_.extend({msg: 'result', id: msg.id}, payload));
}
},
@@ -652,6 +583,19 @@ _.extend(Session.prototype, {
});
},
// XXX This mixes accounts concerns (login tokens) into livedata, which is not
// ideal. Eventually we'll have an API that allows accounts to keep track of
// which connections are associated with tokens and close them when necessary,
// rather than the current state of things where accounts tells livedata which
// connections are associated with which tokens, and when to close connections
// associated with a given token.
_setLoginToken: function (newToken) {
var self = this;
var oldToken = self.sessionData.loginToken;
self.sessionData.loginToken = newToken;
self.server._loginTokenChanged(self, newToken, oldToken);
},
// Sets the current user id in all appropriate contexts and reruns
// all subscriptions
_setUserId: function(userId) {
@@ -714,12 +658,6 @@ _.extend(Session.prototype, {
self._pendingReady = [];
}
});
// XXX figure out the login token that was just used, and set up an observe
// on the user doc so that deleting the user or the login token disconnects
// the session. For now, if you want to make sure that your deleted users
// don't have any continuing sessions, you can restart the server, but we
// should make it automatic.
},
_startSubscription: function (handler, subId, params, name) {
@@ -895,6 +833,12 @@ _.extend(Subscription.prototype, {
cur._publishCursor(self);
});
self.ready();
} else if (res) {
// truthy values other than cursors or arrays are probably a
// user mistake (possible returning a Mongo document via, say,
// `coll.findOne()`).
self.error(new Error("Publish function can only return a Cursor or "
+ "an array of Cursors"));
}
},
@@ -1027,11 +971,23 @@ Server = function () {
self.sessions = {}; // map from id to session
// Keeps track of the open connections associated with particular login
// tokens. Used for logging out all a user's open connections, expiring login
// tokens, etc.
// XXX This mixes accounts concerns (login tokens) into livedata, which is not
// ideal. Eventually we'll have an API that allows accounts to keep track of
// which connections are associated with tokens and close them when necessary,
// rather than the current state of things where accounts tells livedata which
// connections are associated with which tokens, and when to close connections
// associated with a given token.
self.sessionsByLoginToken = {};
self.stream_server = new StreamServer;
self.stream_server.register(function (socket) {
// socket implements the SockJSConnection interface
socket.meteor_session = null;
socket._meteorSession = null;
var sendError = function (reason, offendingMessage) {
var msg = {msg: 'error', reason: reason};
@@ -1057,7 +1013,7 @@ Server = function () {
}
if (msg.msg === 'connect') {
if (socket.meteor_session) {
if (socket._meteorSession) {
sendError("Already connected", msg);
return;
}
@@ -1065,44 +1021,26 @@ Server = function () {
return;
}
if (!socket.meteor_session) {
if (!socket._meteorSession) {
sendError('Must connect first', msg);
return;
}
socket.meteor_session.processMessage(msg, socket);
socket._meteorSession.processMessage(msg);
} catch (e) {
// XXX print stack nicely
Meteor._debug("Internal exception while processing message", msg,
e.stack);
e.message, e.stack);
}
});
socket.on('close', function () {
if (socket.meteor_session)
socket.meteor_session.detach(socket);
});
});
// Every minute, clean up sessions that have been abandoned for a
// minute. Also run result cache cleanup.
// XXX at scale, we'll want to have a separate timer for each
// session, and stagger them
// XXX when we get resume working again, we might keep sessions
// open longer (but stop running their diffs!)
Meteor.setInterval(function () {
var now = +(new Date);
var destroyedIds = [];
_.each(self.sessions, function (s, id) {
s.cleanup();
if (!s.socket && (now - s.last_detach_time) > 60 * 1000) {
s.destroy();
destroyedIds.push(id);
if (socket._meteorSession) {
Fiber(function () {
self._destroySession(socket._meteorSession);
}).run();
}
});
_.each(destroyedIds, function (id) {
delete self.sessions[id];
});
}, 1 * 60 * 1000);
});
};
_.extend(Server.prototype, {
@@ -1110,19 +1048,13 @@ _.extend(Server.prototype, {
_handleConnect: function (socket, msg) {
var self = this;
// In the future, handle session resumption: something like:
// socket.meteor_session = self.sessions[msg.session]
// socket._meteorSession = self.sessions[msg.session]
var version = calculateVersion(msg.support, SUPPORTED_DDP_VERSIONS);
if (msg.version === version) {
// Creating a new session
socket.meteor_session = new Session(self, version);
self.sessions[socket.meteor_session.id] = socket.meteor_session;
socket.send(stringifyDDP({msg: 'connected',
session: socket.meteor_session.id}));
// will kick off previous connection, if any
socket.meteor_session.connect(socket);
socket._meteorSession = new Session(self, version, socket);
self.sessions[socket._meteorSession.id] = socket._meteorSession;
} 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
@@ -1216,6 +1148,21 @@ _.extend(Server.prototype, {
}
},
_destroySession: function (session) {
var self = this;
delete self.sessions[session.id];
if (session.sessionData.loginToken) {
self.sessionsByLoginToken[session.sessionData.loginToken] = _.without(
self.sessionsByLoginToken[session.sessionData.loginToken],
session.id
);
if (_.isEmpty(self.sessionsByLoginToken[session.sessionData.loginToken])) {
delete self.sessionsByLoginToken[session.sessionData.loginToken];
}
}
session.destroy();
},
methods: function (methods) {
var self = this;
_.each(methods, function (func, name) {
@@ -1270,17 +1217,27 @@ _.extend(Server.prototype, {
var setUserId = function() {
throw new Error("Can't call setUserId on a server initiated method call");
};
var setLoginToken = function () {
// XXX is this correct?
throw new Error("Can't call _setLoginToken on a server " +
"initiated method call");
};
var currentInvocation = DDP._CurrentInvocation.get();
if (currentInvocation) {
userId = currentInvocation.userId;
setUserId = function(userId) {
currentInvocation.setUserId(userId);
};
setLoginToken = function (newToken) {
currentInvocation._setLoginToken(newToken);
};
}
var invocation = new MethodInvocation({
isSimulation: false,
userId: userId, setUserId: setUserId,
userId: userId,
setUserId: setUserId,
_setLoginToken: setLoginToken,
sessionData: self.sessionData
});
try {
@@ -1305,6 +1262,42 @@ _.extend(Server.prototype, {
if (exception)
throw exception;
return result;
},
_loginTokenChanged: function (session, newToken, oldToken) {
var self = this;
if (oldToken) {
// Remove the session from the list of open sessions for the old token.
self.sessionsByLoginToken[oldToken] = _.without(
self.sessionsByLoginToken[oldToken],
session.id
);
if (_.isEmpty(self.sessionsByLoginToken[oldToken]))
delete self.sessionsByLoginToken[oldToken];
}
if (newToken) {
if (! _.has(self.sessionsByLoginToken, newToken))
self.sessionsByLoginToken[newToken] = [];
self.sessionsByLoginToken[newToken].push(session.id);
}
},
// Close all open sessions associated with any of the tokens in
// `tokens`.
_closeAllForTokens: function (tokens) {
var self = this;
_.each(tokens, function (token) {
if (_.has(self.sessionsByLoginToken, token)) {
// _destroySession modifies sessionsByLoginToken, so we clone it.
_.each(EJSON.clone(self.sessionsByLoginToken[token]), function (sessionId) {
// Destroy session and remove from self.sessions.
var session = self.sessions[sessionId];
if (session) {
self._destroySession(session);
}
});
}
});
}
});

View File

@@ -10,9 +10,13 @@ Meteor.methods({
check(arguments, [Match.Any]);
return arguments[0];
},
exception: function (where, intended) {
exception: function (where, options) {
check(where, String);
check(intended, Match.Optional(Boolean));
check(options, Match.Optional({
intended: Match.Optional(Boolean),
throwThroughFuture: Match.Optional(Boolean)
}));
options = options || {};
var shouldThrow =
(Meteor.isServer && where === "server") ||
(Meteor.isClient && where === "client") ||
@@ -20,11 +24,20 @@ Meteor.methods({
if (shouldThrow) {
var e;
if (intended)
if (options.intended)
e = new Meteor.Error(999, "Client-visible test exception");
else
e = new Error("Test method throwing an exception");
e.expected = true;
// We used to improperly serialize errors that were thrown through a
// future first.
if (Meteor.isServer && options.throwThroughFuture) {
var Future = Npm.require('fibers/future');
var f = new Future;
f['throw'](e);
e = f.wait();
}
throw e;
}
},

View File

@@ -124,6 +124,8 @@ testAsyncMulti("livedata - basic method invocation", [
echoTest({$date: 30}), // literal
echoTest({$literal: {$date: 30}}),
echoTest(12),
echoTest(Infinity),
echoTest(-Infinity),
function (test, expect) {
if (Meteor.isServer)
@@ -214,7 +216,17 @@ testAsyncMulti("livedata - basic method invocation", [
if (Meteor.isServer) {
var threw = false;
try {
Meteor.call("exception", "both", true);
Meteor.call("exception", "both", {intended: true});
} catch (e) {
threw = true;
test.equal(e.error, 999);
test.equal(e.reason, "Client-visible test exception");
}
test.isTrue(threw);
threw = false;
try {
Meteor.call("exception", "both", {intended: true,
throwThroughFuture: true});
} catch (e) {
threw = true;
test.equal(e.error, 999);
@@ -225,12 +237,18 @@ testAsyncMulti("livedata - basic method invocation", [
if (Meteor.isClient) {
test.equal(
Meteor.call("exception", "both", true,
Meteor.call("exception", "both", {intended: true},
expect(failure(test, 999,
"Client-visible test exception"))),
undefined);
test.equal(
Meteor.call("exception", "server", true,
Meteor.call("exception", "server", {intended: true},
expect(failure(test, 999,
"Client-visible test exception"))),
undefined);
test.equal(
Meteor.call("exception", "server", {intended: true,
throwThroughFuture: true},
expect(failure(test, 999,
"Client-visible test exception"))),
undefined);

View File

@@ -3,8 +3,7 @@ Package.describe({
internal: true
});
Npm.depends({sockjs: "0.3.7",
websocket: "1.0.7"});
Npm.depends({sockjs: "0.3.8", websocket: "1.0.8"});
Package.on_use(function (api) {
api.use(['check', 'random', 'ejson', 'json', 'underscore', 'deps', 'logging'],

View File

@@ -142,8 +142,9 @@ _.extend(LivedataTest.ClientStream.prototype, {
self._clearConnectionTimer();
if (self.currentConnection) {
self.currentConnection.close();
var conn = self.currentConnection;
self.currentConnection = null;
conn.close();
}
},

View File

@@ -38,7 +38,10 @@ StreamServer = function () {
// combining CPU-heavy processing with SockJS termination (eg a proxy which
// converts to Unix sockets) but for now, raise the delay.
disconnect_delay: 60 * 1000,
jsessionid: false
// Set the USE_JSESSIONID environment variable to enable setting the
// JSESSIONID cookie. This is useful for setting up proxies with
// session affinity.
jsessionid: !!process.env.USE_JSESSIONID
};
// If you know your server environment (eg, proxies) will prevent websockets

View File

@@ -1,7 +1,7 @@
{
"dependencies": {
"cli-color": {
"version": "0.2.2",
"version": "0.2.3",
"dependencies": {
"es5-ext": {
"version": "0.9.2"

View File

@@ -4,7 +4,7 @@ Package.describe({
});
Npm.depends({
"cli-color": "0.2.2"
"cli-color": "0.2.3"
});
Package.on_use(function (api) {

View File

@@ -3,7 +3,7 @@ Package.describe({
});
Package.on_use(function (api) {
api.use(['livedata', 'underscore', 'spark', 'templating'], 'client');
api.use(['livedata', 'mongo-livedata', 'underscore', 'spark', 'templating'], 'client');
api.add_files([
'madewith.css',

View File

@@ -79,3 +79,13 @@ Meteor.Error = Meteor.makeErrorType(
else
self.message = '[' + self.error + ']';
});
// Meteor.Error is basically data and is sent over DDP, so you should be able to
// properly EJSON-clone it. This is especially important because if a
// Meteor.Error is thrown through a Future, the error, reason, and details
// properties become non-enumerable so a standard Object clone won't preserve
// them and they will be lost from DDP.
Meteor.Error.prototype.clone = function () {
var self = this;
return new Meteor.Error(self.error, self.reason, self.details);
};

View File

@@ -84,7 +84,7 @@ _.extend(Meteor, {
var logErr = function (err) {
if (err)
return Meteor._debug("Exception in callback of async function",
err ? err.stack : err);
err.stack ? err.stack : err);
};
// Pop off optional args that are undefined

View File

@@ -1,29 +1,24 @@
{
"dependencies": {
"clean-css": {
"version": "1.0.11",
"version": "1.1.2",
"dependencies": {
"commander": {
"version": "1.2.0",
"dependencies": {
"keypress": {
"version": "0.1.0"
}
}
"version": "2.0.0"
}
}
},
"uglify-js": {
"from": "https://github.com/mishoo/UglifyJS2/tarball/b1febde3e9be32b9d88918ed733efc3796e3f143",
"from": "https://github.com/meteor/UglifyJS2/tarball/bb0a762d12d2ecd058b9d7b57f16b4c289378d9c",
"dependencies": {
"async": {
"version": "0.2.9"
},
"source-map": {
"version": "0.1.26",
"version": "0.1.30",
"dependencies": {
"amdefine": {
"version": "0.0.5"
"version": "0.0.8"
}
}
},
@@ -34,6 +29,9 @@
"version": "0.0.2"
}
}
},
"uglify-to-browserify": {
"version": "1.0.1"
}
}
}

View File

@@ -4,9 +4,9 @@ Package.describe({
});
Npm.depends({
"clean-css": "1.0.11",
// We depend on this commit, which has not been released yet.
"uglify-js": "https://github.com/mishoo/UglifyJS2/tarball/b1febde3e9be32b9d88918ed733efc3796e3f143"
"clean-css": "1.1.2",
// Fork of 2.4.0 fixing https://github.com/mishoo/UglifyJS2/pull/308
"uglify-js": "https://github.com/meteor/UglifyJS2/tarball/bb0a762d12d2ecd058b9d7b57f16b4c289378d9c"
});
Package.on_use(function (api) {

View File

@@ -46,7 +46,7 @@ It just hasn't been looked at/thought about yet.
upsert combined with $-operators might work, but hasn't actually been
looked at or tested.
In general, the API needs tests, espectially update. (On the other
In general, the API needs tests, especially update. (On the other
hand, the underlying selector and mutator code is quite well tested.)
## OTHER STUFF ##

View File

@@ -87,12 +87,18 @@ LocalCollection.Cursor = function (collection, selector, options) {
if (LocalCollection._selectorIsId(selector)) {
// stash for fast path
self.selector_id = LocalCollection._idStringify(selector);
self.selector_f = LocalCollection._compileSelector(selector);
self.selector_f = LocalCollection._compileSelector(selector, self);
self.sort_f = undefined;
} else {
// MongoDB throws different errors on different branching operators
// containing $near
if (isGeoQuerySpecial(selector))
throw new Error("$near can't be inside $or/$and/$nor/$not");
self.selector_id = undefined;
self.selector_f = LocalCollection._compileSelector(selector);
self.sort_f = options.sort ? LocalCollection._compileSort(options.sort) : null;
self.selector_f = LocalCollection._compileSelector(selector, self);
self.sort_f = (isGeoQuery(selector) || options.sort) ?
LocalCollection._compileSort(options.sort || [], self) : null;
}
self.skip = options.skip;
self.limit = options.limit;
@@ -140,9 +146,8 @@ LocalCollection.prototype.findOne = function (selector, options) {
return this.find(selector, options).fetch()[0];
};
LocalCollection.Cursor.prototype.forEach = function (callback) {
LocalCollection.Cursor.prototype.forEach = function (callback, thisArg) {
var self = this;
var doc;
if (self.db_objects === null)
self.db_objects = self._getRawObjects(true);
@@ -155,12 +160,13 @@ LocalCollection.Cursor.prototype.forEach = function (callback) {
movedBefore: true});
while (self.cursor_pos < self.db_objects.length) {
var elt = EJSON.clone(self.db_objects[self.cursor_pos++]);
var elt = EJSON.clone(self.db_objects[self.cursor_pos]);
if (self.projection_f)
elt = self.projection_f(elt);
if (self._transform)
elt = self._transform(elt);
callback(elt);
callback.call(thisArg, elt, self.cursor_pos, self);
++self.cursor_pos;
}
};
@@ -169,11 +175,11 @@ LocalCollection.Cursor.prototype.getTransform = function () {
return self._transform;
};
LocalCollection.Cursor.prototype.map = function (callback) {
LocalCollection.Cursor.prototype.map = function (callback, thisArg) {
var self = this;
var res = [];
self.forEach(function (doc) {
res.push(callback(doc));
self.forEach(function (doc, index) {
res.push(callback.call(thisArg, doc, index, self));
});
return res;
};
@@ -473,9 +479,13 @@ LocalCollection.prototype.insert = function (doc, callback) {
LocalCollection._recomputeResults(self.queries[qid]);
});
self._observeQueue.drain();
// Defer in case the callback returns on a future; gives the caller time to
// wait on the future.
if (callback) Meteor.defer(function () { callback(null, doc._id); });
// Defer because the caller likely doesn't expect the callback to be run
// immediately.
if (callback)
Meteor.defer(function () {
callback(null, doc._id);
});
return doc._id;
};
@@ -484,7 +494,7 @@ LocalCollection.prototype.remove = function (selector, callback) {
var remove = [];
var queriesToRecompute = [];
var selector_f = LocalCollection._compileSelector(selector);
var selector_f = LocalCollection._compileSelector(selector, self);
// Avoid O(n) for "remove a single doc by ID".
var specificIds = LocalCollection._idsMatchedBySelector(selector);
@@ -533,9 +543,12 @@ LocalCollection.prototype.remove = function (selector, callback) {
LocalCollection._recomputeResults(query);
});
self._observeQueue.drain();
// Defer in case the callback returns on a future; gives the caller time to
// wait on the future.
if (callback) Meteor.defer(callback);
var result = remove.length;
if (callback)
Meteor.defer(function () {
callback(null, result);
});
return result;
};
// XXX atomicity: if multi is true, and one modification fails, do
@@ -548,10 +561,7 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) {
}
if (!options) options = {};
if (options.upsert)
throw new Error("upsert not yet implemented");
var selector_f = LocalCollection._compileSelector(selector);
var selector_f = LocalCollection._compileSelector(selector, self);
// Save the original results of any query that we might need to
// _recomputeResults on, because _modifyAndNotify will mutate the objects in
@@ -565,12 +575,15 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) {
});
var recomputeQids = {};
var updateCount = 0;
for (var id in self.docs) {
var doc = self.docs[id];
if (selector_f(doc)) {
// XXX Should we save the original even if mod ends up being a no-op?
self._saveOriginal(id, doc);
self._modifyAndNotify(doc, mod, recomputeQids);
++updateCount;
if (!options.multi)
break;
}
@@ -583,9 +596,54 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) {
qidToOriginalResults[qid]);
});
self._observeQueue.drain();
// Defer in case the callback returns on a future; gives the caller time to
// wait on the future.
if (callback) Meteor.defer(callback);
// If we are doing an upsert, and we didn't modify any documents yet, then
// it's time to do an insert. Figure out what document we are inserting, and
// generate an id for it.
var insertedId;
if (updateCount === 0 && options.upsert) {
var newDoc = LocalCollection._removeDollarOperators(selector);
LocalCollection._modify(newDoc, mod, true);
if (! newDoc._id && options.insertedId)
newDoc._id = options.insertedId;
insertedId = self.insert(newDoc);
updateCount = 1;
}
// Return the number of affected documents, or in the upsert case, an object
// containing the number of affected docs and the id of the doc that was
// inserted, if any.
var result;
if (options._returnObject) {
result = {
numberAffected: updateCount
};
if (insertedId !== undefined)
result.insertedId = insertedId;
} else {
result = updateCount;
}
if (callback)
Meteor.defer(function () {
callback(null, result);
});
return result;
};
// A convenience wrapper on update. LocalCollection.upsert(sel, mod) is
// equivalent to LocalCollection.update(sel, mod, { upsert: true, _returnObject:
// true }).
LocalCollection.prototype.upsert = function (selector, mod, options, callback) {
var self = this;
if (! callback && typeof options === "function") {
callback = options;
options = {};
}
return self.update(selector, mod, _.extend({}, options, {
upsert: true,
_returnObject: true
}), callback);
};
LocalCollection.prototype._modifyAndNotify = function (
@@ -623,7 +681,7 @@ LocalCollection.prototype._modifyAndNotify = function (
// in the output. So it's safe to skip recompute if neither before or
// after are true.)
if (before || after)
recomputeQids[qid] = true;
recomputeQids[qid] = true;
} else if (before && !after) {
LocalCollection._removeFromResults(query, doc);
} else if (!before && after) {
@@ -1033,119 +1091,113 @@ LocalCollection._compileProjection = function (fields) {
if (!_.isObject(fields))
throw MinimongoError("fields option must be an object");
// Check passed projection fields' keys:
// If you have two rules such as 'foo.bar' and 'foo.bar.baz', then the
// result becomes ambiguous. If that happens, there is a probability you are
// doing something wrong, framework should notify you about such mistake
// earlier on cursor compilation step than later during runtime.
// Note, that real mongo doesn't do anything about it and the later rule
// appears in projection project, more priority it takes.
//
// Example, assume following in mongo shell:
// > db.coll.insert({ a: { b: 23, c: 44 } })
// > db.coll.find({}, { 'a': 1, 'a.b': 1 })
// { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23 } }
// > db.coll.find({}, { 'a.b': 1, 'a': 1 })
// { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23, "c" : 44 } }
//
// Note, how second time the return set of keys is different.
var keyPaths = _.keys(fields);
_.each(keyPaths, function (keyPath) {
_.each(keyPaths, function (anotherKeyPath) {
var idx = keyPath.indexOf(anotherKeyPath);
// check if one key is path-prefix of another (like "abra" and
// "abra.cadabra", but not "abra" and "abrab.ra")
if (keyPath !== anotherKeyPath && !idx && keyPath[anotherKeyPath.length] === '.')
throw MinimongoError("both " + keyPath + " and " + anotherKeyPath +
" found in fields option, using both of them may trigger " +
"unexpected behavior. Did you mean to use only one of them?");
});
});
if (_.any(_.values(fields), function (x) {
return _.indexOf([1, 0, true, false], x) === -1; }))
throw MinimongoError("Projection values should be one of 1, 0, true, or false");
var _idProjection = _.isUndefined(fields._id) ? true : fields._id;
delete fields._id;
// Find the non-_id keys (_id is handled specially because it is included unless
// explicitly excluded). Sort the keys, so that our code to detect overlaps
// like 'foo' and 'foo.bar' can assume that 'foo' comes first.
var fieldsKeys = _.reject(_.keys(fields).sort(), function (key) { return key === '_id'; });
var including = null; // Unknown
var projectionRules = [];
var projectionRulesTree = {}; // Tree represented as nested objects
_.each(fields, function (rule, keyPath) {
rule = !!rule;
_.each(fieldsKeys, function (keyPath) {
var rule = !!fields[keyPath];
if (including === null)
including = rule;
if (including !== rule)
// This error message is copies from MongoDB shell
throw MinimongoError("You cannot currently mix including and excluding fields.");
projectionRules.push(keyPath.split('.'));
var treePos = projectionRulesTree;
keyPath = keyPath.split('.');
_.each(keyPath.slice(0, -1), function (key, idx) {
if (!_.has(treePos, key))
treePos[key] = {};
else if (_.isBoolean(treePos[key])) {
// Check passed projection fields' keys: If you have two rules such as
// 'foo.bar' and 'foo.bar.baz', then the result becomes ambiguous. If
// that happens, there is a probability you are doing something wrong,
// framework should notify you about such mistake earlier on cursor
// compilation step than later during runtime. Note, that real mongo
// doesn't do anything about it and the later rule appears in projection
// project, more priority it takes.
//
// Example, assume following in mongo shell:
// > db.coll.insert({ a: { b: 23, c: 44 } })
// > db.coll.find({}, { 'a': 1, 'a.b': 1 })
// { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23 } }
// > db.coll.find({}, { 'a.b': 1, 'a': 1 })
// { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23, "c" : 44 } }
//
// Note, how second time the return set of keys is different.
var currentPath = keyPath.join('.');
var anotherPath = keyPath.slice(0, idx + 1).join('.');
throw MinimongoError("both " + currentPath + " and " + anotherPath +
" found in fields option, using both of them may trigger " +
"unexpected behavior. Did you mean to use only one of them?");
}
treePos = treePos[key];
});
treePos[_.last(keyPath)] = including;
});
// XXX do these functions share too much in common?
if (including)
return function (doc) {
var result = {};
// returns transformed doc according to ruleTree
var transform = function (doc, ruleTree) {
// Special case for "sets"
if (_.isArray(doc))
return _.map(doc, function (subdoc) { return transform(subdoc, ruleTree); });
_.each(projectionRules, function (keyPath) {
var target = result;
var docTarget = doc;
for (var i = 0; i < keyPath.length - 1; i++) {
var key = keyPath[i];
// This block simulates MongoDB behavior for different edge-cases when
// object on certain path wasn't found or array found instead of an
// object, or vice-versa.
if (!_.has(target, key)) {
if (_.isArray(docTarget[key])) {
target[key] = [];
docTarget = undefined;
break;
} else if (_.isObject(docTarget[key]))
target[key] = {};
else {
docTarget = undefined;
break;
}
}
var res = including ? {} : EJSON.clone(doc);
_.each(ruleTree, function (rule, key) {
if (!_.has(doc, key))
return;
if (_.isObject(rule)) {
// For sub-objects/subsets we branch
if (_.isObject(doc[key]))
res[key] = transform(doc[key], rule);
// Otherwise we don't even touch this subfield
} else if (including)
res[key] = doc[key];
else
delete res[key];
});
target = target[key];
docTarget = docTarget[key];
}
return res;
};
if (keyPath.length > 0 && docTarget && _.has(docTarget, _.last(keyPath)))
target[_.last(keyPath)] = docTarget[_.last(keyPath)];
});
return function (obj) {
var res = transform(obj, projectionRulesTree);
if (_idProjection && _.has(doc, '_id'))
result._id = doc._id;
return result;
};
else
return function (doc) {
// XXX Deep copy on this level might be a slowing factor,
// In fact we need it only in case of nested excluded fields.
var result = EJSON.clone(doc);
_.each(projectionRules, function (keyPath) {
var target = result;
var docTarget = doc;
for (var i = 0; i < keyPath.length - 1; i++) {
var key = keyPath[i];
if (!_.has(target, key)) {
break;
}
target = target[key];
docTarget = docTarget[key];
}
if (keyPath.length > 0)
delete target[_.last(keyPath)];
});
if (!_idProjection)
delete result._id;
return result;
};
if (_idProjection && _.has(obj, '_id'))
res._id = obj._id;
if (!_idProjection && _.has(res, '_id'))
delete res._id;
return res;
};
};
// Searches $near operator in the selector recursively
// (including all $or/$and/$nor/$not branches)
var isGeoQuery = function (selector) {
return _.any(selector, function (val, key) {
// Note: _.isObject matches objects and arrays
return key === "$near" || (_.isObject(val) && isGeoQuery(val));
});
};
// Checks if $near appears under some $or/$and/$nor/$not branch
var isGeoQuerySpecial = function (selector) {
return _.any(selector, function (val, key) {
if (_.contains(['$or', '$and', '$nor', '$not'], key))
return isGeoQuery(val);
// Note: _.isObject matches objects and arrays
return _.isObject(val) && isGeoQuerySpecial(val);
});
};

View File

@@ -66,7 +66,8 @@ var log_callbacks = function (operations) {
// XXX test shared structure in all MM entrypoints
Tinytest.add("minimongo - basics", function (test) {
var c = new LocalCollection(),
fluffyKitten_id;
fluffyKitten_id,
count;
fluffyKitten_id = c.insert({type: "kitten", name: "fluffy"});
c.insert({type: "kitten", name: "snookums"});
@@ -87,7 +88,8 @@ Tinytest.add("minimongo - basics", function (test) {
test.length(c.find({type: "kitten"}).fetch(), 2);
test.length(c.find({type: "cryptographer"}).fetch(), 2);
c.update({name: "snookums"}, {$set: {type: "cryptographer"}});
count = c.update({name: "snookums"}, {$set: {type: "cryptographer"}});
test.equal(count, 1);
test.equal(c.find().count(), 4);
test.equal(c.find({type: "kitten"}).count(), 1);
test.equal(c.find({type: "cryptographer"}).count(), 3);
@@ -102,10 +104,12 @@ Tinytest.add("minimongo - basics", function (test) {
c.remove({_id: null});
c.remove({_id: false});
c.remove({_id: undefined});
c.remove();
count = c.remove();
test.equal(count, 0);
test.equal(c.find().count(), 4);
c.remove({});
count = c.remove({});
test.equal(count, 4);
test.equal(c.find().count(), 0);
c.insert({_id: 1, name: "strawberry", tags: ["fruit", "red", "squishy"]});
@@ -180,16 +184,25 @@ Tinytest.add("minimongo - cursors", function (test) {
// forEach
var count = 0;
q.forEach(function (obj) {
var context = {};
q.forEach(function (obj, i, cursor) {
test.equal(obj.i, count++);
});
test.equal(obj.i, i);
test.isTrue(context === this);
test.isTrue(cursor === q);
}, context);
test.equal(count, 20);
// everything empty
test.length(q.fetch(), 0);
q.rewind();
// map
res = q.map(function (obj) { return obj.i * 2; });
res = q.map(function (obj, i, cursor) {
test.equal(obj.i, i);
test.isTrue(context === this);
test.isTrue(cursor === q);
return obj.i * 2;
}, context);
test.length(res, 20);
for (var i = 0; i < 20; i++)
test.equal(res[i], i * 2);
@@ -427,6 +440,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: {$ne: 1}}, {a: [1, 2]});
nomatch({a: {$ne: 2}}, {a: [1, 2]});
match({a: {$ne: 3}}, {a: [1, 2]});
nomatch({'a.b': {$ne: 1}}, {a: [{b: 1}, {b: 2}]});
nomatch({'a.b': {$ne: 2}}, {a: [{b: 1}, {b: 2}]});
match({'a.b': {$ne: 3}}, {a: [{b: 1}, {b: 2}]});
nomatch({a: {$ne: {x: 1}}}, {a: {x: 1}});
match({a: {$ne: {x: 1}}}, {a: {x: 2}});
@@ -456,7 +472,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
nomatch({a: {$nin: [1, 2, 3]}}, {a: [2]}); // tested against mongodb
nomatch({a: {$nin: [{x: 1}, {x: 2}, {x: 3}]}}, {a: [{x: 2}]});
nomatch({a: {$nin: [1, 2, 3]}}, {a: [4, 2]});
nomatch({'a.b': {$nin: [1, 2, 3]}}, {a: [{b:4}, {b:2}]});
match({a: {$nin: [1, 2, 3]}}, {a: [4]});
match({'a.b': {$nin: [1, 2, 3]}}, {a: [{b:4}]});
// $size
match({a: {$size: 0}}, {a: []});
@@ -564,7 +582,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) {
match({x: {$not: {$lt: 10, $gt: 7}}}, {x: 6});
match({x: {$not: {$gt: 7}}}, {x: [2, 3, 4]});
match({'x.y': {$not: {$gt: 7}}}, {x: [{y:2}, {y:3}, {y:4}]});
nomatch({x: {$not: {$gt: 7}}}, {x: [2, 3, 4, 10]});
nomatch({'x.y': {$not: {$gt: 7}}}, {x: [{y:2}, {y:3}, {y:4}, {y:10}]});
match({x: {$not: /a/}}, {x: "dog"});
nomatch({x: {$not: /a/}}, {x: "cat"});
@@ -898,7 +918,7 @@ Tinytest.add("minimongo - projection_compiler", function (test) {
var testProjection = function (projection, tests) {
var projection_f = LocalCollection._compileProjection(projection);
var equalNonStrict = function (a, b, desc) {
test.equal(EJSON.stringify(a), EJSON.stringify(b), desc);
test.isTrue(_.isEqual(a, b), desc);
};
_.each(tests, function (testCase) {
@@ -983,6 +1003,11 @@ Tinytest.add("minimongo - projection_compiler", function (test) {
[ { a: { b: 42 } }, { a: { b: 42 } }, "Can't have ambiguous rules (one is prefix of another)" ]
]);
});
test.throws(function () {
testProjection({ 'a.b.c': 1, 'a.b': 1, 'a': 1 }, [
[ { a: { b: 42 } }, { a: { b: 42 } }, "Can't have ambiguous rules (one is prefix of another)" ]
]);
});
test.throws(function () {
testProjection("some string", [
@@ -1069,6 +1094,63 @@ Tinytest.add("minimongo - fetch with fields", function (test) {
});
});
Tinytest.add("minimongo - fetch with projection, subarrays", function (test) {
// Apparently projection of type 'foo.bar.x' for
// { foo: [ { bar: { x: 42 } }, { bar: { x: 3 } } ] }
// should return exactly this object. More precisely, arrays are considered as
// sets and are queried separately and then merged back to result set
var c = new LocalCollection();
// Insert a test object with two set fields
c.insert({
setA: [{
fieldA: 42,
fieldB: 33
}, {
fieldA: "the good",
fieldB: "the bad",
fieldC: "the ugly"
}],
setB: [{
anotherA: { },
anotherB: "meh"
}, {
anotherA: 1234,
anotherB: 431
}]
});
var equalNonStrict = function (a, b, desc) {
test.isTrue(_.isEqual(a, b), desc);
};
var testForProjection = function (projection, expected) {
var fetched = c.find({}, { fields: projection }).fetch()[0];
equalNonStrict(fetched, expected, "failed sub-set projection: " +
JSON.stringify(projection));
};
testForProjection({ 'setA.fieldA': 1, 'setB.anotherB': 1, _id: 0 },
{
setA: [{ fieldA: 42 }, { fieldA: "the good" }],
setB: [{ anotherB: "meh" }, { anotherB: 431 }]
});
testForProjection({ 'setA.fieldA': 0, 'setB.anotherA': 0, _id: 0 },
{
setA: [{fieldB:33}, {fieldB:"the bad",fieldC:"the ugly"}],
setB: [{ anotherB: "meh" }, { anotherB: 431 }]
});
c.remove({});
c.insert({a:[[{b:1,c:2},{b:2,c:4}],{b:3,c:5},[{b:4, c:9}]]});
testForProjection({ 'a.b': 1, _id: 0 },
{a: [ [ { b: 1 }, { b: 2 } ], { b: 3 }, [ { b: 4 } ] ] });
testForProjection({ 'a.b': 0, _id: 0 },
{a: [ [ { c: 2 }, { c: 4 } ], { c: 5 }, [ { c: 9 } ] ] });
});
Tinytest.add("minimongo - observe ordered with projection", function (test) {
// These tests are copy-paste from "minimongo -observe ordered",
// slightly modified to test projection
@@ -1495,6 +1577,28 @@ Tinytest.add("minimongo - modify", function (test) {
modify({a: [1]}, {$push: {a: [2]}}, {a: [1, [2]]});
modify({a: []}, {$push: {'a.1': 99}}, {a: [null, [99]]}); // tested
modify({a: {}}, {$push: {'a.x': 99}}, {a: {x: [99]}});
modify({}, {$push: {a: {$each: [1, 2, 3]}}},
{a: [1, 2, 3]});
modify({a: []}, {$push: {a: {$each: [1, 2, 3]}}},
{a: [1, 2, 3]});
modify({a: [true]}, {$push: {a: {$each: [1, 2, 3]}}},
{a: [true, 1, 2, 3]});
// No positive numbers for $slice
exception({}, {$push: {a: {$each: [], $slice: 5}}});
modify({a: [true]}, {$push: {a: {$each: [1, 2, 3], $slice: -2}}},
{a: [2, 3]});
modify({a: [false, true]}, {$push: {a: {$each: [1], $slice: -2}}},
{a: [true, 1]});
modify(
{a: [{x: 3}, {x: 1}]},
{$push: {a: {
$each: [{x: 4}, {x: 2}],
$slice: -2,
$sort: {x: 1}
}}},
{a: [{x: 3}, {x: 4}]});
modify({}, {$push: {a: {$each: [1, 2, 3], $slice: 0}}}, {a: []});
modify({a: [1, 2]}, {$push: {a: {$each: [1, 2, 3], $slice: 0}}}, {a: []});
// $pushAll
modify({}, {$pushAll: {a: [1]}}, {a: [1]});
@@ -1596,11 +1700,8 @@ Tinytest.add("minimongo - modify", function (test) {
modify({a: {b: 12}}, {$rename: {'a.b': 'x'}}, {a: {}, x: 12}); // tested
modify({a: {b: 12}}, {$rename: {'a.b': 'q.r'}}, {a: {}, q: {r: 12}});
modify({a: {b: 12}}, {$rename: {'a.b': 'q.2.r'}}, {a: {}, q: {2: {r: 12}}});
// Opera weirdly reorders the output. But what it does tends to be close
// enough.
modify({a: {b: 12}, q: {}}, {$rename: {'a.b': 'q.2.r'}},
(typeof opera === 'undefined' ? {a: {}, q: {2: {r: 12}}}
: {q: {2: {r: 12}}, a: {}}));
{a: {}, q: {2: {r: 12}}});
exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2'}}); // tested
exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2.r'}}); // tested
test.expect_fail();
@@ -1934,7 +2035,8 @@ Tinytest.add("minimongo - diff", function (test) {
Tinytest.add("minimongo - saveOriginals", function (test) {
// set up some data
var c = new LocalCollection();
var c = new LocalCollection(),
count;
c.insert({_id: 'foo', x: 'untouched'});
c.insert({_id: 'bar', x: 'updateme'});
c.insert({_id: 'baz', x: 'updateme'});
@@ -1945,9 +2047,12 @@ Tinytest.add("minimongo - saveOriginals", function (test) {
c.saveOriginals();
c.insert({_id: "hooray", z: 'insertme'});
c.remove({y: 'removeme'});
c.update({x: 'updateme'}, {$set: {z: 5}}, {multi: true});
count = c.update({x: 'updateme'}, {$set: {z: 5}}, {multi: true});
c.update('bar', {$set: {k: 7}}); // update same doc twice
// Verify returned count is correct
test.equal(count, 2);
// Verify the originals.
var originals = c.retrieveOriginals();
var affected = ['bar', 'baz', 'quux', 'whoa', 'hooray'];
@@ -2180,3 +2285,98 @@ Tinytest.add("minimongo - count on cursor with limit", function(test){
c.stop();
});
Tinytest.add("minimongo - $near operator tests", function (test) {
var coll = new LocalCollection();
coll.insert({ rest: { loc: [2, 3] } });
coll.insert({ rest: { loc: [-3, 3] } });
coll.insert({ rest: { loc: [5, 5] } });
test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 30 } }).count(), 3);
test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 4 } }).count(), 1);
var points = coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 6 } }).fetch();
_.each(points, function (point, i, points) {
test.isTrue(!i || distance([0, 0], point.rest.loc) >= distance([0, 0], points[i - 1].rest.loc));
});
function distance(a, b) {
var x = a[0] - b[0];
var y = a[1] - b[1];
return Math.sqrt(x * x + y * y);
}
// GeoJSON tests
coll = new LocalCollection();
var data = [{ "category" : "BURGLARY", "descript" : "BURGLARY OF STORE, FORCIBLE ENTRY", "address" : "100 Block of 10TH ST", "location" : { "type" : "Point", "coordinates" : [ -122.415449723856, 37.7749518087273 ] } },
{ "category" : "WEAPON LAWS", "descript" : "POSS OF PROHIBITED WEAPON", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415386041221, 37.7747879744156 ] } },
{ "category" : "LARCENY/THEFT", "descript" : "GRAND THEFT OF PROPERTY", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.41538270191, 37.774683628213 ] } },
{ "category" : "LARCENY/THEFT", "descript" : "PETTY THEFT FROM LOCKED AUTO", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415396041221, 37.7747879744156 ] } },
{ "category" : "OTHER OFFENSES", "descript" : "POSSESSION OF BURGLARY TOOLS", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415386041221, 37.7747879734156 ] } }
];
_.each(data, function (x, i) { coll.insert(_.extend(x, { x: i })); });
var close15 = coll.find({ location: { $near: {
$geometry: { type: "Point",
coordinates: [-122.4154282, 37.7746115] },
$maxDistance: 15 } } }).fetch();
test.length(close15, 1);
test.equal(close15[0].descript, "GRAND THEFT OF PROPERTY");
var close20 = coll.find({ location: { $near: {
$geometry: { type: "Point",
coordinates: [-122.4154282, 37.7746115] },
$maxDistance: 20 } } }).fetch();
test.length(close20, 4);
test.equal(close20[0].descript, "GRAND THEFT OF PROPERTY");
test.equal(close20[1].descript, "PETTY THEFT FROM LOCKED AUTO");
test.equal(close20[2].descript, "POSSESSION OF BURGLARY TOOLS");
test.equal(close20[3].descript, "POSS OF PROHIBITED WEAPON");
// Any combinations of $near with $or/$and/$nor/$not should throw an error
test.throws(function () {
coll.find({ location: {
$not: {
$near: {
$geometry: {
type: "Point",
coordinates: [-122.4154282, 37.7746115]
}, $maxDistance: 20 } } } });
});
test.throws(function () {
coll.find({
$and: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}},
{ x: 0 }]
});
});
test.throws(function () {
coll.find({
$or: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}},
{ x: 0 }]
});
});
test.throws(function () {
coll.find({
$nor: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 1 }}},
{ x: 0 }]
});
});
test.throws(function () {
coll.find({
$and: [{
$and: [{
location: {
$near: {
$geometry: {
type: "Point",
coordinates: [-122.4154282, 37.7746115]
},
$maxDistance: 1
}
}
}]
}]
});
});
});

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