diff --git a/.gitignore b/.gitignore index 46396713f1..0bac695bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ /dev_bundle /dev_bundle*.tar.gz /dist -\#*# +\#*\# +.\#* .idea diff --git a/History.md b/History.md index 587dbd676a..555fdf52ca 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,65 @@ ## vNEXT +## v0.5.0 + +* This release introduces Meteor Accounts, a full-featured auth system that supports + - fine-grained user-based control over database reads and writes + - federated login with any OAuth provider (with built-in support for + Facebook, GitHub, Google, Twitter, and Weibo) + - secure password login + - email validation and password recovery + - an optional set of UI widgets implementing standard login/signup/password + change/logout flows + + When you upgrade to Meteor 0.5.0, existing apps will lose the ability to write + to the database from the client. To restore this, either: + - configure each of your collections with + [`collection.allow`](http://docs.meteor.com/#allow) and + [`collection.deny`](http://docs.meteor.com/#deny) calls to specify which + users can perform which write operations, or + - add the `insecure` smart package (which is included in new apps by default) + to restore the old behavior where anyone can write to any collection which + has not been configured with `allow` or `deny` + + For more information on Meteor Accounts, see + http://docs.meteor.com/#dataandsecurity and + http://docs.meteor.com/#accounts_api + +* The new function `Meteor.autorun` allows you run any code in a reactive + context. See http://docs.meteor.com/#meteor_autorun + +* Arrays and objects can now be stored in the `Session`; mutating the value you + retrieve with `Session.get` does not affect the value in the session. + +* On the client, `Meteor.apply` takes a new `wait` option, which ensures that no + further method calls are sent to the server until this method is finished; it + is used for login and logout methods in order to keep the user ID + well-defined. You can also specifiy an `onReconnect` handler which is run when + re-establishing a connection; Meteor Accounts uses this to log back in on + reconnect. + +* Meteor now provides a compatible replacement for the DOM `localStorage` + facility that works in IE7, in the `localstorage-polyfill` smart package. + +* Meteor now packages the D3 library for manipulating documents based on data in + a smart package called `d3`. + +* `Meteor.Collection` now takes its optional `manager` argument (used to + associate a collection with a server you've connected to with + `Meteor.connect`) as a named option. (The old call syntax continues to work + for now.) + +* Fix a bug where trying to immediately resubscribe to a record set after + unsubscribing could fail silently. + +* Better error handling for failed Mongo writes from inside methods; previously, + errors here could cause clients to stop processing data from the server. + + +Patches contributed by GitHub users bradens, dandv, dybskiy, possibilities, +zhangcheng, and 75lb. + ## v0.4.2 diff --git a/LICENSE.txt b/LICENSE.txt index 596f55370b..df6d73d5cb 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -356,6 +356,11 @@ Copyright (c) 2011: Tim Koschützki (tim@debuggable.com) Felix Geisendörfer (felix@debuggable.com) +---------- +node-form-data: https://github.com/felixge/node-form-data +---------- + +Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors ============== @@ -622,6 +627,11 @@ npmlog: https://github.com/isaacs/npmlog once: https://github.com/isaacs/once osenv: https://github.com/isaacs/osenv mute-stream: https://github.com/isaacs/mute-stream +couch-login: https://github.com/isaacs/couch-login +npmconf: https://github.com/isaacs/npmconf +read-installed: https://github.com/isaacs/read-installed +read-package-json: https://github.com/isaacs/read-package-json +promzard: https://github.com/isaacs/promzard ---------- Copyright (c) Isaac Z. Schlueter ("Author") @@ -685,6 +695,104 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +---------- +jsbn: http://www-cs-students.stanford.edu/~tjw/jsbn/ +---------- + +Copyright (c) 2003-2005 Tom Wu +All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, +EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY +WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, +INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF +THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +In addition, the following condition applies: + +All redistributions must retain an intact copy of this copyright notice +and disclaimer. + + +---------- +jsSHA2: http://anmar.eu.org/projects/jssha2/ +with Unicode support (Utf8Encode function) added by + http://www.webtoolkit.info/javascript-sha256.html +---------- + +Copyright (c) 2003-2004, Angel Marin +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. + + +---------- +D3: http://d3js.org/ +---------- + +Copyright (c) 2012, Michael Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* The name Michael Bostock may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ============= @@ -775,6 +883,36 @@ By Isaac Z. Schlueter (http://blog.izs.me/) 0. You just DO WHAT THE FUCK YOU WANT TO. +---------- +node-stream-buffer: https://github.com/samcday/node-stream-buffer +---------- + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to + + ---------- mongodb: http://www.mongodb.org/ ---------- @@ -1636,3 +1774,17 @@ maintained libraries. The externally maintained libraries used by Node are: THE SOFTWARE. """ + + +---------- +OpenStreetMap: http://openstreetmap.org/ + (San Francisco SOMA map in examples/parties/public/soma.jpeg) +---------- + +OpenStreetMap is open data, licensed under the Open Data Commons Open Database +License (ODbL): http://opendatacommons.org/licenses/odbl/1.0/. Any rights in +individual contents of the database are licensed under the Database Contents +License: http://opendatacommons.org/licenses/dbcl/1.0/ The cartography is +licensed under the under the Creative Commons Attribution-ShareAlike 2.0 license +(CC-BY-SA). Full information is available at +http://www.openstreetmap.org/copyright diff --git a/admin/debian/changelog b/admin/debian/changelog index 684a5f5dcc..8cf191a410 100644 --- a/admin/debian/changelog +++ b/admin/debian/changelog @@ -1,4 +1,4 @@ -meteor (0.4.2-1) unstable; urgency=low +meteor (0.5.0-1) unstable; urgency=low * Automated debian build. diff --git a/admin/install-s3.sh b/admin/install-s3.sh index 0a474805e7..e0b027e9db 100755 --- a/admin/install-s3.sh +++ b/admin/install-s3.sh @@ -5,7 +5,7 @@ ## example. URLBASE="https://d3sqy0vbqsdhku.cloudfront.net" -VERSION="0.4.2" +VERSION="0.5.0" PKGVERSION="${VERSION}-1" UNAME=`uname` diff --git a/admin/manifest.json b/admin/manifest.json index efe73ba21e..46ec2a2c74 100644 --- a/admin/manifest.json +++ b/admin/manifest.json @@ -1,6 +1,6 @@ { - "version": "0.4.2", - "deb_version": "0.4.2-1", - "rpm_version": "0.4.2-1", + "version": "0.5.0", + "deb_version": "0.5.0-1", + "rpm_version": "0.5.0-1", "urlbase": "https://d3sqy0vbqsdhku.cloudfront.net" } diff --git a/admin/meteor.spec b/admin/meteor.spec index 87f4b59aea..4381b8a925 100644 --- a/admin/meteor.spec +++ b/admin/meteor.spec @@ -5,7 +5,7 @@ Summary: Meteor platform and JavaScript application server Vendor: Meteor Name: meteor -Version: 0.4.2 +Version: 0.5.0 Release: 1 License: MIT Group: Networking/WWW diff --git a/admin/spark-standalone.sh b/admin/spark-standalone.sh index 2b38d4a5a3..e84c3bdbd0 100755 --- a/admin/spark-standalone.sh +++ b/admin/spark-standalone.sh @@ -9,6 +9,7 @@ PACKAGES_DIR=`dirname $0`/../packages echo 'Meteor = {};' cat $PACKAGES_DIR/uuid/uuid.js cat $PACKAGES_DIR/deps/deps.js +cat $PACKAGES_DIR/deps/deps-utils.js cat $PACKAGES_DIR/liverange/liverange.js cat $PACKAGES_DIR/universal-events/listener.js cat $PACKAGES_DIR/universal-events/events-ie.js diff --git a/app/lib/updater.js b/app/lib/updater.js index 1df7ed39d7..27734303d8 100644 --- a/app/lib/updater.js +++ b/app/lib/updater.js @@ -1,4 +1,4 @@ -exports.CURRENT_VERSION = "0.4.2"; +exports.CURRENT_VERSION = "0.5.0"; var fs = require("fs"); var http = require("http"); diff --git a/app/meteor/post-upgrade.js b/app/meteor/post-upgrade.js index f14089bd6b..fc59f6d2c9 100644 --- a/app/meteor/post-upgrade.js +++ b/app/meteor/post-upgrade.js @@ -2,7 +2,7 @@ try { // XXX can't get this from updater.js because in 0.3.7 and before the // updater didn't have the right NODE_PATH set. At some point we can // remove this and just use updater.CURRENT_VERSION. - var VERSION = "0.4.2"; + var VERSION = "0.5.0"; var fs = require('fs'); var path = require('path'); diff --git a/app/meteor/skel/.meteor/packages b/app/meteor/skel/.meteor/packages index 6c923cd368..2ca3c152a4 100644 --- a/app/meteor/skel/.meteor/packages +++ b/app/meteor/skel/.meteor/packages @@ -4,4 +4,5 @@ # but you can also edit it by hand. autopublish +insecure preserve-inputs diff --git a/docs/client/api.html b/docs/client/api.html index 22dae6cc5d..4fa0dbac25 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -3,10 +3,10 @@

The Meteor API

-Your Javascript code can run in two environments: the client -(browser), and the server (a Node.js container on a server). For -each function in this API reference, we'll indicate if the function is -available just on the client, just on the server, or *Anywhere*. +Your Javascript code can run in two environments: the *client* (browser), and +the *server* (a [Node.js](http://nodejs.org/) container on a server). For each +function in this API reference, we'll indicate if the function is available just +on the client, just on the server, or *Anywhere*.

Meteor Core

@@ -47,18 +47,25 @@ will publish that cursor's documents. // server: publish the rooms collection, minus secret info. Meteor.publish("rooms", function () { - return Rooms.find({}, {fields: {secretInfo: false}}); + return Rooms.find({}, {fields: {secretInfo: 0}}); }); -Otherwise, the publish function can set and unset -individual record attributes on a client, use these methods provided by -`this` in your publish function. + // ... and publish secret info for rooms where the logged-in user + // is an admin. If the client subscribes to both streams, the records + // are merged together into the same documents in the Rooms collection. + Meteor.publish("adminSecretInfo", function () { + return Rooms.find({admin: this.userId}, {fields: {secretInfo: 1}}); + }); + +Otherwise, the publish function can [`set`](#publish_set) and +[`unset`](#publish_unset) individual record attributes on a client. These +methods are provided by `this` in your publish function. -In particular, if you use observe() to watch changes to the database, be -sure to call `this.flush` from inside your observe callbacks. Methods -that update the database are considered finished when the observe +In particular, if you use [`observe`](#observe) to watch changes to the +database, be sure to call `this.flush` from inside your observe callbacks. +Methods that update the database are considered finished when the `observe` callbacks return. Example: @@ -108,6 +115,11 @@ project that includes the `autopublish` package. Your publish function will still work. {{/warning}} +{{> api_box subscription_userId}} + +This is constant. However, if the logged-in user changes, the publish +function is rerun with the new value. + {{> api_box subscription_set}} {{> api_box subscription_unset}} {{> api_box subscription_complete}} @@ -145,9 +157,9 @@ attribute.) If all of the attributes in a document are removed, Meteor will remove the (now empty) document. If you want to publish empty -documents, just use a placeholder attribute. +documents, just use a placeholder attribute: - // Clicks.insert({exists: true}); + Clicks.insert({exists: true}); {{> api_box autosubscribe}} @@ -201,6 +213,8 @@ object, which provides the following: * `isSimulation`: a boolean value, true if this invocation is a stub. * `unblock`: when called, allows the next method from this client to begin running. +* `userId`: the id of the current user. +* `setUserId`: a function that associates the current client with a user. Calling `methods` on the client defines *stub* functions associated with server methods of the same name. You don't have to define a stub for @@ -215,6 +229,29 @@ intended to *simulate* the result of what the server's method will do, but without waiting for the round trip delay. If a stub throws an exception it will be logged to the console. + +{{> api_box method_invocation_userId}} + +The user id is an arbitrary string — typically the id of the user record +in the database. You can set it with the `setUserId` function. If you're using +the [Meteor accounts system](#accounts_api) then this is handled for you. + +{{> api_box method_invocation_setUserId}} + +Call this function to change the currently logged in user on the +connection that made this method call. This simply sets the value of +`userId` for future method calls received on this connection. Pass +`null` to log out the connection. + +If you are using the [built-in Meteor accounts system](#accounts_api) then this +should correspond to the `_id` field of a document in the +[`Meteor.users`](#meteor_users) collection. + +`setUserId` is not retroactive. It affects the current method call and +any future method calls on the connection. Any previous method calls on +this connection will still see the value of `userId` that was in effect +when they started. + {{> api_box method_invocation_isSimulation}} {{> api_box method_invocation_unblock}} @@ -238,7 +275,7 @@ This is how to invoke a method. It will run the method on the server. If a stub is available, it will also run the stub on the client. If you include a callback function as the last argument (which can't be -an argument to the method, since functions aren't serializeable), the +an argument to the method, since functions aren't serializable), the method will run asynchronously: it will return nothing in particular and will not throw an exception. When the method is complete (which may or may not happen before `Meteor.call` returns), the callback will be @@ -282,8 +319,9 @@ only to the server.) {{> api_box meteor_apply}} -`Meteor.apply` is just like `Meteor.call`, but it allows the -arguments to be passed as an array. +`Meteor.apply` is just like `Meteor.call`, except that the method arguments are +passed as an array rather than directly as arguments, and you can specify +options about how the client executes the method.

Server connections

@@ -305,9 +343,9 @@ the server. The return value is an object with the following fields:
status String
Describes the current reconnection status. The possible - values are connected (the connection is up and - running), connecting (disconnected and trying to open a - new connection), and waiting (failed to connect and + values are `connected` (the connection is up and + running), `connecting` (disconnected and trying to open a + new connection), and `waiting` (failed to connect and waiting to try to reconnect).
retryCount @@ -319,15 +357,15 @@ the server. The return value is an object with the following fields: Number or undefined
The estimated time of the next reconnection attempt. To turn this into an interval until the next reconnection, use - retryTime - (new Date()).getTime(). This key will - be set only when status is waiting. + `retryTime - (new Date()).getTime()`. This key will + be set only when `status` is `waiting`.
Instead of using callbacks to notify you on changes, this is -a reactive data source. You can use it in a -template or invalidation -context to get realtime updates. +a [reactive](#reactivity) data source. You can use it in a +[template](#templates) or [invalidation](#meteor_deps) +context to get realtime updates. {{> api_box reconnect}} @@ -337,13 +375,28 @@ To call methods on another Meteor application or subscribe to its data sets, call `Meteor.connect` with the URL of the application. `Meteor.connect` returns an object which provides: -* `subscribe` -* `methods` (to define stubs) -* `call` -* `apply` -* `status` -* `reconnect` +* `subscribe` - + Subscribe to a record set. See + [Meteor.subscribe](#meteor_subscribe). +* `call` - + Invoke a method. See [Meteor.call](#meteor_call). +* `apply` - + Invoke a method with an argument array. See + [Meteor.apply](#meteor_apply). +* `methods` - + Define client-only stubs for methods defined on the remote server. See + [Meteor.methods](#meteor_methods). +* `status` - + Get the current connection status. See + [Meteor.status](#meteor_status). +* `reconnect` - + See [Meteor.reconnect](#meteor_reconnect). +* `onReconnect` - Set this to a function to be called as the first step of + reconnecting. This function can call methods which will be executed before + any other outstanding methods. For example, this can be used to re-establish + the appropriate authentication context on the new connection. +By default, clients open a connection to the server from which they're loaded. When you call `Meteor.subscribe`, `Meteor.status`, `Meteor.call`, and `Meteor.apply`, you are using a connection back to that default server. @@ -399,7 +452,8 @@ Specifically, when you pass a `name`, here's what happens: * On the server, a collection with that name is created on a backend Mongo server. When you call methods on that collection on the server, -they translate directly into normal Mongo operations. +they translate directly into normal Mongo operations (after checking that +they match your [access control rules](#allow)). * On the client, a Minimongo instance is created. Minimongo is essentially an in-memory, non-persistent @@ -426,13 +480,8 @@ the package: $ meteor remove autopublish -{{#warning}} -Currently the client is given full write access to the collection. They -can execute arbitrary Mongo update commands. Once we build -authentication, you will be able to limit the client's direct access to -insert, update, and remove. We are also considering validators and -other ORM-like functionality. -{{/warning}} +and instead call [`Meteor.publish`](#meteor_publish) to specify which parts of +your collection should be published to which users. // Create a collection called Posts and put a document in it. The // document will be immediately visible in the local copy of the @@ -501,8 +550,8 @@ those changes may or may not appear in the result set. Cursors are a reactive data source. The first time you retrieve a cursor's documents with `fetch`, `map`, or `forEach` inside a -reactive context (eg, [`Meteor.render`](#meteor_render), -[`Meteor.autosubscribe`](#meteor_autosubscribe), Meteor will register a +reactive context (eg, [`Meteor.render`](#meteor_render) or +[`Meteor.autosubscribe`](#meteor_autosubscribe)), Meteor will register a dependency on the underlying data. Any change to the collection that changes the documents in a cursor will trigger a recomputation. To disable this behavior, pass `{reactive: false}` as an option to @@ -544,10 +593,9 @@ Example: {{> api_box update}} -Modify documents that match `selector` as -given by `modifier` (see modifier -documentation). By default, modify only one matching document. -If `multi` is true, modify all matching documents. +Modify documents that match `selector` as given by `modifier` (see [modifier +documentation](#modifiers)). By default, modify only one matching document. If +`multi` is true, modify all matching documents. Instead of a selector, you can pass a string, which will be interpreted as an `_id`. @@ -608,6 +656,153 @@ Example: Logs.remove({}); +{{> api_box allow}} + +When a client calls `insert`, `update`, or `remove` on a collection, the +collection's `allow` and [`deny`](#deny) callbacks are called +on the server to determine if the write should be allowed. If at least +one `allow` callback allows the write, and no `deny` callbacks deny the +write, then the write is allowed to proceed. + +These checks are run only when a client tries to write to the database +directly, for example by calling `update` from inside an event +handler. Server code is trusted and isn't subject to `allow` and `deny` +restrictions. That includes methods that are called with `Meteor.call` +— they are expected to do their own access checking rather than +relying on `allow` and `deny`. + +You can call `allow` as many times as you like, and each call can +include any combination of `insert`, `update`, and `remove` +functions. The functions should return `true` if they think the +operation should be allowed. Otherwise they should return `false`, or +nothing at all (`undefined`). In that case Meteor will continue +searching through any other `allow` rules on the collection. + +The available callbacks are: + +
+{{#dtdd "insert(userId, doc)"}} +The user `userId` wants to insert the document `doc` into the +collection. Return `true` if this should be allowed. +{{/dtdd}} + +{{#dtdd "update(userId, docs, fields, modifier)"}} +The user `userId` wants to update some documents. Meteor has fetched the +documents from the database and they are available in `docs` as an +array. Return `true` if the user should be allowed to change these +documents. + +Additional details about the proposed modification are in `fields` and +`modifier`. `fields` is the top-level fields in the document that the +client wishes to modify, for example `['name', 'score']`. `modifier` is +the raw Mongo modifier that the client wants to execute, for example +`{$set: {'name.first': "Alice"}, $inc: {score: 1}}`. + +Only Mongo modifiers are supported (operations like `$set` and `$push`.) +If the user tries to replace the entire document rather than use +$-modifiers, the request will be denied without checking the `allow` +functions. + +{{/dtdd}} + +{{#dtdd "remove(userId, docs)"}} +The user `userId` wants to remove some documents. Meteor has fetched the +documents from the database and they are available in `docs` as an +array. Return `true` if the user should be allowed to remove these +documents. +{{/dtdd}} + +
+ +By default, when Meteor fetches the documents from the database for the +`docs` array, it will retrieve all of the fields in the documents. For +efficiency you may instead want to retrieve just the fields that are +actually needed by your functions. This is enabled by the `fetch` +option. Set `fetch` to an array of the field names that should be +retrieved. + +Example: + + // Create a collection where users can only modify documents that + // they own. Ownership is tracked by an 'owner' field on each + // document. All documents must be owned by the user that created + // them and ownership can't be changed. Only a document's owner + // is allowed to delete it, and the 'locked' attribute can be + // set on a document to prevent its accidental deletion. + + Posts = new Meteor.Collection("posts"); + + Posts.allow({ + insert: function (userId, doc) { + // the user must be logged in, and the document must be owned by the user + return (userId && doc.owner === userId); + }, + update: function (userId, docs, fields, modifier) { + // can only change your own documents + return _.all(docs, function(doc) { + return doc.owner === userId; + }); + }, + remove: function (userId, docs) { + // can only remove your own documents + return _.all(docs, function(doc) { + return doc.owner === userId; + }); + }, + fetch: ['owner'] + }); + + Posts.deny({ + update: function (userId, docs, fields, modifier) { + // can't change owners + return _.contains(fields, 'owner'); + }, + remove: function (userId, docs) { + // can't remove locked documents + return _.any(docs, function (doc) { + return doc.locked; + }); + }, + fetch: ['locked'] // no need to fetch 'owner' + }); + +If you never set up any `allow` rules on a collection then all client +writes to the collection will be denied, and it will only be possible to +write to the collection from server-side code. In this case you will +have to create a method for each possible write that clients are allowed +to do. You'll then call these methods with `Meteor.call` rather than +having the clients call `insert`, `update`, and `remove` directly on the +collection. + +Meteor also has a special "insecure mode" for quickly prototyping new +applications. In insecure mode, if you haven't set up any `allow` or `deny` +rules on a collection, then all users have full write access to the +collection. This is the only effect of insecure mode. If you call `allow` or +`deny` at all on a collection, even `Posts.allow({})`, then access is checked +just like normal on that collection. __New Meteor projects start in insecure +mode by default.__ To turn it off just type `meteor remove insecure`. + +{{#note}} +For `update` and `remove`, documents will be affected only if they match +the selector both at the time the documents are fetched to run the +`allow` and `deny` rules, __and__ at the time that the operation is +actually executed. This is accomplished by rewriting the selector to +`{$and: [(original selector), {$in: {_id: [(ids of documents fetched +and checked by allow and deny)]}}]}`. +{{/note}} + +{{> api_box deny}} + +This works just like [`allow`](#allow), except it lets you +make sure that certain writes are definitely denied, even if there is an +`allow` rule that says that they should be permitted. + +When a client tries to write to a collection, the Meteor server first +checks the collection's `deny` rules. If none of them return true then +it checks the collection's `allow` rules. Meteor allows the write only +if no `deny` rules return `true` and at least one `allow` rule returns +`true`. +

Cursors

To create a cursor, use [`find`](#find). To access the documents in a @@ -751,7 +946,8 @@ But they can also contain more complicated tests: // Matches documents where fruit is one of three possibilities {fruit: {$in: ["peach", "plum", "pear"]}} -See the complete documentation. +See the [complete +documentation](http://www.mongodb.org/display/DOCS/Advanced+Queries). {{/api_box_inline}} @@ -767,14 +963,17 @@ place by changing some of its fields. Some examples: // 'supporters' array {$inc: {votes: 2}, $push: {supporters: "Traz"}} -But if a modifier doesn't contain any $-operators, then it is -instead interpreted as a literal document, and completely replaces -whatever was previously in the database. +But if a modifier doesn't contain any $-operators, then it is instead +interpreted as a literal document, and completely replaces whatever was +previously in the database. (Literal document modifiers are not currently +supported by [validated updates](#allow).) // Find the document with id "123", and completely replace it. Users.update({_id: "123"}, {name: "Alice", friends: ["Bob"]}); -See the full list of modifiers. +See the [full list of +modifiers](http://www.mongodb.org/display/DOCS/Updating#Updating-ModifierOperations) +full list of modifiers. {{/api_box_inline}} @@ -858,7 +1057,7 @@ Example: {{> api_box equals}} -These two expressions do the same thing: +If value is a scalar, then these two expressions do the same thing: (1) Session.get("key") === value (2) Session.equals("key", value) @@ -905,6 +1104,415 @@ Example: // If Session.get had been used instead of Session.equals, then // when the selection changed, all the items would be re-rendered. +For object and array session values, you cannot use `Session.equals`; instead, +you need to use the `underscore` package and write +`_.isEqual(Session.get(key), value)`. + + + +

Accounts

+ +The Meteor Accounts system builds on top of the `userId` support in +[`publish`](#publish_userId) and [`methods`](#method_userId). The core +packages add the concept of user documents stored in the database, and +additional packages add [secure password +authentication](#accounts_passwords), [integration with third party +login services](#meteor_loginwithexternalservice), and a [pre-built user +interface](#accountsui). + +The basic Accounts system is in the `accounts-base` package, but +applications typically include this automatically by adding one of the +login provider packages: `accounts-password`, `accounts-facebook`, +`accounts-github`, `accounts-google`, `accounts-twitter`, or +`accounts-weibo`. + + +{{> api_box user}} + +Retreives the user record for the current user from +the [`Meteor.users`](#meteor_users) collection. + +On the client this will be a subset of the fields in the document, only +those that are published from the server are available on the client. By +default the server publishes `username`, `emails`, and +`profile`. See [`Meteor.users`](#meteor_users) for more on +the fields used in user documents. + +If the user is logged in but the user's database record is not fully +loaded yet, this returns an object with only the `_id` field set. During +this period [`userLoaded`](#meteor_userloaded) will return +`false`. + +{{> api_box userId}} + +{{> api_box users}} + +This collection contains one document per registered user. Here's an example +user document: + + { + _id: "bbca5d6a-2156-41c4-89da-0329e8c99a4f", // Meteor.userId() + username: "cool_kid_13", // unique name + emails: [ + // each email address can only belong to one user. + { address: "cool@example.com", verified: true }, + { address: "another@different.com", verified: false } + ], + createdAt: 1349761684042, + profile: { + // The profile is writable by the user by default. + name: "Joe Schmoe" + }, + services: { + facebook: { + id: "709050", // facebook id + accessToken: "AAACCgdX7G2...AbV9AZDZD" + }, + resume: { + loginTokens: [ + { token: "97e8c205-c7e4-47c9-9bea-8e2ccc0694cd", + when: 1349761684048 } + ] + } + } + } + +A user document can contain any data you want to store about a user. Meteor +treats the following fields specially: + +- `username`: a unique String identifying the user. +- `emails`: an Array of Objects with keys `address` and `verified`; + an email address may belong to at most one user. `verified` is + a Boolean which is true if the user has [verified the + address](#accounts_verifyemail) with a token sent over email. +- `createdAt`: a numeric timestamp (milliseconds since January 1 1970) + of the time the user document was created. +- `profile`: an Object which (by default) the user can create + and update with any data. +- `services`: an Object containing data used by particular + login services. For example, its `reset` field contains + tokens used by [forgot password](#accounts_forgotpassword) links, + and its `resume` field contains tokens used to keep you + logged in between sessions. + +Like all [Meteor.Collection](#collections)s, you can access all +documents on the server, but only those specifically published by the server are +available on the client. + +By default, the current user's `username`, `emails` and `profile` are +published to the client. You can publish additional fields for the +current user with: + + Meteor.publish("userData", function () { + return Meteor.users.find({_id: this.userId}, + {fields: {'other': 1, 'things': 1}}); + }); + +If the `autopublish` package is installed, the `username` and `profile` fields +for all users are published to all clients. To publish specific fields from all +users: + + Meteor.publish("allUserData", function () { + return Meteor.users.find({}, {fields: {'nested.things': 1}}); + }); + +Users are by default allowed to specify their own `profile` field with +[`Accounts.createUser`](#accounts_createuser) and modify it with +`Meteor.users.update`. To allow users to edit additional fields, use +[`Meteor.users.allow`](#allow). To forbid users from making any modifications to +their user document: + + Meteor.users.deny({update: function () { return true; }}); + + +{{> api_box userLoaded}} + +There are some cases when the client knows the id of the logged in user +but has not yet received the user data from the server. For example, if +the user is logged in and reloads the page the user data will be +unavailable during initial page load. + +During these periods, `userLoaded` will return false +and [`user`](#meteor_user) will return an object with only +the `_id` key. + +{{#note}} +We realize this is inconvenient. It is a temporary solution. In the +future we will either make it unnecessary or fold it into a more +general mechanism. +{{/note}} + +{{> api_box logout}} + +{{> api_box loginWithPassword}} + +This function is provided by the `accounts-password` package. See the +[Passwords](#accounts_passwords) section below. + + +{{> api_box loginWithExternalService}} + +These functions initiate the login process with an external +service (eg: Facebook, Google, etc), using OAuth. When called they open a new pop-up +window that loads the provider's login page. Once the user has logged in +with the provider, the pop-up window is closed and the Meteor client +logs in to the Meteor server with the information provided by the external +service. + + + +In addition to identifying the user to your application, some services +have APIs that allow you to take action on behalf of the user. To +request specific permissions from the user, pass the +`requestPermissions` option the login function. This will cause the user +to be presented with an additional page in the pop-up dialog to permit +access to their data. The user's `accessToken` — with permissions +to access the service's API — is stored in the `services` field of +the user document. The supported values for `requestPermissions` differ +for each login service and are documented on their respective developer +sites: + +- Facebook: +- GitHub: +- Google: +- Twitter, Weibo: `requestPermissions` currently not supported + +External login services typically require registering and configuring +your application before use. The easiest way to do this is with the +[`accounts-ui` package](#accountsui) which presents a step-by-step guide +to configuring each service. However, the data can be also be entered +manually in the `Accounts.loginServiceConfiguration` collection. For +example: + + Accounts.loginServiceConfiguration.insert({ + service: "weibo", + clientId: "1292962797", + secret: "75a730b58f5691de5522789070c319bc" + }); + + +Each external service has its own login provider package and login function. For +example, to support GitHub login, run `$ meteor add accounts-github` and use the +`Meteor.loginWithGithub` function: + + Meteor.loginWithGithub({ + requestPermissions: ['user', 'public_repo'] + }, function (err) { + if (err) + Session.set('errorMessage', err.reason || 'Unknown error'); + }); + + + + +{{> api_box accounts_config}} +{{> api_box accounts_ui_config}} + +Example: + + Accounts.ui.config({ + requestPermissions: { + facebook: ['user_likes'], + github: ['user', 'repo'] + }, + passwordSignupFields: 'USERNAME_AND_OPTIONAL_EMAIL' + }); + +{{> api_box accounts_validateNewUser}} + +This can be called multiple times. If any of the functions return `false` or +throw an error, the new user creation is aborted. To set a specific error +message (which will be displayed by [`accounts-ui`](#accountsui)), throw a new +[`Meteor.Error`](#meteor_error). + +Example: + + // Validate username, sending a specific error message on failure. + Accounts.validateNewUser(function (user) { + if (user.username && user.username.length >= 3) + return true; + throw new Meteor.Error(403, "Username must have at least 3 characters"); + }); + // Validate username, without a specific error message. + Accounts.validateNewUser(function (user) { + return user.username !== "root"; + }); + +{{> api_box accounts_onCreateUser}} + +Use this when you need to do more than simply accept or reject new user +creation. With this function you can programatically control the +contents of new user documents. + +The function you pass will be called with two arguments: `options` and +`user`. The `options` argument comes +from [`Accounts.createUser`](#accounts_createuser) for +password-based users or from an external service login flow. `options` may come +from an untrusted client so make sure to validate any values you read from +it. The `user` argument is created on the server and contains a +proposed user object with all the automatically generated fields +required for the user to log in. + +The function should return the user document (either the one passed in or a +newly-created object) with whatever modifications are desired. The returned +document is inserted directly into the [`Meteor.users`](#meteor_users) collection. + +The default create user function simply copies `options.profile` into +the new user document. Calling `onCreateUser` overrides the default +hook. This can only be called once. + +Example: + + + + // Support for playing D&D: Roll 3d6 for dexterity + Accounts.onCreateUser(function(options, user) { + var d6 = function () { return Math.floor(Math.random() * 6) + 1; }; + user.dexterity = d6() + d6() + d6(); + // We still want the default hook's 'profile' behavior. + if (options.profile) + user.profile = options.profile; + return user; + }); + + +

Passwords

+ +The `accounts-password` package contains a full system for password +based authentication. In addition to the basic username and password +based sign-in process it also supports email based sign-in including +address verification and password recovery emails. + +Unlike most web applications, the Meteor client does not send the user's +password directly to the server. It uses the [Secure Remote Password +protocol](http://en.wikipedia.org/wiki/Secure_Remote_Password_protocol) +to ensure the server never sees the user's plain-text password. This +helps protect against embarrassing password leaks if the server's +database is compromised. + +To add password support to your application, run `$ meteor add +accounts-password`. You can construct your own user interface using the +functions below, or use the [`accounts-ui` package](#accountsui) to +include a turn-key user interface for password based sign-in. + + +{{> api_box accounts_createUser}} + +On the client this function logs in as the newly created user on +successful completion. On the server, it returns the newly created user +id. + +On the client, you must pass `password` and one of `username` or `email` +— enough information for the user to be able to log in again +later. On the server, you can pass any subset of these options, but the +user will not be able to log in until it has an identifier and a +password. + +To create an account without a password on the server and still let the +user pick their own password, call `createUser` with the `email` option +and then +call [`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail). This +will send the user an email with a link to set their initial password. + +By default the `profile` option is added directly to the new user document. To +override this behavior, use [`Accounts.onCreateUser`](#accounts_createuser). + +This function is only used for creating users with passwords. The external +service login flows do not use this function. + + +{{> api_box accounts_changePassword}} + +{{> api_box accounts_forgotPassword}} + +This triggers a call +to [`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail) +on the server. Pass the token the user receives in this email +to [`Accounts.resetPassword`](#accounts_resetpassword) to +complete the password reset process. + +If you are using the [`accounts-ui` package](#pkg_accounts_ui), this is handled +automatically. Otherwise, it is your responsiblity to prompt the user for the +new password and call `resetPassword`. + +{{> api_box accounts_resetPassword}} + +This function accepts tokens generated +by [`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail) +and +[`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail). + +{{> api_box accounts_setPassword}} + +{{> api_box accounts_verifyEmail}} + +This function accepts tokens generated +by [`Accounts.sendVerificationEmail`](#accounts_sendverificationemail). It +sets the `emails.verified` field in the user record. + +{{> api_box accounts_sendResetPasswordEmail}} + +The token in this email should be passed +to [`Accounts.resetPassword`](#accounts_resetpassword). + +To customize the contents of the email, see +[`Accounts.emailTemplates`](#accounts_emailtemplates). + +{{> api_box accounts_sendEnrollmentEmail}} + +The token in this email should be passed +to [`Accounts.resetPassword`](#accounts_resetpassword). + +To customize the contents of the email, see +[`Accounts.emailTemplates`](#accounts_emailtemplates). + +{{> api_box accounts_sendVerificationEmail}} + +The token in this email should be passed +to [`Accounts.verifyEmail`](#accounts_verifyemail). + +To customize the contents of the email, see +[`Accounts.emailTemplates`](#accounts_emailtemplates). + +{{> api_box accounts_emailTemplates}} + +This is an `Object` with several fields that are used to generate text +for the emails by `sendResetPasswordEmail`, `sendEnrollmentEmail`, and +`sendVerificationEmail`. + +Override fields of the object by assigning to them: + +- `from`: A `String` with an [RFC5322](http://tools.ietf.org/html/rfc5322) From + address. By default email is from `no-reply@meteor.com`. If you wish to + receive email from users asking for help with their account, be sure to set + this to an email address that you can receive email at. +- `siteName`: The public name of your application. Defaults to the DNS name of + the application (eg: `awesome.meteor.com`). +- `resetPassword`: An `Object` with two fields: + - `resetPassword.subject`: A `Function` that takes a user object and returns + a `String` for the subject line of a reset password email. + - `resetPassword.text`: A `Function` that takes a user object and a url, and + returns the body text for a reset password email. +- `enrollAccount`: Same as `resetPassword`, but for initial password setup for + new accounts. +- `verifyEmail`: Same as `resetPassword`, but for verifying the users email + address. + + +Example: + + Accounts.emailTemplates.siteName = "AwesomeSite"; + Accounts.emailTemplates.from = "AwesomeSite Admin "; + Accounts.emailTemplates.enrollAccount.subject = function (user) { + return "Welcome to Awesome Town, " + user.profile.name; + }; + Accounts.emailTemplates.enrollAccount.text = function (user, url) { + return "You have been selected to participate in building a better future!" + + " To activate your account, simply click the link below:\n\n" + + url; + }; + +

Templates

A template that you declare as `<{{! }}template name="foo"> ... api_box template_rendered}} -This callback is called once when an instance of Template.*myTemplate* is rendered into DOM nodes and put into the document for the first time, and again each time any part of the template is re-rendered. +This callback is called once when an instance of Template.*myTemplate* is +rendered into DOM nodes and put into the document for the first time, and again +each time any part of the template is re-rendered. In the body of the callback, `this` is a [template instance](#template_inst) object that is unique to this occurrence of @@ -983,8 +1593,8 @@ See [Event Maps](#eventmaps) for a detailed description of the event map format and how event handling works in Meteor. {{#note}} -This syntax replaces the previous syntax: `Template.myTemplate.events = {...}`, but for now, the -old syntax still works. +This syntax replaces the previous syntax: `Template.myTemplate.events = {...}`, +but for now, the old syntax still works. {{/note}} @@ -1233,11 +1843,12 @@ Matches a particular type of event, such as 'click'. {{#dtdd "eventtype selector"}} Matches a particular type of event, but only when it appears on -an element that matches a certain CSS selector. +an element that matches a certain CSS selector. {{/dtdd}} {{#dtdd "event1, event2"}} -To handle more than one type of event with the same function, use a comma-separated list. +To handle more than one type of event with the same function, use a +comma-separated list. {{/dtdd}} @@ -1440,7 +2051,6 @@ sub-template. {{/api_box_inline}} -

Timers

Meteor uses global environment variables @@ -1468,7 +2078,7 @@ dependencies — it "just works". The mechanism is simple and efficient. When you call a function that supports reactive updates (say, a database query), it automatically saves the current "invalidation context" object if any (say, the current template being -rendered.) Later, when the data changes, it can "invalidates" this +rendered.) Later, when the data changes, it can "invalidate" this context (tell the template to rerender itself.) The whole implementation is about 50 lines of code. @@ -1628,6 +2238,34 @@ might think of it as a dynamically scoped ("special") variable. (That just means that [`run`](#run) sets it, runs some user-supplied code, and then restores its previous value.) +{{> api_box autorun }} + +`Meteor.autorun` allows you to set up your own reactive context, where you can +perform arbitrary actions when dependencies change. For example, you can monitor +a cursor (which is a reactive data source) and aggregate it into a session +variable: + + Meteor.autorun(function() { + var oldest = _.max(Monkeys.find().fetch(), function (monkey) { + return monkey.age; + }); + if (oldest) + Session.set("oldest", oldest.name); + }); + +Or you can wait for a session variable to get a certain value, and do something +the first time it does so, using the `stop` handle to prevent further runs: + + Meteor.autorun(function(handle) { + if (!Session.equals("shouldAlert", true)) return; + handle.stop(); + alert("Oh no!"); + }); + +The function is invoked immediately and — like all reactive +sources — the rerun occurs at the time of the next +[`Meteor.flush`](#meteor_flush). + {{> api_box flush }} @@ -1661,12 +2299,10 @@ elements are cleaned up by logic that is triggered by context invalidations. - -

Meteor.http

`Meteor.http` provides an HTTP API on the client and server. To use -these functions, add the HTTP package to your project with `meteor add +these functions, add the HTTP package to your project with `$ meteor add http`. {{> api_box httpcall}} @@ -1739,7 +2375,7 @@ Example server method: Meteor.methods({checkTwitter: function (userId) { this.unblock(); - var result = Meteor.http.call("GET", "http://api.twitter.com/xxx", + var result = Meteor.http.call("GET", "http://api.twitter.com/xyz", {params: {user: userId}}); if (result.statusCode === 200) return true @@ -1748,7 +2384,7 @@ Example server method: Example asynchronous HTTP call: - Meteor.http.call("POST", "http://api.twitter.com/xxx", + Meteor.http.call("POST", "http://api.twitter.com/xyz", {data: {some: "json", stuff: 1}}, function (error, result) { if (result.statusCode === 200) { @@ -1773,9 +2409,9 @@ send mail. Currently, Meteor supports sending mail over SMTP; the `MAIL_URL` environment variable should be of the form `smtp://USERNAME:PASSWORD@HOST:PORT/`. For apps deployed with `meteor deploy`, `MAIL_URL` defaults to an account (provided by -
Mailgun) which allows -apps to send up to 200 emails per day; you may override this default by -assigning to `process.env.MAIL_URL` before your first call to `Email.send`. +[Mailgun](http://www.mailgun.com/)) which allows apps to send up to 200 emails +per day; you may override this default by assigning to `process.env.MAIL_URL` +before your first call to `Email.send`. If `MAIL_URL` is not set (eg, when running your application locally), `Email.send` outputs the message to standard output instead. diff --git a/docs/client/api.js b/docs/client/api.js index 44f167cf67..0f339c1b9e 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -145,6 +145,14 @@ Template.api.subscription_onStop = { ] }; +Template.api.subscription_userId = { + id: "publish_userId", + name: "this.userId", + locus: "Server", + descr: ["The id of logged-in user, or `null` if no user is logged in."] +}; + + Template.api.subscribe = { id: "meteor_subscribe", name: "Meteor.subscribe(name [, arg1, arg2, ... ] [, onComplete])", @@ -187,6 +195,25 @@ Template.api.methods = { ] }; +Template.api.method_invocation_userId = { + id: "method_userId", + name: "this.userId", + locus: "Anywhere", + descr: ["The id of the user that made this method call, or `null` if no user was logged in."] +}; + +Template.api.method_invocation_setUserId = { + id: "method_setUserId", + name: "this.setUserId(userId)", + locus: "Server", + descr: ["Set the logged in user."], + args: [ + {name: "userId", + type: "String or null", + descr: "The value that should be returned by `userId` on this connection."} + ] +}; + Template.api.method_invocation_unblock = { id: "method_unblock", name: "this.unblock()", @@ -239,7 +266,7 @@ Template.api.meteor_call = { Template.api.meteor_apply = { id: "meteor_apply", - name: "Meteor.apply(name, params [, asyncCallback])", + name: "Meteor.apply(name, params [, options] [, asyncCallback])", locus: "Anywhere", descr: ["Invoke a method passing an array of arguments."], args: [ @@ -252,6 +279,12 @@ Template.api.meteor_apply = { {name: "asyncCallback", type: "Function", descr: "Optional callback. If passed, the method runs asynchronously, instead of synchronously, and calls asyncCallback passing either the error or the result."} + ], + options: [ + {name: "wait", + type: "Boolean", + descr: "(Client only) If true, don't send any subsequent method calls until this one is completed. " + + "Only run the callback for this method once all previous method calls have completed."} ] }; @@ -288,18 +321,19 @@ Template.api.connect = { Template.api.meteor_collection = { id: "meteor_collection", - name: "new Meteor.Collection(name, manager)", // driver undocumented + name: "new Meteor.Collection(name, [options])", locus: "Anywhere", descr: ["Constructor for a Collection"], args: [ {name: "name", type: "String", - descr: "The name of the collection. If null, creates an unmanaged (unsynchronized) local collection."}, + descr: "The name of the collection. If null, creates an unmanaged (unsynchronized) local collection."} + ], + options: [ {name: "manager", type: "Object", descr: "The Meteor connection that will manage this collection, defaults to `Meteor` if null. Unmanaged (`name` is null) collections cannot specify a manager." } - // driver ] }; @@ -364,6 +398,93 @@ Template.api.findone = { ] }; +Template.api.insert = { + id: "insert", + name: "collection.insert(doc, [callback])", + locus: "Anywhere", + descr: ["Insert a document in the collection. Returns its unique _id."], + args: [ + {name: "doc", + type: "Object", + descr: "The document to insert. Should not yet have an _id attribute."}, + {name: "callback", + type: "Function", + descr: "Optional. If present, called with an error object as the first argument and, if no error, the _id as the second."} + ] +}; + +Template.api.update = { + id: "update", + name: "collection.update(selector, modifier, [options], [callback])", + locus: "Anywhere", + descr: ["Modify one or more documents in the collection"], + args: [ + {name: "selector", + type: "Object: Mongo selector, or String", + type_link: "selectors", + descr: "Specifies which documents to modify"}, + {name: "modifier", + type: "Object: 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 its argument."} + ], + options: [ + {name: "multi", + type: "Boolean", + descr: "True to modify all matching documents; false to only modify one of the matching documents (the default)."} + ] +}; + +Template.api.remove = { + id: "remove", + name: "collection.remove(selector, [callback])", + locus: "Anywhere", + descr: ["Remove documents from the collection"], + args: [ + {name: "selector", + type: "Object: Mongo selector, or String", + type_link: "selectors", + descr: "Specifies which documents to remove"}, + {name: "callback", + type: "Function", + descr: "Optional. If present, called with an error object as its argument."} + ] +}; + +Template.api.allow = { + id: "allow", + name: "collection.allow(options)", + locus: "Server", + descr: ["Allow users to write directly to this collection from client code, subject to limitations you define."], + options: [ + {name: "insert, update, remove", + type: "Function", + descr: "Functions that look at a proposed modification to the database and return true if it should be allowed."}, + {name: "fetch", + type: "Array of String", + descr: "Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions."} + ] +}; + +Template.api.deny = { + id: "deny", + name: "collection.deny(options)", + locus: "Server", + descr: ["Override `allow` rules."], + options: [ + {name: "insert, update, remove", + type: "Function", + descr: "Functions that look at a proposed modification to the database and return true if it should be denied, even if an `allow` rule says otherwise."}, + {name: "fetch", + type: "Array of Strings", + descr: "Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions."} + ] +}; + + Template.api.cursor_count = { id: "count", name: "cursor.count()", @@ -422,62 +543,6 @@ Template.api.cursor_observe = { ] }; -Template.api.insert = { - id: "insert", - name: "collection.insert(doc, [callback])", - locus: "Anywhere", - descr: ["Insert a document in the collection. Returns its unique _id."], - args: [ - {name: "doc", - type: "Object", - descr: "The document to insert. Should not yet have an _id attribute."}, - {name: "callback", - type: "Function", - descr: "Optional. If present, called with an error object as the first argument and, if no error, the _id as the second."} - ] -}; - -Template.api.update = { - id: "update", - name: "collection.update(selector, modifier, [options], [callback])", - locus: "Anywhere", - descr: ["Modify one or more documents in the collection"], - args: [ - {name: "selector", - type: "Object: Mongo selector, or String", - type_link: "selectors", - descr: "Specifies which documents to modify"}, - {name: "modifier", - type: "Object: 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 its argument."} - ], - options: [ - {name: "multi", - type: "Boolean", - descr: "True to modify all matching documents; false to only modify one of the matching documents (the default)."} - ] -}; - -Template.api.remove = { - id: "remove", - name: "collection.remove(selector, [callback])", - locus: "Anywhere", - descr: ["Remove documents from the collection"], - args: [ - {name: "selector", - type: "Object: Mongo selector, or String", - type_link: "selectors", - descr: "Specifies which documents to remove"}, - {name: "callback", - type: "Function", - descr: "Optional. If present, called with an error object as its argument."} - ] -}; - Template.api.selectors = { id: "selectors", name: "Mongo-style Selectors" @@ -543,6 +608,18 @@ Template.api.current = { descr: ["The current [`invalidation context`](#context), or `null` if not being called from inside [`run`](#run)."] }; +Template.api.autorun = { + id: "meteor_autorun", + name: "Meteor.autorun(func)", + locus: "Client", + descr: ["Run a function and rerun it whenever its dependencies change. Returns a handle that provides a `stop` method, which will prevent further reruns."], + args: [ + {name: "func", + type: "Function", + descr: "The function to run. It receives one argument: the same handle that `Meteor.autorun` returns."} + ] +}; + Template.api.flush = { id: "meteor_flush", name: "Meteor.flush()", @@ -603,6 +680,381 @@ Template.api.isolate = { +Template.api.user = { + id: "meteor_user", + name: "Meteor.user()", + locus: "Anywhere but publish functions", + descr: ["Get the current user record, or `null` if no user is logged in. A reactive data source."] +}; + + +Template.api.userId = { + id: "meteor_userid", + name: "Meteor.userId()", + locus: "Anywhere but publish functions", + descr: ["Get the current user id, or `null` if no user is logged in. A reactive data source."] +}; + + +Template.api.users = { + id: "meteor_users", + name: "Meteor.users", + locus: "Anywhere", + descr: ["A [Meteor.Collection](#collections) containing user documents."] +}; + +Template.api.userLoaded = { + id: "meteor_userloaded", + name: "Meteor.userLoaded()", + locus: "Client", + descr: ["Determine if the current user document is fully loaded in [`Meteor.users`](#meteor_users). A reactive data source."] +}; + + + +Template.api.logout = { + id: "meteor_logout", + name: "Meteor.logout([callback])", + locus: "Client", + descr: ["Log the user out."], + 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", + name: "Meteor.loginWithPassword(user, password, [callback])", + locus: "Client", + descr: ["Log the user in with a password."], + args: [ + { + name: "user", + type: "Object or String", + descr: "Either a string interpreted as a username or an email; or an object with a single key: `email`, `username` or `id`." + }, + { + name: "password", + type: "String", + descr: "The user's password. This is __not__ sent in plain text over the wire — it is secured with [SRP](http://en.wikipedia.org/wiki/Secure_Remote_Password_protocol)." + }, + { + name: "callback", + type: "Function", + descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure." + } + ] +}; + + +Template.api.loginWithExternalService = { + id: "meteor_loginwithexternalservice", + name: "Meteor.loginWithExternalService([options], [callback])", + locus: "Client", + descr: ["Log the user in using an external service."], + args: [ + { + name: "callback", + type: "Function", + descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure." + } + ], + options: [ + { + name: "requestPermissions", + type: "Array of Strings", + descr: "A list of permissions to request from the user." + } + ] +}; + + + +Template.api.accounts_config = { + id: "accounts_config", + name: "Accounts.config(options)", + locus: "Anywhere", + descr: ["Set global accounts options."], + options: [ + { + name: "sendVerificationEmail", + type: "Boolean", + descr: "New users with an email address will receive an address verification email." + }, + { + name: "forbidClientAccountCreation", + type: "Boolean", + descr: "[`createUser`](#accounts_createuser) requests from the client will be rejected." + } + ] +}; + +Template.api.accounts_ui_config = { + id: "accounts_ui_config", + name: "Accounts.ui.config(options)", + locus: "Client", + descr: ["Configure the behavior of [`{{loginButtons}}`](#accountsui)."], + options: [ + { + name: "requestPermissions", + type: "Object", + descr: "Which [permissions](#requestpermissions) to request from the user for each external service." + }, + { + name: "passwordSignupFields", + type: "String", + descr: "Which fields to display in the user creation form. One of '`USERNAME_AND_EMAIL`', '`USERNAME_AND_OPTIONAL_EMAIL`', '`USERNAME_ONLY`', or '`EMAIL_ONLY`' (default)." + } + ] +}; + +Template.api.accounts_validateNewUser = { + id: "accounts_validatenewuser", + name: "Accounts.validateNewUser(func)", + locus: "Server", + descr: ["Set restrictions on new user creation."], + args: [ + { + name: "func", + type: "Function", + descr: "Called whenever a new user is created. Takes the new user object, and returns true to allow the creation or false to abort." + } + ] +}; + +Template.api.accounts_onCreateUser = { + id: "accounts_oncreateuser", + name: "Accounts.onCreateUser(func)", + locus: "Server", + descr: ["Customize new user creation."], + args: [ + { + name: "func", + type: "Function", + descr: "Called whenever a new user is created. Return the new user object, or throw an `Error` to abort the creation." + } + ] +}; + + + +Template.api.accounts_createUser = { + id: "accounts_createuser", + name: "Accounts.createUser(options, [callback])", + locus: "Anywhere", + descr: ["Create a new user."], + args: [ + { + name: "callback", + type: "Function", + descr: "Client only, optional callback. Called with no arguments on success, or with a single `Error` argument on failure." + } + ], + options: [ + { + name: "username", + type: "String", + descr: "A unique name for this user." + }, + { + name: "email", + type: "String", + descr: "The user's email address." + }, + { + name: "password", + type: "String", + descr: "The user's password. This is __not__ sent in plain text over the wire." + }, + { + name: "profile", + type: "Object", + descr: "The user's profile, typically including the `name` field." + } + ] +}; + +Template.api.accounts_changePassword = { + id: "accounts_changepassword", + name: "Accounts.changePassword(oldPassword, newPassword, [callback])", + locus: "Client", + descr: ["Change the current user's password. Must be logged in."], + args: [ + { + name: "oldPassword", + type: "String", + descr: "The user's current password. This is __not__ sent in plain text over the wire." + }, + { + name: "newPassword", + type: "String", + descr: "A new password for the user. This is __not__ sent in plain text over the wire." + }, + { + name: "callback", + type: "Function", + descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure." + } + ] +}; + +Template.api.accounts_forgotPassword = { + id: "accounts_forgotpassword", + name: "Accounts.forgotPassword(options, [callback])", + locus: "Client", + descr: ["Request a forgot password email."], + args: [ + { + name: "callback", + type: "Function", + descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure." + } + ], + options: [ + { + name: "email", + type: "String", + descr: "The email address to send a password reset link." + } + ] +}; + +Template.api.accounts_resetPassword = { + id: "accounts_resetpassword", + name: "Accounts.resetPassword(token, newPassword, [callback])", + locus: "Client", + descr: ["Reset the password for a user using a token received in email. Logs the user in afterwards."], + args: [ + { + name: "token", + type: "String", + descr: "The token retrieved from the reset password URL." + }, + { + name: "newPassword", + type: "String", + descr: "A new password for the user. This is __not__ sent in plain text over the wire." + }, + { + name: "callback", + type: "Function", + descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure." + } + ], +}; + +Template.api.accounts_setPassword = { + id: "accounts_setpassword", + name: "Accounts.setPassword(userId, newPassword)", + locus: "Server", + descr: ["Forcibly change the password for a user."], + args: [ + { + name: "userId", + type: "String", + descr: "The id of the user to update." + }, + { + name: "newPassword", + type: "String", + descr: "A new password for the user." + } + ] +}; + +Template.api.accounts_verifyEmail = { + id: "accounts_verifyemail", + name: "Accounts.verifyEmail(token, [callback])", + locus: "Client", + descr: ["Marks the user's email address as verified. Logs the user in afterwards."], + args: [ + { + name: "token", + type: "String", + descr: "The token retrieved from the verification URL." + }, + { + name: "callback", + type: "Function", + descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure." + } + ] +}; + + +Template.api.accounts_sendResetPasswordEmail = { + id: "accounts_sendresetpasswordemail", + name: "Accounts.sendResetPasswordEmail(userId, [email])", + locus: "Server", + descr: ["Send an email with a link the user can use to reset their password."], + args: [ + { + name: "userId", + type: "String", + descr: "The id of the user to send email to." + }, + { + name: "email", + type: "String", + descr: "Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first email in the list." + } + ] +}; + +Template.api.accounts_sendEnrollmentEmail = { + id: "accounts_sendenrollmentemail", + name: "Accounts.sendEnrollmentEmail(userId, [email])", + locus: "Server", + descr: ["Send an email with a link the user can use to set their initial password."], + args: [ + { + name: "userId", + type: "String", + descr: "The id of the user to send email to." + }, + { + name: "email", + type: "String", + descr: "Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first email in the list." + } + ] +}; + +Template.api.accounts_sendVerificationEmail = { + id: "accounts_sendverificationemail", + name: "Accounts.sendVerificationEmail(userId, [email])", + locus: "Server", + descr: ["Send an email with a link the user can use verify their email address."], + args: [ + { + name: "userId", + type: "String", + descr: "The id of the user to send email to." + }, + { + name: "email", + type: "String", + descr: "Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first unverified email in the list." + } + ] +}; + + + +Template.api.accounts_emailTemplates = { + id: "accounts_emailtemplates", + name: "Accounts.emailTemplates", + locus: "Anywhere", + descr: ["Options to customize emails sent from the Accounts system."] +}; + + + Template.api.setTimeout = { id: "meteor_settimeout", name: "Meteor.setTimeout", @@ -726,7 +1178,7 @@ Template.api.set = { type: "String", descr: "The key to set, eg, `selectedItem`"}, {name: "value", - type: "String, Number, Boolean, null, or undefined", + type: "JSON-able object or undefined", descr: "The new value for `key`"} ] }; @@ -735,7 +1187,7 @@ Template.api.get = { id: "session_get", name: "Session.get(key)", locus: "Client", - descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set)."], + descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set). This returns a clone of the session value, so if it's an object or an array, mutating the returned value has no effect on the value stored in the session."], args: [ {name: "key", type: "String", @@ -760,7 +1212,7 @@ Template.api.equals = { Template.api.httpcall = { id: "meteor_http_call", - name: "Meteor.http.call(method, url, [options], [asyncCallback])", + name: "Meteor.http.call(method, url [, options] [, asyncCallback])", locus: "Anywhere", descr: ["Perform an outbound HTTP request."], args: [ @@ -951,8 +1403,7 @@ Template.api.template_data = { }; var rfc = function (descr) { - return ('RFC5322' - + ' ' + descr); + return '[RFC5322](http://tools.ietf.org/html/rfc5322) ' + descr; }; Template.api.email_send = { diff --git a/docs/client/commandline.html b/docs/client/commandline.html index 519359ea30..7ed2643e89 100644 --- a/docs/client/commandline.html +++ b/docs/client/commandline.html @@ -34,7 +34,7 @@ Run `meteor help run` to see the full list of options.

meteor create name

-Create a new Meteor project. Makes a subdirectory named name +Create a new Meteor project. Makes a subdirectory named *name* and copies in the template app. You can pass an absolute or relative path. diff --git a/docs/client/concepts.html b/docs/client/concepts.html index 964289b85f..5cdbd82439 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -21,12 +21,12 @@ when writing those apps.

Structuring your application

-A Meteor application is a mix of JavaScript that runs inside a -client web browser, JavaScript that runs on the Meteor server inside -a Node.js container, and all the supporting HTML fragments, CSS rules, -and static assets. Meteor automates the packaging and transmission -of these different components. And, it is quite flexible about how -you choose to structure those components in your file tree. +A Meteor application is a mix of JavaScript that runs inside a client web +browser, JavaScript that runs on the Meteor server inside a +[Node.js](http://nodejs.org/) container, and all the supporting HTML fragments, +CSS rules, and static assets. Meteor automates the packaging and transmission +of these different components. And, it is quite flexible about how you choose +to structure those components in your file tree. The only server asset is JavaScript. Meteor gathers all your JavaScript files, excluding anything under the `client` @@ -83,78 +83,133 @@ for images, `favicon.ico`, `robots.txt`, and anything else. @@ -261,15 +321,14 @@ the new elements using a library like jQuery. In that case, call [`Meteor.flush`](#meteor_flush) to bring the DOM up to date immediately. -When live-updating DOM elements are taken off the screen, they are -automatically cleaned up — their callbacks are torn down, any -associated database queries are stopped, and they stop updating. For -this reason, you never have to worry about -the zombie templates that plague hand-written update -logic. To protect your elements from cleanup, just make sure that they -on-screen before your code returns to the event loop, or before any -call you make to [`Meteor.flush`](#meteor_flush). +When live-updating DOM elements are taken off the screen, they are automatically +cleaned up — their callbacks are torn down, any associated database +queries are stopped, and they stop updating. For this reason, you never have to +worry about the [zombie +templates](http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/) +that plague hand-written update logic. To protect your elements from cleanup, +just make sure that they on-screen before your code returns to the event loop, +or before any call you make to [`Meteor.flush`](#meteor_flush). Another thorny problem in hand-written applications is element preservation. Suppose the user is typing text into an `` @@ -307,10 +366,9 @@ available as a function on the global `Template` object. {{#note}} Today, the only templating system that has been packaged for Meteor is Handlebars. Let us know what templating systems you'd like to use with -Meteor. Meanwhile, see -the Handlebars documentation -and Meteor -Handlebars extensions. +Meteor. Meanwhile, see the [Handlebars +documentation](http://www.handlebarsjs.com/) and [Meteor Handlebars +extensions](https://github.com/meteor/meteor/wiki/Handlebars). {{/note}} A template with a `name` of `hello` is rendered by calling the @@ -444,7 +502,7 @@ discussion. + + + + diff --git a/docs/client/packages/backbone.html b/docs/client/packages/backbone.html index 32bde2ba63..f12fc61b12 100644 --- a/docs/client/packages/backbone.html +++ b/docs/client/packages/backbone.html @@ -8,7 +8,7 @@ functionality, it also provides an API for HTML5 pushState and client-side URL routing. For more information about Backbone, see -http://documentcloud.github.com/backbone/. +. {{/better_markdown}} diff --git a/docs/client/packages/bootstrap.html b/docs/client/packages/bootstrap.html index 7be4a681d1..62a712298f 100644 --- a/docs/client/packages/bootstrap.html +++ b/docs/client/packages/bootstrap.html @@ -9,7 +9,7 @@ interactions including typography, forms, buttons, tables, grids, and navigation. For more information about Bootstrap, see -http://twitter.github.com/bootstrap/. +. {{/better_markdown}} diff --git a/docs/client/packages/coffeescript.html b/docs/client/packages/coffeescript.html index f0b183cad2..3f24ce8c54 100644 --- a/docs/client/packages/coffeescript.html +++ b/docs/client/packages/coffeescript.html @@ -10,8 +10,7 @@ interpretation at runtime. CoffeeScript is supported on both the client and the server. Files ending with `.coffee` are automatically compiled to JavaScript. -See http://jashkenas.github.com/coffee-script/ -for more information. +See for more information. {{/better_markdown}} diff --git a/docs/client/packages/d3.html b/docs/client/packages/d3.html new file mode 100644 index 0000000000..f361fa27c8 --- /dev/null +++ b/docs/client/packages/d3.html @@ -0,0 +1,17 @@ + diff --git a/docs/client/packages/jquery.html b/docs/client/packages/jquery.html index 625bee7377..989160cd2d 100644 --- a/docs/client/packages/jquery.html +++ b/docs/client/packages/jquery.html @@ -3,7 +3,7 @@ ## `jquery` -jQuery is a fast and concise JavaScript +[jQuery](http://jquery.com/) is a fast and concise JavaScript Library that simplifies HTML document traversing, event handling, animating, and Ajax interactions for rapid web development. diff --git a/docs/client/packages/less.html b/docs/client/packages/less.html index 191db163ad..da85fe95bb 100644 --- a/docs/client/packages/less.html +++ b/docs/client/packages/less.html @@ -10,8 +10,7 @@ With the `less` package installed, `.less` files in your application are automatically compiled to CSS and the results are included in the client CSS bundle. -See http://lesscss.org/ for -documentation of the LESS language. +See for documentation of the LESS language. {{/better_markdown}} diff --git a/docs/client/packages/sass.html b/docs/client/packages/sass.html index 18bdb9f272..51c917ecf4 100644 --- a/docs/client/packages/sass.html +++ b/docs/client/packages/sass.html @@ -7,10 +7,15 @@ expresions. With the `sass` package installed, `.sass` files in your application are automatically compiled to CSS and the results are included in the client CSS bundle. -See https://github.com/visionmedia/sass.js -for the JavaScript implementation of the Sass language -and http://sass-lang.com/ for the -original project. +See for the JavaScript implementation +of the Sass language and for the original project. + +{{#warning}} +The Sass JavaScript implementation used by Node is unmaintained and doesn't +implement the newest language syntax documented at . It +may be removed from a future version of Meteor; consider using [Less](#less) or +[Stylus](#stylus) instead. +{{/warning}} {{/better_markdown}} diff --git a/docs/client/packages/spiderable.html b/docs/client/packages/spiderable.html index 2255f02978..3bd785c4d4 100644 --- a/docs/client/packages/spiderable.html +++ b/docs/client/packages/spiderable.html @@ -3,36 +3,33 @@ ## `spiderable` -The `spiderable` package is a temporary solution to allow web search -engines to index a Meteor application. It uses the AJAX -Crawling specification published by Google to serve HTML to -compatible spiders (Google, Bing, Yandex, and more). +The `spiderable` package is a temporary solution to allow web search engines to +index a Meteor application. It uses the [AJAX Crawling +specification](https://developers.google.com/webmasters/ajax-crawling/) +published by Google to serve HTML to compatible spiders (Google, Bing, Yandex, +and more). -When a spider requests an HTML snapshot of a page the Meteor server runs -the client half of the application inside phantomjs, a headless browser, and -returns the full HTML generated by the client code. +When a spider requests an HTML snapshot of a page the Meteor server runs the +client half of the application inside [phantomjs](http://phantomjs.org/), a +headless browser, and returns the full HTML generated by the client code. {{#warning}} This is a temporary approach to allow Meteor applications to be searchable. Expect significant changes to this package. {{/warning}} -In order to have links between multiple pages on a site visible to -spiders, apps must use real links (eg ``) rather than -simply re-rendering portions of the page when an element is -clicked. Apps should render their content based on the URL of the page -and can use HTML5 push-state to alter the URL on the client without -triggering a page reload. See the Todos example for a -demonstration. +In order to have links between multiple pages on a site visible to spiders, apps +must use real links (eg ``) rather than simply re-rendering +portions of the page when an element is clicked. Apps should render their +content based on the URL of the page and can use [HTML5 +pushState](https://developer.mozilla.org/en-US/docs/DOM/Manipulating_the_browser_history) +to alter the URL on the client without triggering a page reload. See the [Todos +example](http://meteor.com/examples/todos) for a demonstration. {{#warning}} If you deploy your application with `meteor bundle`, you must install -`phantomjs` (http://phantomjs.org) somewhere in your +`phantomjs` ([http://phantomjs.org](http://phantomjs.org/)) somewhere in your `$PATH`. If you use `meteor deploy` this is already taken care of. {{/warning}} diff --git a/docs/client/packages/stylus.html b/docs/client/packages/stylus.html index 74be1c06b9..4665873c37 100644 --- a/docs/client/packages/stylus.html +++ b/docs/client/packages/stylus.html @@ -14,10 +14,9 @@ The `stylus` package also includes `nib` support. Add `@import 'nib'` to your `.styl` files to enable cross-browser mixins such as `linear-gradient` and `border-radius`. -See http://learnboost.github.com/stylus -for documentation of the Stylus language, -and http://visionmedia.github.com/nib -for documentation of the nib extensions. +See for documentation of the Stylus +language, and for documentation of the nib +extensions. {{/better_markdown}} diff --git a/docs/client/packages/underscore.html b/docs/client/packages/underscore.html index 7c05201773..ae7f2ad88b 100644 --- a/docs/client/packages/underscore.html +++ b/docs/client/packages/underscore.html @@ -9,8 +9,8 @@ concise JavaScript in a functional style. The `underscore` package defines the `_` namespace on both the client and the server. -See http://documentcloud.github.com/underscore/ -for underscore API documentation. +See for underscore API +documentation. {{#warning}} Currently, underscore is included in all projects, as the Meteor diff --git a/examples/leaderboard/.meteor/packages b/examples/leaderboard/.meteor/packages index 6c923cd368..2ca3c152a4 100644 --- a/examples/leaderboard/.meteor/packages +++ b/examples/leaderboard/.meteor/packages @@ -4,4 +4,5 @@ # but you can also edit it by hand. autopublish +insecure preserve-inputs diff --git a/examples/leaderboard/leaderboard.js b/examples/leaderboard/leaderboard.js index 1aa10b0d77..d75006046c 100644 --- a/examples/leaderboard/leaderboard.js +++ b/examples/leaderboard/leaderboard.js @@ -1,5 +1,5 @@ // Set up a collection to contain player information. On the server, -// it is backed by a MongoDB collection named "players." +// it is backed by a MongoDB collection named "players". Players = new Meteor.Collection("players"); diff --git a/examples/other/template-demo/client/template-demo.html b/examples/other/template-demo/client/template-demo.html index d1736b5871..bb3293b1a7 100644 --- a/examples/other/template-demo/client/template-demo.html +++ b/examples/other/template-demo/client/template-demo.html @@ -178,5 +178,6 @@ X={{x}}
+ diff --git a/examples/other/template-demo/client/template-demo.js b/examples/other/template-demo/client/template-demo.js index a3e1ffaacd..7959a9ca2c 100644 --- a/examples/other/template-demo/client/template-demo.js +++ b/examples/other/template-demo/client/template-demo.js @@ -137,32 +137,6 @@ Template.timer.destroyed = function () { /////////////////////////////////////////////////////////////////////////////// -// Run f(). Record its dependencies. Rerun it whenever the -// dependencies change. -// -// Returns an object with a stop() method. Call stop() to stop the -// rerunning. -// -// XXX this should go into Meteor core as Meteor.autorun -var autorun = function (f) { - var ctx; - var slain = false; - var rerun = function () { - if (slain) - return; - ctx = new Meteor.deps.Context; - ctx.run(f); - ctx.onInvalidate(rerun); - }; - rerun(); - return { - stop: function () { - slain = true; - ctx.invalidate(); - } - }; -}; - Template.d3Demo.left = function () { return { group: "left" }; }; @@ -201,6 +175,9 @@ Template.circles.events({ } }); }); + }, + 'click .clear': function () { + Circles.remove({group: this.group}); } }); @@ -230,7 +207,7 @@ Template.circles.rendered = function () { if (! self.handle) { d3.select(self.node).append("rect"); - self.handle = autorun(function () { + self.handle = Meteor.autorun(function () { var circle = d3.select(self.node).selectAll("circle") .data(Circles.find({group: data.group}).fetch(), function (d) { return d._id; }); diff --git a/examples/parties/.meteor/.gitignore b/examples/parties/.meteor/.gitignore new file mode 100644 index 0000000000..4083037423 --- /dev/null +++ b/examples/parties/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/examples/parties/.meteor/packages b/examples/parties/.meteor/packages new file mode 100644 index 0000000000..fae85f5ca5 --- /dev/null +++ b/examples/parties/.meteor/packages @@ -0,0 +1,13 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +preserve-inputs +accounts-ui +accounts-password +d3 +bootstrap +email +accounts-facebook +accounts-twitter diff --git a/examples/parties/client/client.js b/examples/parties/client/client.js new file mode 100644 index 0000000000..da20aa3fc5 --- /dev/null +++ b/examples/parties/client/client.js @@ -0,0 +1,276 @@ +// All Tomorrow's Parties -- client + +Meteor.subscribe("directory"); +Meteor.subscribe("parties"); + +// If no party selected, select one. +Meteor.startup(function () { + Meteor.autorun(function () { + if (! Session.get("selected")) { + var party = Parties.findOne(); + if (party) + Session.set("selected", party._id); + } + }); +}); + +/////////////////////////////////////////////////////////////////////////////// +// Party details sidebar + +Template.details.party = function () { + return Parties.findOne(Session.get("selected")); +}; + +Template.details.anyParties = function () { + return Parties.find().count() > 0; +}; + +Template.details.creatorName = function () { + var owner = Meteor.users.findOne(this.owner); + if (owner._id === Meteor.userId()) + return "me"; + return displayName(owner); +}; + +Template.details.canRemove = function () { + return this.owner === Meteor.userId() && attending(this) === 0; +}; + +Template.details.maybeChosen = function (what) { + var myRsvp = _.find(this.rsvps, function (r) { + return r.user === Meteor.userId(); + }) || {}; + + return what == myRsvp.rsvp ? "chosen btn-inverse" : ""; +}; + +Template.details.events({ + 'click .rsvp_yes': function () { + Meteor.call("rsvp", Session.get("selected"), "yes"); + return false; + }, + 'click .rsvp_maybe': function () { + Meteor.call("rsvp", Session.get("selected"), "maybe"); + return false; + }, + 'click .rsvp_no': function () { + Meteor.call("rsvp", Session.get("selected"), "no"); + return false; + }, + 'click .invite': function () { + openInviteDialog(); + return false; + }, + 'click .remove': function () { + Parties.remove(this._id); + return false; + } +}); + +/////////////////////////////////////////////////////////////////////////////// +// Party attendance widget + +Template.attendance.rsvpName = function () { + var user = Meteor.users.findOne(this.user); + return displayName(user); +}; + +Template.attendance.outstandingInvitations = function () { + var party = Parties.findOne(this._id); + return Meteor.users.find({$and: [ + {_id: {$in: party.invited}}, // they're invited + {_id: {$nin: _.pluck(party.rsvps, 'user')}} // but haven't RSVP'd + ]}); +}; + +Template.attendance.invitationName = function () { + return displayName(this); +}; + +Template.attendance.rsvpIs = function (what) { + return this.rsvp === what; +}; + +Template.attendance.nobody = function () { + return ! this.public && (this.rsvps.length + this.invited.length === 0); +}; + +Template.attendance.canInvite = function () { + return ! this.public && this.owner === Meteor.userId(); +}; + +/////////////////////////////////////////////////////////////////////////////// +// Map display + +// Use jquery to get the position clicked relative to the map element. +var coordsRelativeToElement = function (element, event) { + var offset = $(element).offset(); + var x = event.pageX - offset.left; + var y = event.pageY - offset.top; + return { x: x, y: y }; +}; + +Template.map.events({ + 'mousedown circle, mousedown text': function (event, template) { + Session.set("selected", event.currentTarget.id); + }, + 'dblclick .map': function (event, template) { + if (! Meteor.userId()) // must be logged in to create events + return; + var coords = coordsRelativeToElement(event.currentTarget, event); + openCreateDialog(coords.x / 500, coords.y / 500); + } +}); + +Template.map.rendered = function () { + var self = this; + self.node = self.find("svg"); + + if (! self.handle) { + self.handle = Meteor.autorun(function () { + var selected = Session.get('selected'); + var selectedParty = selected && Parties.findOne(selected); + var radius = function (party) { + return 10 + Math.sqrt(attending(party)) * 10; + }; + + // Draw a circle for each party + var updateCircles = function (group) { + group.attr("id", function (party) { return party._id; }) + .attr("cx", function (party) { return party.x * 500; }) + .attr("cy", function (party) { return party.y * 500; }) + .attr("r", radius) + .attr("class", function (party) { + return party.public ? "public" : "private"; + }) + .style('opacity', function (party) { + return selected === party._id ? 1 : 0.6; + }); + }; + + var circles = d3.select(self.node).select(".circles").selectAll("circle") + .data(Parties.find().fetch(), function (party) { return party._id; }); + + updateCircles(circles.enter().append("circle")); + updateCircles(circles.transition().duration(250).ease("cubic-out")); + circles.exit().transition().duration(250).attr("r", 0).remove(); + + // Label each with the current attendance count + var updateLabels = function (group) { + group.attr("id", function (party) { return party._id; }) + .text(function (party) {return attending(party) || '';}) + .attr("x", function (party) { return party.x * 500; }) + .attr("y", function (party) { return party.y * 500 + radius(party)/2 }) + .style('font-size', function (party) { + return radius(party) * 1.25 + "px"; + }); + }; + + var labels = d3.select(self.node).select(".labels").selectAll("text") + .data(Parties.find().fetch(), function (party) { return party._id; }); + + updateLabels(labels.enter().append("text")); + updateLabels(labels.transition().duration(250).ease("cubic-out")); + labels.exit().remove(); + + // Draw a dashed circle around the currently selected party, if any + var callout = d3.select(self.node).select("circle.callout") + .transition().duration(250).ease("cubic-out"); + if (selectedParty) + callout.attr("cx", selectedParty.x * 500) + .attr("cy", selectedParty.y * 500) + .attr("r", radius(selectedParty) + 10) + .attr("class", "callout") + .attr("display", ''); + else + callout.attr("display", 'none'); + }); + } +}; + +Template.map.destroyed = function () { + this.handle && this.handle.stop(); +}; + +/////////////////////////////////////////////////////////////////////////////// +// Create Party dialog + +var openCreateDialog = function (x, y) { + Session.set("createCoords", {x: x, y: y}); + Session.set("createError", null); + Session.set("showCreateDialog", true); +}; + +Template.page.showCreateDialog = function () { + return Session.get("showCreateDialog"); +}; + +Template.createDialog.events({ + 'click .save': function (event, template) { + var title = template.find(".title").value; + var description = template.find(".description").value; + var public = ! template.find(".private").checked; + var coords = Session.get("createCoords"); + + if (title.length && description.length) { + Meteor.call('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("showCreateDialog", false); + } else { + Session.set("createError", + "It needs a title and a description, or why bother?"); + } + }, + + 'click .cancel': function () { + Session.set("showCreateDialog", false); + } +}); + +Template.createDialog.error = function () { + return Session.get("createError"); +}; + +/////////////////////////////////////////////////////////////////////////////// +// Invite dialog + +var openInviteDialog = function () { + Session.set("showInviteDialog", true); +}; + +Template.page.showInviteDialog = function () { + return Session.get("showInviteDialog"); +}; + +Template.inviteDialog.events({ + 'click .invite': function (event, template) { + Meteor.call('invite', Session.get("selected"), this._id); + }, + 'click .done': function (event, template) { + Session.set("showInviteDialog", false); + return false; + } +}); + +Template.inviteDialog.uninvited = function () { + var party = Parties.findOne(Session.get("selected")); + if (! party) + return []; // party hasn't loaded yet + return Meteor.users.find({$nor: [{_id: {$in: party.invited}}, + {_id: party.owner}]}); +}; + +Template.inviteDialog.displayName = function () { + return displayName(this); +}; diff --git a/examples/parties/client/parties.css b/examples/parties/client/parties.css new file mode 100644 index 0000000000..19b3cb58ef --- /dev/null +++ b/examples/parties/client/parties.css @@ -0,0 +1,81 @@ +.header { + padding: 20px 0; +} + +.details { + margin-top: -18px; +} + +.mask { + position: absolute; + width: 100%; + height: 100%; + top: 0px; + left: 0px; + background-color: #000000; + opacity: .4; + z-index: 1; +} + +.invite-row .invite { + margin: 10px 10px 10px 0; +} + +.rsvp-buttons { + text-align: center; + margin: 40px 0 40px 0; +} + +.description { + margin: 20px 0 20px 0; +} + +.attendance .who { + margin-bottom: 5px; +} + +.attendance .invite { + text-align: center; +} + +input.chosen { + font-weight: bold; +} + +.map { + position: relative; + background-image: url('/soma.jpeg'); + background-position: -20px -20px; + width: 500px; + height: 500px; +} + +.map circle.public { + fill: #49AFCD; +} + +.map circle.private { + fill: #DA4F49; +} + +.map text { + text-anchor: middle; + fill: white; + font-weight: bold; +} + +.map circle.callout { + stroke-width: 5px; + stroke-dasharray: 9, 5; + stroke-opacity: .8; + fill: none; + stroke: red; +} + +.attribution { + position: absolute; + bottom: 0; + background-color: white; + padding: 3px; + padding-bottom: 0; +} diff --git a/examples/parties/client/parties.html b/examples/parties/client/parties.html new file mode 100644 index 0000000000..1a0e47191c --- /dev/null +++ b/examples/parties/client/parties.html @@ -0,0 +1,218 @@ + + All Tomorrow's Parties + + + + + {{> page}} + + + + + + + + + + + + + + diff --git a/examples/parties/model.js b/examples/parties/model.js new file mode 100644 index 0000000000..968a990440 --- /dev/null +++ b/examples/parties/model.js @@ -0,0 +1,162 @@ +// All Tomorrow's Parties -- data model +// Loaded on both the client and the server + +/////////////////////////////////////////////////////////////////////////////// +// Parties + +/* + Each party is represented by a document in the Parties collection: + owner: user id + x, y: Number (screen coordinates in the interval [0, 1]) + title, description: String + public: Boolean + invited: Array of user id's that are invited (only if !public) + rsvps: Array of objects like {user: userId, rsvp: "yes"} (or "no"/"maybe") +*/ +Parties = new Meteor.Collection("parties"); + +Parties.allow({ + insert: function (userId, party) { + return false; // no cowboy inserts -- use createParty method + }, + update: function (userId, parties, fields, modifier) { + return _.all(parties, function (party) { + if (userId !== party.owner) + return false; // not the owner + + var allowed = ["title", "description", "x", "y"]; + if (_.difference(fields, allowed).length) + return false; // tried to write to forbidden field + + // A good improvement would be to validate the type of the new + // value of the field (and if a string, the length.) In the + // future Meteor will have a schema system to makes that easier. + return true; + }); + }, + remove: function (userId, parties) { + return ! _.any(parties, function (party) { + // deny if not the owner, or if other people are going + return party.owner !== userId || attending(party) > 0; + }); + } +}); + +var attending = function (party) { + return (_.groupBy(party.rsvps, 'rsvp').yes || []).length; +}; + +Meteor.methods({ + // options should include: title, description, x, y, public + createParty: function (options) { + options = options || {}; + if (! (typeof options.title === "string" && options.title.length && + typeof options.description === "string" && + options.description.length && + typeof options.x === "number" && options.x >= 0 && options.x <= 1 && + typeof options.y === "number" && options.y >= 0 && options.y <= 1)) + throw new Meteor.Error(400, "Required parameter missing"); + if (options.title.length > 100) + throw new Meteor.Error(413, "Title too long"); + if (options.description.length > 1000) + throw new Meteor.Error(413, "Description too long"); + if (! this.userId) + throw new Meteor.Error(403, "You must be logged in"); + + return Parties.insert({ + owner: this.userId, + x: options.x, + y: options.y, + title: options.title, + description: options.description, + public: !! options.public, + invited: [], + rsvps: [] + }); + }, + + invite: function (partyId, userId) { + var party = Parties.findOne(partyId); + if (! party || party.owner !== this.userId) + throw new Meteor.Error(404, "No such party"); + if (party.public) + throw new Meteor.Error(400, + "That party is public. No need to invite people."); + if (userId !== party.owner && ! _.contains(party.invited, userId)) { + Parties.update(partyId, { $addToSet: { invited: userId } }); + + var from = contactEmail(Meteor.users.findOne(this.userId)); + var to = contactEmail(Meteor.users.findOne(userId)); + if (Meteor.isServer && to) { + // This code only runs on the server. If you didn't want clients + // to be able to see it, you could move it to a separate file. + Email.send({ + from: "noreply@example.com", + to: to, + replyTo: from || undefined, + subject: "PARTY: " + party.title, + text: +"Hey, I just invited you to '" + party.title + "' on All Tomorrow's Parties." + +"\n\nCome check it out: " + Meteor.absoluteUrl() + "\n" + }); + } + } + }, + + rsvp: function (partyId, rsvp) { + if (! this.userId) + throw new Meteor.Error(403, "You must be logged in to RSVP"); + if (! _.contains(['yes', 'no', 'maybe'], rsvp)) + throw new Meteor.Error(400, "Invalid RSVP"); + var party = Parties.findOne(partyId); + if (! party) + throw new Meteor.Error(404, "No such party"); + if (! party.public && party.owner !== this.userId && + !_.contains(party.invited, this.userId)) + // private, but let's not tell this to the user + throw new Meteor.Error(403, "No such party"); + + var rsvpIndex = _.indexOf(_.pluck(party.rsvps, 'user'), this.userId); + if (rsvpIndex !== -1) { + // update existing rsvp entry + + if (Meteor.isServer) { + // update the appropriate rsvp entry with $ + Parties.update( + {_id: partyId, "rsvps.user": this.userId}, + {$set: {"rsvps.$.rsvp": rsvp}}); + } else { + // minimongo doesn't yet support $ in modifier. as a temporary + // workaround, make a modifier that uses an index. this is + // safe on the client since there's only one thread. + var modifier = {$set: {}}; + modifier.$set["rsvps." + rsvpIndex + ".rsvp"] = rsvp; + Parties.update(partyId, modifier); + } + + // Possible improvement: send email to the other people that are + // coming to the party. + } else { + // add new rsvp entry + Parties.update(partyId, + {$push: {rsvps: {user: this.userId, rsvp: rsvp}}}); + } + } +}); + +/////////////////////////////////////////////////////////////////////////////// +// Users + +var displayName = function (user) { + if (user.profile && user.profile.name) + return user.profile.name; + return user.emails[0].address; +}; + +var contactEmail = function (user) { + if (user.emails && user.emails.length) + return user.emails[0].address; + if (user.services && user.services.facebook && user.services.facebook.email) + return user.services.facebook.email; + return null; +}; diff --git a/examples/parties/public/soma.jpeg b/examples/parties/public/soma.jpeg new file mode 100644 index 0000000000..1d4a70424f Binary files /dev/null and b/examples/parties/public/soma.jpeg differ diff --git a/examples/parties/server/server.js b/examples/parties/server/server.js new file mode 100644 index 0000000000..46ac47b686 --- /dev/null +++ b/examples/parties/server/server.js @@ -0,0 +1,10 @@ +// All Tomorrow's Parties -- server + +Meteor.publish("directory", function () { + return Meteor.users.find({}, {fields: {emails: 1, profile: 1}}); +}); + +Meteor.publish("parties", function () { + return Parties.find( + {$or: [{"public": true}, {invited: this.userId}, {owner: this.userId}]}); +}); diff --git a/examples/todos/.meteor/packages b/examples/todos/.meteor/packages index d5213759ba..abc9ef7fad 100644 --- a/examples/todos/.meteor/packages +++ b/examples/todos/.meteor/packages @@ -8,3 +8,4 @@ backbone spiderable jquery preserve-inputs +insecure diff --git a/examples/todos/client/todos.html b/examples/todos/client/todos.html index 5cf0c45b8c..8a6cfb15a9 100644 --- a/examples/todos/client/todos.html +++ b/examples/todos/client/todos.html @@ -98,5 +98,3 @@ {{/each}} - - diff --git a/examples/todos/client/todos.js b/examples/todos/client/todos.js index ee8e394d29..ad72b3ebd8 100644 --- a/examples/todos/client/todos.js +++ b/examples/todos/client/todos.js @@ -19,7 +19,6 @@ Session.set('editing_listname', null); // When editing todo text, ID of the todo Session.set('editing_itemname', null); - // Subscribe to 'lists' collection on startup. // Select a list once data has arrived. Meteor.subscribe('lists', function () { diff --git a/examples/unfinished/accounts-ui-viewer/.meteor/.gitignore b/examples/unfinished/accounts-ui-viewer/.meteor/.gitignore new file mode 100644 index 0000000000..4083037423 --- /dev/null +++ b/examples/unfinished/accounts-ui-viewer/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/examples/unfinished/accounts-ui-viewer/.meteor/packages b/examples/unfinished/accounts-ui-viewer/.meteor/packages new file mode 100644 index 0000000000..634f653e55 --- /dev/null +++ b/examples/unfinished/accounts-ui-viewer/.meteor/packages @@ -0,0 +1,15 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +autopublish +insecure +preserve-inputs +accounts-ui +less +accounts-google +accounts-github +accounts-password +underscore +accounts-facebook diff --git a/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.html b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.html new file mode 100644 index 0000000000..f0a3fc21b4 --- /dev/null +++ b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.html @@ -0,0 +1,123 @@ + + accounts-ui-viewer + + + + {{> page}} + + + + + + + diff --git a/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js new file mode 100644 index 0000000000..21d8486015 --- /dev/null +++ b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js @@ -0,0 +1,225 @@ + +Meteor.users.allow({update: function () { return true; }}); + +if (Meteor.isClient) { + + Accounts.STASH = _.extend({}, Accounts); + Accounts.STASH.userLoaded = Meteor.userLoaded; + + var handleSetting = function (key, value) { + if (key === "numServices") { + _.each(['facebook', 'github', 'google'], + function (serv, i) { + if (i < value) + Accounts[serv] = Accounts.STASH[serv]; + else + Accounts[serv] = null; + }); + } else if (key === "hasPasswords") { + Accounts.password = value && Accounts.STASH.password || null; + var user = Meteor.user(); + if (user) { + if (! value) { + // make sure we have no username if "app" has no passwords + Meteor.users.update(Meteor.userId(), + { $unset: { username: 1 }}); + } else { + // make sure we have a username + Meteor.users.update(Meteor.userId(), + { $set: { username: Meteor.uuid() }}); + } + } + } else if (key === "signupFields") { + Accounts.ui._options.passwordSignupFields = value; + } else if (key === "fakeUserNotLoaded") { + Meteor.userLoaded = (value ? function () { return false; } : + Accounts.STASH.userLoaded); + } + }; + + if (! Session.get('settings')) + Session.set('settings', { + alignRight: false, + positioning: "relative", + numServices: 3, + hasPasswords: true, + signupFields: 'EMAIL_ONLY', + fakeUserNotLoaded: false, + bgcolor: 'white' + }); + else + _.each(Session.get('settings'), function (v,k) { + handleSetting(k, v); + }); + + Template.page.settings = function () { + return Session.get('settings'); + }; + + Template.page.settingsClass = function () { + var settings = Session.get('settings'); + var classes = []; + if (settings.positioning) + classes.push('positioning-' + settings.positioning.toLowerCase()); + return classes.join(' '); + }; + + Template.page.outerClass = function () { + var settings = Session.get('settings'); + var classes = []; + return classes.join(' '); + }; + + var keyValueFromId = function (id) { + var match; + if (id && (match = /^(.*?):(.*)$/.exec(id))) { + var key = match[1]; + var value = castValue(match[2]); + return [key, value]; + } + return null; + }; + + var castValue = function (value) { + if (value === "false") + value = false; + else if (value === "true") + value = true; + else if (/^[0-9]+$/.test(value)) + value = Number(value); + return value; + }; + + Template.radio.maybeChecked = function () { + var curValue = Session.get('settings')[this.key]; + if (castValue(this.value) === curValue) + return 'checked="checked"'; + return ''; + }; + + Template.page.radio = function (key, value, label) { + return new Handlebars.SafeString( + Template.radio({key: key, value: value, label: label})); + }; + + Template.page.button = function (key, value, label) { + return new Handlebars.SafeString( + Template.button({key: key, value: value, label: label})); + }; + + Template.page.match = function (kv) { + kv = keyValueFromId(kv); + if (! kv) + return false; + + return Session.get('settings')[kv[0]] === kv[1]; + }; + + Template.page.dropdownAlign = function () { + var settings = this; + return settings.alignRight ? 'right' : 'left'; + }; + + var fakeLogin = function () { + Accounts.createUser( + {username: Meteor.uuid(), + password: "password", + profile: { name: "Joe Schmoe" }}, + function () { + var user = Meteor.user(); + if (! user) + return; + // delete our username if we are in a mode + // where there aren't usernames/emails/passwords + // (only third-party auth) so that there is no + // "Change Password" button when signed in + if (! Session.get('settings').hasPasswords) + Meteor.users.update(Meteor.userId(), + { $unset: { username: 1 }}); + }); + }; + + var exitFlows = function () { + Accounts._loginButtonsSession.set('inSignupFlow', false); + Accounts._loginButtonsSession.set('inForgotPasswordFlow', false); + Accounts._loginButtonsSession.set('inChangePasswordFlow', false); + Accounts._loginButtonsSession.set('inMessageOnlyFlow', false); + }; + + Template.page.events({ + 'change #controlpane input[type=radio]': function (event) { + var input = event.currentTarget; + var keyValue; + if (input && input.id && (keyValue = keyValueFromId(input.id))) { + var key = keyValue[0]; + var value = keyValue[1]; + if (value === "false") + value = false; + else if (value === "true") + value = true; + var settings = Session.get('settings'); + settings[key] = value; + Session.set('settings', settings); + + handleSetting(key, value); + } + }, + 'click #controlpane button': function (event) { + if (this.key === "fakeConfig") { + var service = this.value; + if (! Accounts.loginServiceConfiguration.findOne({service: service})) + Accounts.loginServiceConfiguration.insert( + {service: service, fake: true}); + } else if (this.key === "unconfig") { + var service = this.value; + Accounts.loginServiceConfiguration.remove({service: service}); + } else if (this.key === "messages") { + if (this.value === "error") { + Accounts._loginButtonsSession.set('errorMessage', 'An error occurred! Gee golly gosh.'); + } else if (this.value === "info") { + Accounts._loginButtonsSession.set('infoMessage', 'Here is some information that is crucial.'); + } else if (this.value === "clear") { + Accounts._loginButtonsSession.resetMessages(); + } + } else if (this.key === "sign") { + if (this.value === 'in') { + // create a random new user + Accounts._loginButtonsSession.closeDropdown(); + fakeLogin(); + } else if (this.value === 'out') { + Meteor.logout(); + } + } else if (this.key === "showConfig") { + Accounts._loginButtonsSession.configureService(this.value); + } else if (this.key === "lov") { + exitFlows(); + Accounts._loginButtonsSession.set("dropdownVisible", true); + if (Meteor.userId()) + Meteor.logout(); + if (this.value === "createAccount") + Accounts._loginButtonsSession.set("inSignupFlow", true); + else if (this.value === "forgotPassword") + Accounts._loginButtonsSession.set("inForgotPasswordFlow", true); + } else if (this.key === "liv") { + exitFlows(); + Accounts._loginButtonsSession.set("dropdownVisible", true); + if (! Meteor.userId()) + fakeLogin(); + if (this.value === "changePassword") + Accounts._loginButtonsSession.set("inChangePasswordFlow", true); + else if (this.value === "messageOnly") + Accounts._loginButtonsSession.set("inMessageOnlyFlow", true); + } else if (this.key === "modals") { + var value = this.value; + _.each([ + 'resetPasswordToken', + 'enrollAccountToken', + 'justVerifiedEmail'], function (k) { + Accounts._loginButtonsSession.set( + k, k.indexOf(value) >= 0 ? 'foo' : null); + }); + } + } + }); + +} \ No newline at end of file diff --git a/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.less b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.less new file mode 100644 index 0000000000..e7db3c5eff --- /dev/null +++ b/examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.less @@ -0,0 +1,110 @@ + +* { padding: 0; margin: 0; } +html, body { height: 100%; } + +#controlpane { + position: absolute; + left: 0; + width: 299px; + top: 0; + bottom: 0; + + background: #eee; + border-right: 1px solid #999; + + overflow: auto; + + h3 { + border-top: 1px solid #999; + font-size: 85%; + margin-bottom: 5px; + } + + .group { + margin: 10px; + } + + input[type=radio] { + margin-left: 5px; + vertical-align: middle; + } + + label { + padding-left: 3px; + } +} + +#previewpane { + position: absolute; + left: 300px; + right: 0; + top: 0; + bottom: 0; + + #preview-wrapper { + margin: 20px; + } +} + +.radio { + white-space: nowrap; +} + +.positioning-floatright { + #login-buttons { + float: right; + margin-right: 180px; + } + + #pos-indicator { + display: block; + top: 0; + right: 0; + width: 200px; + height: 20px; + } +} + +.positioning-relative { + #login-buttons { + position: relative; + left: 150px; + top: 20px; + } + + #pos-indicator { + display: block; + top: 0; + left: 0; + width: 170px; + height: 40px; + } +} + +.positioning-absolute { + #login-buttons { + position: absolute; + left: 170px; + top: 40px; + } + + #pos-indicator { + display: block; + top: 0; + left: 0; + width: 170px; + height: 40px; + } +} + +#pos-indicator { + position: absolute; + background: #eec; + display: none; +} + +a { color: blue; } + +button { padding: 4px; + margin-bottom: 4px; // for when buttons wrap + } \ No newline at end of file diff --git a/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js b/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js index 25edfd7f49..29825f1ae3 100644 --- a/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js +++ b/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js @@ -1,7 +1,7 @@ Leaderboard = Meteor.connect("http://leader2.meteor.com/sockjs"); // XXX I'd rather this be Leaderboard.Players.. can this API be easier? -Players = new Meteor.Collection("players", Leaderboard); +Players = new Meteor.Collection("players", {manager: Leaderboard}); Template.main.events = { 'keydown': function () { diff --git a/examples/unfinished/python-ddp-client/test_input b/examples/unfinished/python-ddp-client/test_input index bdbe747ce8..07da1e3b20 100644 --- a/examples/unfinished/python-ddp-client/test_input +++ b/examples/unfinished/python-ddp-client/test_input @@ -1,15 +1,15 @@ sub -sub xcxc -sub xcxc yzyz -sub xcxc {} +sub undefinedSub +sub undefinedSub someArg +sub undefinedSub {} sub allApps sub myApp "foo.bar" sub myApp ["foo.meteor.com"] call -call xcxc -call xcxc yzyz -call xcxc {} +call undefinedMethod +call undefinedMethod yzyz +call undefinedMethod {} call vote call vote [] call vote ["foo.meteor.com"] diff --git a/examples/wordplay/.meteor/packages b/examples/wordplay/.meteor/packages index 87e2506e78..011b739a68 100644 --- a/examples/wordplay/.meteor/packages +++ b/examples/wordplay/.meteor/packages @@ -3,5 +3,6 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. +insecure jquery preserve-inputs diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js new file mode 100644 index 0000000000..796efc1b2f --- /dev/null +++ b/packages/accounts-base/accounts_client.js @@ -0,0 +1,113 @@ +(function () { + + Meteor.userId = function () { + return Meteor.default_connection.userId(); + }; + + var userLoadedListeners = new Meteor.deps._ContextSet; + var currentUserSubscriptionData; + + Meteor.userLoaded = function () { + userLoadedListeners.addCurrentContext(); + return currentUserSubscriptionData && currentUserSubscriptionData.loaded; + }; + + // This calls userId and userLoaded, both of which are reactive. + Meteor.user = function () { + var userId = Meteor.userId(); + if (!userId) + return null; + if (Meteor.userLoaded()) { + var user = Meteor.users.findOne(userId); + if (user) return user; + } + // Either the subscription isn't done yet, or for some reason this user has + // no published fields (and thus is considered to not exist in + // minimongo). Return a minimal object. + return {_id: userId}; + }; + + Accounts._makeClientLoggedOut = function() { + Accounts._unstoreLoginToken(); + Meteor.default_connection.setUserId(null); + Meteor.default_connection.onReconnect = null; + userLoadedListeners.invalidateAll(); + if (currentUserSubscriptionData) { + currentUserSubscriptionData.handle.stop(); + currentUserSubscriptionData = null; + } + }; + + Accounts._makeClientLoggedIn = function(userId, token) { + Accounts._storeLoginToken(userId, token); + Meteor.default_connection.setUserId(userId); + Meteor.default_connection.onReconnect = function() { + Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) { + if (error) { + Accounts._makeClientLoggedOut(); + throw error; + } else { + // nothing to do + } + }); + }; + userLoadedListeners.invalidateAll(); + if (currentUserSubscriptionData) { + currentUserSubscriptionData.handle.stop(); + } + var data = currentUserSubscriptionData = {loaded: false}; + data.handle = Meteor.subscribe( + "meteor.currentUser", function () { + // Important! We use "data" here, not "currentUserSubscriptionData", so + // that if we log out and in again before this subscription is ready, we + // don't make currentUserSubscriptionData look ready just because this + // older iteration of subscribing is ready. + data.loaded = true; + userLoadedListeners.invalidateAll(); + }); + }; + + Meteor.logout = function (callback) { + Meteor.apply('logout', [], {wait: true}, function(error, result) { + if (error) { + callback && callback(error); + } else { + Accounts._makeClientLoggedOut(); + callback && callback(); + } + }); + }; + + // If we're using Handlebars, register the {{currentUser}} and + // {{currentUserLoaded}} global helpers. + if (typeof Handlebars !== 'undefined') { + Handlebars.registerHelper('currentUser', function () { + return Meteor.user(); + }); + Handlebars.registerHelper('currentUserLoaded', function () { + return Meteor.userLoaded(); + }); + } + + // XXX this can be simplified if we merge in + // https://github.com/meteor/meteor/pull/273 + var loginServicesConfigured = false; + var loginServicesConfiguredListeners = new Meteor.deps._ContextSet; + Meteor.subscribe("meteor.loginServiceConfiguration", function () { + loginServicesConfigured = true; + loginServicesConfiguredListeners.invalidateAll(); + }); + + // A reactive function returning whether the + // loginServiceConfiguration subscription is ready. Used by + // accounts-ui to hide the login button until we have all the + // configuration loaded + Accounts.loginServicesConfigured = function () { + if (loginServicesConfigured) + return true; + + // not yet complete, save the context for invalidation once we are. + loginServicesConfiguredListeners.addCurrentContext(); + return false; + }; +})(); diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js new file mode 100644 index 0000000000..adeb721346 --- /dev/null +++ b/packages/accounts-base/accounts_common.js @@ -0,0 +1,69 @@ +if (typeof Accounts === 'undefined') + Accounts = {}; + +if (!Accounts._options) { + Accounts._options = {}; +} + +// @param options {Object} an object with fields: +// - sendVerificationEmail {Boolean} +// Send email address verification emails to new users created from +// client signups. +// - forbidClientAccountCreation {Boolean} +// Do not allow clients to create accounts directly. +Accounts.config = function(options) { + // validate option keys + var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation"]; + _.each(_.keys(options), function (key) { + if (!_.contains(VALID_KEYS, key)) { + throw new Error("Accounts.config: Invalid key: " + key); + } + }); + + // set values in Accounts._options + _.each(VALID_KEYS, function (key) { + if (key in options) { + if (key in Accounts._options) { + throw new Error("Can't set `" + key + "` more than once"); + } else { + Accounts._options[key] = options[key]; + } + } + }); +}; + +// Users table. Don't use the normal autopublish, since we want to hide +// some fields. Code to autopublish this is in accounts_server.js. +// XXX Allow users to configure this collection name. +Meteor.users = new Meteor.Collection("users", {_preventAutopublish: true}); +// There is an allow call in accounts_server that restricts this +// collection. + + +// Table containing documents with configuration options for each +// login service +Accounts.loginServiceConfiguration = new Meteor.Collection( + "meteor_accounts_loginServiceConfiguration", {_preventAutopublish: true}); +// Leave this collection open in insecure mode. In theory, someone could +// hijack your oauth connect requests to a different endpoint or appId, +// but you did ask for 'insecure'. The advantage is that it is much +// easier to write a configuration wizard that works only in insecure +// mode. + + +// Thrown when trying to use a login service which is not configured +Accounts.ConfigError = function(description) { + this.message = description; +}; +Accounts.ConfigError.prototype = new Error(); +Accounts.ConfigError.prototype.name = 'Accounts.ConfigError'; + +// Thrown when the user cancels the login process (eg, closes an oauth +// popup, declines retina scan, etc) +Accounts.LoginCancelledError = function(description) { + this.message = description; + this.cancelled = true; +}; +Accounts.LoginCancelledError.prototype = new Error(); +Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; + diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js new file mode 100644 index 0000000000..c75ca1e87b --- /dev/null +++ b/packages/accounts-base/accounts_server.js @@ -0,0 +1,328 @@ +(function () { + /// + /// LOGIN HANDLERS + /// + + Meteor.methods({ + // @returns {Object|null} + // If successful, returns {token: reconnectToken, id: userId} + // If unsuccessful (for example, if the user closed the oauth login popup), + // returns null + login: function(options) { + var result = tryAllLoginHandlers(options); + if (result !== null) + this.setUserId(result.id); + return result; + }, + + logout: function() { + this.setUserId(null); + } + }); + + Accounts._loginHandlers = []; + + // Try all of the registered login handlers until one of them + // doesn't return `undefined` (NOT null), meaning it handled this + // call to `login`. Return that return value. + var tryAllLoginHandlers = function (options) { + var result = undefined; + + _.find(Accounts._loginHandlers, function(handler) { + + var maybeResult = handler(options); + if (maybeResult !== undefined) { + result = maybeResult; + return true; + } else { + return false; + } + }); + + if (result === undefined) { + throw new Meteor.Error(400, "Unrecognized options for login request"); + } else { + return result; + } + }; + + // @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; + // - `null`, meaning the user didn't actually log in; + // - {id: userId, accessToken: *}, if the user logged in successfully. + Accounts.registerLoginHandler = function(handler) { + Accounts._loginHandlers.push(handler); + }; + + // support reconnecting using a meteor login token + Accounts._generateStampedLoginToken = function () { + return {token: Meteor.uuid(), when: +(new Date)}; + }; + + Accounts.registerLoginHandler(function(options) { + if (options.resume) { + var user = Meteor.users.findOne( + {"services.resume.loginTokens.token": options.resume}); + if (!user) + throw new Meteor.Error(403, "Couldn't find login token"); + + return { + token: options.resume, + id: user._id + }; + } else { + return undefined; + } + }); + + + /// + /// CURRENT USER + /// + Meteor.userId = function () { + // This function only works if called inside a method. In theory, it + // could also be called from publish statements, since they also + // have a userId associated with them. However, given that publish + // functions aren't reactive, using any of the infomation from + // Meteor.user() in a publish function will always use the value + // from when the function first runs. This is likely not what the + // user expects. The way to make this work in a publish is to do + // Meteor.find(this.userId()).observe and recompute when the user + // record changes. + var currentInvocation = Meteor._CurrentInvocation.get(); + if (!currentInvocation) + throw new Error("Meteor.userId can only be invoked in method calls. Use this.userId in publish functions."); + return currentInvocation.userId; + }; + + Meteor.user = function () { + var userId = Meteor.userId(); + if (!userId) + return null; + return Meteor.users.findOne(userId); + }; + + /// + /// CREATE USER HOOKS + /// + var onCreateUserHook = null; + Accounts.onCreateUser = function (func) { + if (onCreateUserHook) + throw new Error("Can only call onCreateUser once"); + else + onCreateUserHook = func; + }; + + // XXX see comment on Accounts.createUser in passwords_server about adding a + // second "server options" argument. + var defaultCreateUserHook = function (options, user) { + if (options.profile) + user.profile = options.profile; + return user; + }; + Accounts.insertUserDoc = function (options, user) { + // add created at timestamp (and protect passed in user object from + // modification) + user = _.extend({createdAt: +(new Date)}, user); + + var result = {}; + if (options.generateLoginToken) { + var stampedToken = Accounts._generateStampedLoginToken(); + result.token = stampedToken.token; + Meteor._ensure(user, 'services', 'resume'); + if (_.has(user.services.resume, 'loginTokens')) + user.services.resume.loginTokens.push(stampedToken); + else + user.services.resume.loginTokens = [stampedToken]; + } + + var fullUser; + if (onCreateUserHook) { + fullUser = onCreateUserHook(options, user); + + // This is *not* part of the API. We need this because we can't isolate + // the global server environment between tests, meaning we can't test + // both having a create user hook set and not having one set. + if (fullUser === 'TEST DEFAULT HOOK') + fullUser = defaultCreateUserHook(options, user); + } else { + fullUser = defaultCreateUserHook(options, user); + } + + _.each(validateNewUserHooks, function (hook) { + if (!hook(fullUser)) + throw new Meteor.Error(403, "User validation failed"); + }); + + try { + result.id = Meteor.users.insert(fullUser); + } catch (e) { + // XXX string parsing sucks, maybe + // https://jira.mongodb.org/browse/SERVER-3069 will get fixed one day + if (e.name !== 'MongoError') throw e; + var match = e.err.match(/^E11000 duplicate key error index: ([^ ]+)/); + if (!match) throw e; + if (match[1].indexOf('$emails.address') !== -1) + throw new Meteor.Error(403, "Email already exists."); + if (match[1].indexOf('username') !== -1) + throw new Meteor.Error(403, "Username already exists."); + // XXX better error reporting for services.facebook.id duplicate, etc + throw e; + } + + return result; + }; + + var validateNewUserHooks = []; + Accounts.validateNewUser = function (func) { + validateNewUserHooks.push(func); + }; + + + /// + /// MANAGING USER OBJECTS + /// + + // Updates or creates a user after we authenticate with a 3rd party. + // + // @param serviceName {String} Service name (eg, twitter). + // @param serviceData {Object} Data to store in the user's record + // under services[serviceName]. Must include an "id" field + // which is a unique identifier for the user in the service. + // @param options {Object, optional} Other options to pass to insertUserDoc + // (eg, profile) + // @returns {Object} Object with token and id keys, like the result + // of the "login" method. + Accounts.updateOrCreateUserFromExternalService = function( + serviceName, serviceData, options) { + options = _.clone(options || {}); + + if (serviceName === "password" || serviceName === "resume") + throw new Error( + "Can't use updateOrCreateUserFromExternalService with internal service " + + serviceName); + if (!_.has(serviceData, 'id')) + throw new Error( + "Service data for service " + serviceName + " must include id"); + + // Look for a user with the appropriate service user id. + var selector = {}; + selector["services." + serviceName + ".id"] = serviceData.id; + var user = Meteor.users.findOne(selector); + + if (user) { + // We *don't* process options (eg, profile) for update, but we do replace + // the serviceData (eg, so that we keep an unexpired access token and + // don't cache old email addresses in serviceData.email). + // XXX provide an onUpdateUser hook which would let apps update + // the profile too + var stampedToken = Accounts._generateStampedLoginToken(); + var setAttrs = {}; + setAttrs["services." + serviceName] = serviceData; + // XXX Maybe we should re-use the selector above and notice if the update + // touches nothing? + Meteor.users.update( + user._id, + {$set: setAttrs, + $push: {'services.resume.loginTokens': stampedToken}}); + return {token: stampedToken.token, id: user._id}; + } else { + // Create a new user with the service data. Pass other options through to + // insertUserDoc. + user = {services: {}}; + user.services[serviceName] = serviceData; + options.generateLoginToken = true; + return Accounts.insertUserDoc(options, user); + } + }; + + + /// + /// PUBLISHING DATA + /// + + // Publish the current user's record to the client. + // XXX This should just be a universal subscription, but we want to know when + // we've gotten the data after a 'login' method, which currently requires + // us to unsub, sub, and wait for onComplete. This is wasteful because + // we're actually guaranteed to have the data by the time that 'login' + // returns. But we don't expose a callback to Meteor.apply which lets us + // know when the data has been processed (ie, quiescence, or at least + // partial quiescence). + Meteor.publish("meteor.currentUser", function() { + if (this.userId) + return Meteor.users.find( + {_id: this.userId}, + {fields: {profile: 1, username: 1, emails: 1}}); + else { + this.complete(); + return null; + } + }, {is_auto: true}); + + // If autopublish is on, also publish everyone else's user record. + Meteor.default_server.onAutopublish(function () { + var handler = function () { + return Meteor.users.find( + {}, {fields: {profile: 1, username: 1}}); + }; + Meteor.default_server.publish(null, handler, {is_auto: true}); + }); + + // Publish all login service configuration fields other than secret. + Meteor.publish("meteor.loginServiceConfiguration", function () { + return Accounts.loginServiceConfiguration.find({}, {fields: {secret: 0}}); + }, {is_auto: true}); // not techincally autopublish, but stops the warning. + + // Allow a one-time configuration for a login service. Modifications + // to this collection are also allowed in insecure mode. + Meteor.methods({ + "configureLoginService": function(options) { + // Don't let random users configure a service we haven't added yet (so + // that when we do later add it, it's set up with their configuration + // instead of ours). + if (!Accounts[options.service]) + throw new Meteor.Error(403, "Service unknown"); + if (Accounts.loginServiceConfiguration.findOne({service: options.service})) + throw new Meteor.Error(403, "Service " + options.service + " already configured"); + Accounts.loginServiceConfiguration.insert(options); + } + }); + + + /// + /// RESTRICTING WRITES TO USER OBJECTS + /// + + Meteor.users.allow({ + // clients can modify the profile field of their own document, and + // nothing else. + update: function (userId, docs, fields, modifier) { + // if there is more than one doc, at least one of them isn't our + // user record. + if (docs.length !== 1) + return false; + // make sure it is our record + var user = docs[0]; + if (user._id !== userId) + return false; + + // user can only modify the 'profile' field. sets to multiple + // sub-keys (eg profile.foo and profile.bar) are merged into entry + // in the fields list. + if (fields.length !== 1 || fields[0] !== 'profile') + return false; + + return true; + }, + fetch: ['_id'] // we only look at _id. + }); + + /// DEFAULT INDEXES ON USERS + 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}); +}) (); + diff --git a/packages/accounts-base/accounts_tests.js b/packages/accounts-base/accounts_tests.js new file mode 100644 index 0000000000..a60b8c0890 --- /dev/null +++ b/packages/accounts-base/accounts_tests.js @@ -0,0 +1,139 @@ +// XXX it'd be cool to also test that the right thing happens if options +// *are* validated, but Accounts._options is global state which makes this hard +// (impossible?) +Tinytest.add('accounts - config validates keys', function (test) { + test.throws(function () { + Accounts.config({foo: "bar"}); + }); +}); + +Tinytest.add('accounts - updateOrCreateUserFromExternalService', function (test) { + var facebookId = Meteor.uuid(); + var weiboId1 = Meteor.uuid(); + var weiboId2 = Meteor.uuid(); + + + // create an account with facebook + var uid1 = Accounts.updateOrCreateUserFromExternalService( + 'facebook', {id: facebookId, monkey: 42}, {profile: {foo: 1}}).id; + var users = Meteor.users.find({"services.facebook.id": facebookId}).fetch(); + test.length(users, 1); + test.equal(users[0].profile.foo, 1); + test.equal(users[0].services.facebook.monkey, 42); + + // create again with the same id, see that we get the same user. + // it should update services.facebook but not profile. + var uid2 = Accounts.updateOrCreateUserFromExternalService( + 'facebook', {id: facebookId, llama: 50}, + {profile: {foo: 1000, bar: 2}}).id; + test.equal(uid1, uid2); + users = Meteor.users.find({"services.facebook.id": facebookId}).fetch(); + test.length(users, 1); + test.equal(users[0].profile.foo, 1); + test.equal(users[0].profile.bar, undefined); + test.equal(users[0].services.facebook.llama, 50); + test.equal(users[0].services.facebook.monkey, undefined); + // cleanup + Meteor.users.remove(uid1); + + + // users that have different service ids get different users + uid1 = Accounts.updateOrCreateUserFromExternalService( + 'weibo', {id: weiboId1}, {profile: {foo: 1}}).id; + uid2 = Accounts.updateOrCreateUserFromExternalService( + 'weibo', {id: weiboId2}, {profile: {bar: 2}}).id; + test.equal(Meteor.users.find({"services.weibo.id": {$in: [weiboId1, weiboId2]}}).count(), 2); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId1}).profile.foo, 1); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId1}).emails, undefined); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId2}).profile.bar, 2); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId2}).emails, undefined); + + // cleanup + Meteor.users.remove(uid1); + Meteor.users.remove(uid2); + +}); + +Tinytest.add('accounts - insertUserDoc username', function (test) { + var userIn = { + username: Meteor.uuid() + }; + + // user does not already exist. create a user object with fields set. + var result = Accounts.insertUserDoc( + {profile: {name: 'Foo Bar'}}, + userIn + ); + var userOut = Meteor.users.findOne(result.id); + + test.equal(typeof userOut.createdAt, 'number'); + test.equal(userOut.profile.name, 'Foo Bar'); + test.equal(userOut.username, userIn.username); + + // run the hook again. now the user exists, so it throws an error. + test.throws(function () { + Accounts.insertUserDoc( + {profile: {name: 'Foo Bar'}}, + userIn + ); + }); + + // cleanup + Meteor.users.remove(result.id); + +}); + +Tinytest.add('accounts - insertUserDoc email', function (test) { + var email1 = Meteor.uuid(); + var email2 = Meteor.uuid(); + var email3 = Meteor.uuid(); + var userIn = { + emails: [{address: email1, verified: false}, + {address: email2, verified: true}] + }; + + // user does not already exist. create a user object with fields set. + var result = Accounts.insertUserDoc( + {profile: {name: 'Foo Bar'}}, + userIn + ); + var userOut = Meteor.users.findOne(result.id); + + test.equal(typeof userOut.createdAt, 'number'); + test.equal(userOut.profile.name, 'Foo Bar'); + test.equal(userOut.emails, userIn.emails); + + // run the hook again with the exact same emails. + // run the hook again. now the user exists, so it throws an error. + test.throws(function () { + Accounts.insertUserDoc( + {profile: {name: 'Foo Bar'}}, + userIn + ); + }); + + // now with only one of them. + test.throws(function () { + Accounts.insertUserDoc( + {}, {emails: [{address: email1}]} + ); + }); + + test.throws(function () { + Accounts.insertUserDoc( + {}, {emails: [{address: email2}]} + ); + }); + + + // a third email works. + var result3 = Accounts.insertUserDoc( + {}, {emails: [{address: email3}]} + ); + var user3 = Meteor.users.findOne(result3.id); + test.equal(typeof user3.createdAt, 'number'); + + // cleanup + Meteor.users.remove(result.id); + Meteor.users.remove(result3.id); +}); diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js new file mode 100644 index 0000000000..b5f5c11e59 --- /dev/null +++ b/packages/accounts-base/localstorage_token.js @@ -0,0 +1,97 @@ +(function() { + // To be used as the local storage key + var loginTokenKey = "Meteor.loginToken"; + var userIdKey = "Meteor.userId"; + + // Call this from the top level of the test file for any test that does + // logging in and out, to protect multiple tabs running the same tests + // simultaneously from interfering with each others' localStorage. + Accounts._isolateLoginTokenForTest = function () { + loginTokenKey = loginTokenKey + Meteor.uuid(); + userIdKey = userIdKey + Meteor.uuid(); + }; + + Accounts._storeLoginToken = function(userId, token) { + localStorage.setItem(userIdKey, userId); + localStorage.setItem(loginTokenKey, token); + + // to ensure that the localstorage poller doesn't end up trying to + // connect a second time + Accounts._lastLoginTokenWhenPolled = token; + }; + + Accounts._unstoreLoginToken = function() { + localStorage.removeItem(userIdKey); + localStorage.removeItem(loginTokenKey); + + // to ensure that the localstorage poller doesn't end up trying to + // connect a second time + Accounts._lastLoginTokenWhenPolled = null; + }; + + Accounts._storedLoginToken = function() { + return localStorage.getItem(loginTokenKey); + }; + + Accounts._storedUserId = function() { + return localStorage.getItem(userIdKey); + }; +})(); + +// Login with a Meteor access token +// +// XXX having errorCallback only here is weird since other login +// methods will have different callbacks. Standardize this. +Meteor.loginWithToken = function (token, errorCallback) { + Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) { + if (error) { + errorCallback(); + throw error; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + }); +}; + +if (!Accounts._preventAutoLogin) { + // Immediately try to log in via local storage, so that any DDP + // messages are sent after we have established our user account + var token = Accounts._storedLoginToken(); + if (token) { + // On startup, optimistically present us as logged in while the + // request is in flight. This reduces page flicker on startup. + var userId = Accounts._storedUserId(); + userId && Meteor.default_connection.setUserId(userId); + Meteor.loginWithToken(token, function () { + Accounts._makeClientLoggedOut(); + }); + } +} + +// Poll local storage every 3 seconds to login if someone logged in in +// another tab +Accounts._lastLoginTokenWhenPolled = token; +Accounts._pollStoredLoginToken = function() { + if (Accounts._preventAutoLogin) + return; + + var currentLoginToken = Accounts._storedLoginToken(); + + // != instead of !== just to make sure undefined and null are treated the same + if (Accounts._lastLoginTokenWhenPolled != currentLoginToken) { + if (currentLoginToken) + Meteor.loginWithToken(currentLoginToken); // XXX should we pass a callback here? + else + Meteor.logout(); + } + Accounts._lastLoginTokenWhenPolled = currentLoginToken; +}; + +// Semi-internal API. Call this function to re-enable auto login after +// if it was disabled at startup. +Accounts._enableAutoLogin = function () { + Accounts._preventAutoLogin = false; + Accounts._pollStoredLoginToken(); +}; + +setInterval(Accounts._pollStoredLoginToken, 3000); diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js new file mode 100644 index 0000000000..e3e35c31b9 --- /dev/null +++ b/packages/accounts-base/package.js @@ -0,0 +1,25 @@ +Package.describe({ + summary: "A user account system" +}); + +Package.on_use(function (api) { + api.use('underscore', 'server'); + api.use('localstorage-polyfill', 'client'); + api.use('accounts-urls', 'client'); + + // need this because of the Meteor.users collection but in the future + // we'd probably want to abstract this away + api.use('mongo-livedata', ['client', 'server']); + + api.add_files('accounts_common.js', ['client', 'server']); + api.add_files('accounts_server.js', 'server'); + + api.add_files('localstorage_token.js', 'client'); + api.add_files('accounts_client.js', 'client'); +}); + +Package.on_test(function (api) { + api.use('accounts-base'); + api.use('tinytest'); + api.add_files('accounts_tests.js', 'server'); +}); diff --git a/packages/accounts-facebook/facebook_client.js b/packages/accounts-facebook/facebook_client.js new file mode 100644 index 0000000000..42fa7c630a --- /dev/null +++ b/packages/accounts-facebook/facebook_client.js @@ -0,0 +1,35 @@ +(function () { + Meteor.loginWithFacebook = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + var mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); + var display = mobile ? 'touch' : 'popup'; + + var scope = "email"; + if (options && options.requestPermissions) + scope = options.requestPermissions.join(','); + + var loginUrl = + 'https://www.facebook.com/dialog/oauth?client_id=' + config.appId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/facebook?close') + + '&display=' + display + '&scope=' + scope + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback); + }; + +})(); + + + + diff --git a/packages/accounts-facebook/facebook_common.js b/packages/accounts-facebook/facebook_common.js new file mode 100644 index 0000000000..171ca036f6 --- /dev/null +++ b/packages/accounts-facebook/facebook_common.js @@ -0,0 +1,3 @@ +if (!Accounts.facebook) { + Accounts.facebook = {}; +} diff --git a/packages/accounts-facebook/facebook_configure.html b/packages/accounts-facebook/facebook_configure.html new file mode 100644 index 0000000000..aa0344a8c9 --- /dev/null +++ b/packages/accounts-facebook/facebook_configure.html @@ -0,0 +1,19 @@ + diff --git a/packages/accounts-facebook/facebook_configure.js b/packages/accounts-facebook/facebook_configure.js new file mode 100644 index 0000000000..d0e8798f81 --- /dev/null +++ b/packages/accounts-facebook/facebook_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServiceDialogForFacebook.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServiceDialogForFacebook.fields = function () { + return [ + {property: 'appId', label: 'App ID'}, + {property: 'secret', label: 'App Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-facebook/facebook_server.js b/packages/accounts-facebook/facebook_server.js new file mode 100644 index 0000000000..2a003f9003 --- /dev/null +++ b/packages/accounts-facebook/facebook_server.js @@ -0,0 +1,76 @@ +(function () { + + Accounts.oauth.registerService('facebook', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken, + email: identity.email + }, + options: {profile: {name: identity.name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + // Request an access token + var result = Meteor.http.get( + "https://graph.facebook.com/oauth/access_token", { + params: { + client_id: config.appId, + redirect_uri: Meteor.absoluteUrl("_oauth/facebook?close"), + client_secret: config.secret, + code: query.code + } + }); + + if (result.error) + throw result.error; + var response = result.content; + + // Errors come back as JSON but success looks like a query encoded + // in a url + var error_response; + try { + // Just try to parse so that we know if we failed or not, + // while storing the parsed results + error_response = JSON.parse(response); + } catch (e) { + error_response = null; + } + + if (error_response) { + throw new Meteor.Error(500, "Error trying to get access token from Facebook", error_response); + } else { + // Success! Extract the facebook access token from the + // response + var fbAccessToken; + _.each(response.split('&'), function(kvString) { + var kvArray = kvString.split('='); + if (kvArray[0] === 'access_token') + fbAccessToken = kvArray[1]; + // XXX also parse the "expires" argument? + }); + + if (!fbAccessToken) + throw new Meteor.Error(500, "Couldn't find access token in HTTP response."); + return fbAccessToken; + } + }; + + var getIdentity = function (accessToken) { + var result = Meteor.http.get("https://graph.facebook.com/me", { + params: {access_token: accessToken}}); + + if (result.error) + throw result.error; + return result.data; + }; +}) (); diff --git a/packages/accounts-facebook/package.js b/packages/accounts-facebook/package.js new file mode 100644 index 0000000000..9b63589120 --- /dev/null +++ b/packages/accounts-facebook/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Facebook accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['facebook_configure.html', 'facebook_configure.js'], + 'client'); + + api.add_files('facebook_common.js', ['client', 'server']); + api.add_files('facebook_server.js', 'server'); + api.add_files('facebook_client.js', 'client'); +}); diff --git a/packages/accounts-github/github_client.js b/packages/accounts-github/github_client.js new file mode 100644 index 0000000000..c16db9680d --- /dev/null +++ b/packages/accounts-github/github_client.js @@ -0,0 +1,28 @@ +(function () { + Meteor.loginWithGithub = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'github'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + var state = Meteor.uuid(); + + var scope = (options && options.requestPermissions) || []; + var flatScope = _.map(scope, encodeURIComponent).join('+'); + + var loginUrl = + 'https://github.com/login/oauth/authorize' + + '?client_id=' + config.clientId + + '&scope=' + flatScope + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/github?close') + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback, {width: 900, height: 450}); + }; +}) (); diff --git a/packages/accounts-github/github_common.js b/packages/accounts-github/github_common.js new file mode 100644 index 0000000000..0e9b508596 --- /dev/null +++ b/packages/accounts-github/github_common.js @@ -0,0 +1,3 @@ +if (!Accounts.github) { + Accounts.github = {}; +} diff --git a/packages/accounts-github/github_configure.html b/packages/accounts-github/github_configure.html new file mode 100644 index 0000000000..53c26394b3 --- /dev/null +++ b/packages/accounts-github/github_configure.html @@ -0,0 +1,16 @@ + diff --git a/packages/accounts-github/github_configure.js b/packages/accounts-github/github_configure.js new file mode 100644 index 0000000000..cffe1233c4 --- /dev/null +++ b/packages/accounts-github/github_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServiceDialogForGithub.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServiceDialogForGithub.fields = function () { + return [ + {property: 'clientId', label: 'Client ID'}, + {property: 'secret', label: 'Client Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-github/github_server.js b/packages/accounts-github/github_server.js new file mode 100644 index 0000000000..f9dc9147fb --- /dev/null +++ b/packages/accounts-github/github_server.js @@ -0,0 +1,46 @@ +(function () { + Accounts.oauth.registerService('github', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken, + email: identity.email, + username: identity.login + }, + options: {profile: {name: identity.name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'github'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + var result = Meteor.http.post( + "https://github.com/login/oauth/access_token", {headers: {Accept: 'application/json'}, params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/github?close"), + state: query.state + }}); + if (result.error) // if the http response was an error + throw result.error; + if (result.data.error) // if the http response was a json object with an error attribute + throw result.data; + return result.data.access_token; + }; + + var getIdentity = function (accessToken) { + var result = Meteor.http.get( + "https://api.github.com/user", + {params: {access_token: accessToken}}); + if (result.error) + throw result.error; + return result.data; + }; +}) (); \ No newline at end of file diff --git a/packages/accounts-github/package.js b/packages/accounts-github/package.js new file mode 100644 index 0000000000..99187fb043 --- /dev/null +++ b/packages/accounts-github/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Github accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['github_configure.html', 'github_configure.js'], + 'client'); + + api.add_files('github_common.js', ['client', 'server']); + api.add_files('github_server.js', 'server'); + api.add_files('github_client.js', 'client'); +}); diff --git a/packages/accounts-google/google_client.js b/packages/accounts-google/google_client.js new file mode 100644 index 0000000000..75d1a3da2f --- /dev/null +++ b/packages/accounts-google/google_client.js @@ -0,0 +1,39 @@ +(function () { + Meteor.loginWithGoogle = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'google'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + + // always need this to get user id from google. + var required_scope = ['https://www.googleapis.com/auth/userinfo.profile']; + var scope = ['https://www.googleapis.com/auth/userinfo.email']; + if (options && options.requestPermissions) + scope = options.requestPermissions; + scope = _.union(scope, required_scope); + var flat_scope = _.map(scope, encodeURIComponent).join('+'); + + // Might be good to have a way to set access_type=offline. Need to + // both set it here and store the refresh token on the server. + + var loginUrl = + 'https://accounts.google.com/o/oauth2/auth' + + '?response_type=code' + + '&client_id=' + config.clientId + + '&scope=' + flat_scope + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/google?close') + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback); + }; + +}) (); diff --git a/packages/accounts-google/google_common.js b/packages/accounts-google/google_common.js new file mode 100644 index 0000000000..f3945c2c23 --- /dev/null +++ b/packages/accounts-google/google_common.js @@ -0,0 +1,3 @@ +if (!Accounts.google) { + Accounts.google = {}; +} diff --git a/packages/accounts-google/google_configure.html b/packages/accounts-google/google_configure.html new file mode 100644 index 0000000000..d6820b2d37 --- /dev/null +++ b/packages/accounts-google/google_configure.html @@ -0,0 +1,30 @@ + diff --git a/packages/accounts-google/google_configure.js b/packages/accounts-google/google_configure.js new file mode 100644 index 0000000000..c5740c6c9f --- /dev/null +++ b/packages/accounts-google/google_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServiceDialogForGoogle.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServiceDialogForGoogle.fields = function () { + return [ + {property: 'clientId', label: 'Client ID'}, + {property: 'secret', label: 'Client secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-google/google_server.js b/packages/accounts-google/google_server.js new file mode 100644 index 0000000000..167e5f1b75 --- /dev/null +++ b/packages/accounts-google/google_server.js @@ -0,0 +1,48 @@ +(function () { + + Accounts.oauth.registerService('google', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken, + email: identity.email + }, + options: {profile: {name: identity.name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'google'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + var result = Meteor.http.post( + "https://accounts.google.com/o/oauth2/token", {params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/google?close"), + grant_type: 'authorization_code' + }}); + + if (result.error) // if the http response was an error + throw result.error; + if (result.data.error) // if the http response was a json object with an error attribute + throw result.data; + return result.data.access_token; + }; + + var getIdentity = function (accessToken) { + var result = Meteor.http.get( + "https://www.googleapis.com/oauth2/v1/userinfo", + {params: {access_token: accessToken}}); + + if (result.error) + throw result.error; + return result.data; + }; +})(); diff --git a/packages/accounts-google/package.js b/packages/accounts-google/package.js new file mode 100644 index 0000000000..e6484baadb --- /dev/null +++ b/packages/accounts-google/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Google accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['google_configure.html', 'google_configure.js'], + 'client'); + + api.add_files('google_common.js', ['client', 'server']); + api.add_files('google_server.js', 'server'); + api.add_files('google_client.js', 'client'); +}); diff --git a/packages/accounts-oauth-helper/oauth_client.js b/packages/accounts-oauth-helper/oauth_client.js new file mode 100644 index 0000000000..2e29c898b8 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_client.js @@ -0,0 +1,83 @@ +(function () { + // Open a popup window pointing to a OAuth handshake page + // + // @param state {String} The OAuth state generated by the client + // @param url {String} url to page + // @param callback {Function} Callback function to call on + // completion. Takes one argument, null on success, or Error on + // error. + // @param dimensions {optional Object(width, height)} The dimensions of + // the popup. If not passed defaults to something sane + Accounts.oauth.initiateLogin = function(state, url, callback, dimensions) { + // XXX these dimensions worked well for facebook and google, but + // it's sort of weird to have these here. Maybe an optional + // argument instead? + var popup = openCenteredPopup( + url, + (dimensions && dimensions.width) || 650, + (dimensions && dimensions.height) || 331); + + var checkPopupOpen = setInterval(function() { + // Fix for #328 - added a second test criteria (popup.closed === undefined) + // to humour this Android quirk: + // http://code.google.com/p/android/issues/detail?id=21061 + if (popup.closed || popup.closed === undefined) { + clearInterval(checkPopupOpen); + tryLoginAfterPopupClosed(state, callback); + } + }, 100); + }; + + // Send an OAuth login method to the server. If the user authorized + // access in the popup this should log the user in, otherwise + // nothing should happen. + var tryLoginAfterPopupClosed = function(state, callback) { + Meteor.apply('login', [ + {oauth: {state: state}} + ], {wait: true}, function(error, result) { + if (error) { + // got an error from the server. report it back. + callback && callback(error); + } else if (!result) { + // got an empty response from the server. This means our oauth + // state wasn't recognized, which could be either because the + // popup was closed by the user before completion, or some sort + // of error where the oauth provider didn't talk to our server + // correctly and closed the popup somehow. + // + // we assume it was user canceled, and report it as such. this + // will mask failures where things are misconfigured such that + // the server doesn't see the request but does close the + // window. This seems unlikely. + callback && + callback(new Accounts.LoginCancelledError("Popup closed")); + } else { + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + } + }); + }; + + var openCenteredPopup = function(url, width, height) { + var screenX = typeof window.screenX !== 'undefined' + ? window.screenX : window.screenLeft; + var screenY = typeof window.screenY !== 'undefined' + ? window.screenY : window.screenTop; + var outerWidth = typeof window.outerWidth !== 'undefined' + ? window.outerWidth : document.body.clientWidth; + var outerHeight = typeof window.outerHeight !== 'undefined' + ? window.outerHeight : (document.body.clientHeight - 22); + + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + var left = screenX + (outerWidth - width) / 2; + var top = screenY + (outerHeight - height) / 2; + var features = ('width=' + width + ',height=' + height + + ',left=' + left + ',top=' + top); + + var newwindow = window.open(url, 'Login', features); + if (newwindow.focus) + newwindow.focus(); + return newwindow; + }; +})(); diff --git a/packages/accounts-oauth-helper/oauth_common.js b/packages/accounts-oauth-helper/oauth_common.js new file mode 100644 index 0000000000..d47da20292 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_common.js @@ -0,0 +1 @@ +Accounts.oauth = {}; \ No newline at end of file diff --git a/packages/accounts-oauth-helper/oauth_server.js b/packages/accounts-oauth-helper/oauth_server.js new file mode 100644 index 0000000000..c94e540a42 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_server.js @@ -0,0 +1,180 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + Accounts.oauth._services = {}; + + // Register a handler for an OAuth service. The handler will be called + // when we get an incoming http request on /_oauth/{serviceName}. This + // handler should use that information to fetch data about the user + // logging in. + // + // @param name {String} e.g. "google", "facebook" + // @param version {Number} OAuth version (1 or 2) + // @param handleOauthRequest {Function(oauthBinding|query)} + // - (For OAuth1 only) oauthBinding {OAuth1Binding} bound to the appropriate provider + // - (For OAuth2 only) query {Object} parameters passed in query string + // - return value is: + // - {serviceData:, (optional options:)} where serviceData should end + // up in the user's services[name] field + // - `null` if the user declined to give permissions + Accounts.oauth.registerService = function (name, version, handleOauthRequest) { + if (Accounts.oauth._services[name]) + throw new Error("Already registered the " + name + " OAuth service"); + + // Accounts.updateOrCreateUserFromExternalService does a lookup by this id, + // so this should be a unique index. You might want to add indexes for other + // fields returned by your service (eg services.github.login) but you can do + // that in your app. + Meteor.users._ensureIndex('services.' + name + '.id', + {unique: 1, sparse: 1}); + + Accounts.oauth._services[name] = { + serviceName: name, + version: version, + handleOauthRequest: handleOauthRequest + }; + }; + + // When we get an incoming OAuth http request we complete the oauth + // handshake, account and token setup before responding. The + // results are stored in this map which is then read when the login + // method is called. Maps state --> return value of `login` + // + // XXX we should periodically clear old entries + Accounts.oauth._loginResultForState = {}; + + // Listen to calls to `login` with an oauth option set + Accounts.registerLoginHandler(function (options) { + if (!options.oauth) + return undefined; // don't handle + + var result = Accounts.oauth._loginResultForState[options.oauth.state]; + if (result === undefined) // not using `!result` since can be null + // We weren't notified of the user authorizing the login. + return null; + else if (result instanceof Error) + // We tried to login, but there was a fatal error. Report it back + // to the user. + throw result; + else + return result; + }); + + // Listen to incoming OAuth http requests + __meteor_bootstrap__.app + .use(connect.query()) + .use(function(req, res, next) { + // Need to create a Fiber since we're using synchronous http + // calls and nothing else is wrapping this in a fiber + // automatically + Fiber(function () { + Accounts.oauth._middleware(req, res, next); + }).run(); + }); + + Accounts.oauth._middleware = function (req, res, next) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + var serviceName = oauthServiceName(req); + if (!serviceName) { + // not an oauth request. pass to next middleware. + next(); + return; + } + + var service = Accounts.oauth._services[serviceName]; + + // Skip everything if there's no service set by the oauth middleware + if (!service) + throw new Error("Unexpected OAuth service " + serviceName); + + // Make sure we're configured + ensureConfigured(serviceName); + + if (service.version === 1) + Accounts.oauth1._handleRequest(service, req.query, res); + else if (service.version === 2) + Accounts.oauth2._handleRequest(service, req.query, res); + else + throw new Error("Unexpected OAuth version " + service.version); + } catch (err) { + // if we got thrown an error, save it off, it will get passed to + // the approporiate login call (if any) and reported there. + // + // The other option would be to display it in the popup tab that + // is still open at this point, ignoring the 'close' or 'redirect' + // we were passed. But then the developer wouldn't be able to + // style the error or react to it in any way. + if (req.query.state && err instanceof Error) + Accounts.oauth._loginResultForState[req.query.state] = err; + + // also log to the server console, so the developer sees it. + Meteor._debug("Exception in oauth request handler", err); + + // XXX the following is actually wrong. if someone wants to + // redirect rather than close once we are done with the OAuth + // flow, as supported by + // Accounts.oauth_renderOauthResults, this will still + // close the popup instead. Once we fully support the redirect + // flow (by supporting that in places such as + // packages/facebook/facebook_client.js) we should revisit this. + // + // close the popup. because nobody likes them just hanging + // there. when someone sees this multiple times they might + // think to check server logs (we hope?) + closePopup(res); + } + }; + + // Handle /_oauth/* paths and extract the service name + // + // @returns {String|null} e.g. "facebook", or null if this isn't an + // oauth request + var oauthServiceName = function (req) { + + // req.url will be "/_oauth/?" + var barePath = req.url.substring(0, req.url.indexOf('?')); + var splitPath = barePath.split('/'); + + // Any non-oauth request will continue down the default + // middlewares. + if (splitPath[1] !== '_oauth') + return null; + + // Find service based on url + var serviceName = splitPath[2]; + return serviceName; + }; + + // Make sure we're configured + var ensureConfigured = function(serviceName) { + if (!Accounts.loginServiceConfiguration.findOne({service: serviceName})) { + throw new Accounts.ConfigError("Service not configured"); + }; + }; + + Accounts.oauth._renderOauthResults = function(res, query) { + // We support ?close and ?redirect=URL. Any other query should + // just serve a blank page + if ('close' in query) { // check with 'in' because we don't set a value + closePopup(res); + } else if (query.redirect) { + res.writeHead(302, {'Location': query.redirect}); + res.end(); + } else { + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('', 'utf-8'); + } + }; + + var closePopup = function(res) { + res.writeHead(200, {'Content-Type': 'text/html'}); + var content = + ''; + res.end(content, 'utf-8'); + }; + +})(); + + diff --git a/packages/accounts-oauth-helper/package.js b/packages/accounts-oauth-helper/package.js new file mode 100644 index 0000000000..9fe117d37f --- /dev/null +++ b/packages/accounts-oauth-helper/package.js @@ -0,0 +1,12 @@ +Package.describe({ + summary: "Common code for OAuth-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts-base', ['client', 'server']); + + api.add_files('oauth_common.js', ['client', 'server']); + api.add_files('oauth_client.js', 'client'); + api.add_files('oauth_server.js', 'server'); +}); diff --git a/packages/accounts-oauth1-helper/oauth1_binding.js b/packages/accounts-oauth1-helper/oauth1_binding.js new file mode 100644 index 0000000000..835ca74cc1 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_binding.js @@ -0,0 +1,137 @@ +var crypto = __meteor_bootstrap__.require("crypto"); +var querystring = __meteor_bootstrap__.require("querystring"); + +// An OAuth1 wrapper around http calls which helps get tokens and +// takes care of HTTP headers +// +// @param consumerKey {String} As supplied by the OAuth1 provider +// @param consumerSecret {String} As supplied by the OAuth1 provider +// @param urls {Object} +// - requestToken (String): url +// - authorize (String): url +// - accessToken (String): url +// - authenticate (String): url +OAuth1Binding = function(consumerKey, consumerSecret, urls) { + this._consumerKey = consumerKey; + this._secret = consumerSecret; + this._urls = urls; +}; + +OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { + var self = this; + + var headers = self._buildHeader({ + oauth_callback: callbackUrl + }); + + var response = self._call('POST', self._urls.requestToken, headers); + var tokens = querystring.parse(response.content); + + // XXX should we also store oauth_token_secret here? + if (!tokens.oauth_callback_confirmed) + throw new Error("oauth_callback_confirmed false when requesting oauth1 token", tokens); + self.requestToken = tokens.oauth_token; +}; + +OAuth1Binding.prototype.prepareAccessToken = function(query) { + var self = this; + + var headers = self._buildHeader({ + oauth_token: query.oauth_token + }); + + var params = { + oauth_verifier: query.oauth_verifier + }; + + var response = self._call('POST', self._urls.accessToken, headers, params); + var tokens = querystring.parse(response.content); + + self.accessToken = tokens.oauth_token; + self.accessTokenSecret = tokens.oauth_token_secret; +}; + +OAuth1Binding.prototype.call = function(method, url) { + var self = this; + + var headers = self._buildHeader({ + oauth_token: self.accessToken + }); + + var response = self._call(method, url, headers); + return response.data; +}; + +OAuth1Binding.prototype.get = function(url) { + return this.call('GET', url); +}; + +OAuth1Binding.prototype._buildHeader = function(headers) { + var self = this; + return _.extend({ + oauth_consumer_key: self._consumerKey, + oauth_nonce: Meteor.uuid().replace(/\W/g, ''), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: (new Date().valueOf()/1000).toFixed().toString(), + oauth_version: '1.0' + }, headers); +}; + +OAuth1Binding.prototype._getSignature = function(method, url, rawHeaders, accessTokenSecret) { + var self = this; + var headers = self._encodeHeader(rawHeaders); + + var parameters = _.map(headers, function(val, key) { + return key + '=' + val; + }).sort().join('&'); + + var signatureBase = [ + method, + encodeURIComponent(url), + encodeURIComponent(parameters) + ].join('&'); + + var signingKey = encodeURIComponent(self._secret) + '&'; + if (accessTokenSecret) + signingKey += encodeURIComponent(accessTokenSecret); + + return crypto.createHmac('SHA1', signingKey).update(signatureBase).digest('base64'); +}; + +OAuth1Binding.prototype._call = function(method, url, headers, params) { + var self = this; + + // Get the signature + headers.oauth_signature = self._getSignature(method, url, headers, self.accessTokenSecret); + + // Make a authorization string according to oauth1 spec + var authString = self._getAuthHeaderString(headers); + + // Make signed request + var response = Meteor.http.call(method, url, { + params: params, + headers: { + Authorization: authString + } + }); + + if (response.error) { + Meteor._debug('Error sending OAuth1 HTTP call', response.content, method, url, params, authString); + throw response.error; + } + + return response; +}; + +OAuth1Binding.prototype._encodeHeader = function(header) { + return _.reduce(header, function(memo, val, key) { + memo[encodeURIComponent(key)] = encodeURIComponent(val); + return memo; + }, {}); +}; + +OAuth1Binding.prototype._getAuthHeaderString = function(headers) { + return 'OAuth ' + _.map(headers, function(val, key) { + return encodeURIComponent(key) + '="' + encodeURIComponent(val) + '"'; + }).sort().join(', '); +}; diff --git a/packages/accounts-oauth1-helper/oauth1_common.js b/packages/accounts-oauth1-helper/oauth1_common.js new file mode 100644 index 0000000000..d4ce446298 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_common.js @@ -0,0 +1 @@ +Accounts.oauth1 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_server.js b/packages/accounts-oauth1-helper/oauth1_server.js new file mode 100644 index 0000000000..cd2e934871 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_server.js @@ -0,0 +1,67 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + // A place to store request tokens pending verification + Accounts.oauth1._requestTokens = {}; + + // connect middleware + Accounts.oauth1._handleRequest = function (service, query, res) { + + var config = Accounts.loginServiceConfiguration.findOne({service: service.serviceName}); + if (!config) { + throw new Accounts.ConfigError("Service " + service.serviceName + " not configured"); + } + + var urls = Accounts[service.serviceName]._urls; + var oauthBinding = new OAuth1Binding( + config.consumerKey, config.secret, urls); + + if (query.requestTokenAndRedirect) { + // step 1 - get and store a request token + + // Get a request token to start auth process + oauthBinding.prepareRequestToken(query.requestTokenAndRedirect); + + // Keep track of request token so we can verify it on the next step + Accounts.oauth1._requestTokens[query.state] = oauthBinding.requestToken; + + // redirect to provider login, which will redirect back to "step 2" below + var redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; + res.writeHead(302, {'Location': redirectUrl}); + res.end(); + + } else { + // step 2, redirected from provider login - complete the login + // process: if the user authorized permissions, get an access + // token and access token secret and log in as user + + // Get the user's request token so we can verify it and clear it + var requestToken = Accounts.oauth1._requestTokens[query.state]; + delete Accounts.oauth1._requestTokens[query.state]; + + // Verify user authorized access and the oauth_token matches + // the requestToken from previous step + if (query.oauth_token && query.oauth_token === requestToken) { + + // Prepare the login results before returning. This way the + // subsequent call to the `login` method will be immediate. + + // Get the access token for signing requests + oauthBinding.prepareAccessToken(query); + + // Run service-specific handler. + var oauthResult = service.handleOauthRequest(oauthBinding); + + // Get or create user doc and login token for reconnect. + Accounts.oauth._loginResultForState[query.state] = + Accounts.updateOrCreateUserFromExternalService( + service.serviceName, oauthResult.serviceData, oauthResult.options); + } + } + + // Either close the window, redirect, or render nothing + // if all else fails + Accounts.oauth._renderOauthResults(res, query); + }; + +})(); diff --git a/packages/accounts-oauth1-helper/oauth1_tests.js b/packages/accounts-oauth1-helper/oauth1_tests.js new file mode 100644 index 0000000000..d0688ebb89 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_tests.js @@ -0,0 +1,137 @@ + +Tinytest.add("oauth1 - loginResultForState is stored", function (test) { + var http = __meteor_bootstrap__.require('http'); + var twitterfooId = Meteor.uuid(); + var twitterfooName = 'nickname' + Meteor.uuid(); + var twitterfooAccessToken = Meteor.uuid(); + var twitterfooAccessTokenSecret = Meteor.uuid(); + var state = Meteor.uuid(); + + OAuth1Binding.prototype.prepareRequestToken = function() {}; + OAuth1Binding.prototype.prepareAccessToken = function() { + this.accessToken = twitterfooAccessToken; + this.accessTokenSecret = twitterfooAccessTokenSecret; + }; + + if (!Accounts.loginServiceConfiguration.findOne({service: 'twitterfoo'})) + Accounts.loginServiceConfiguration.insert({service: 'twitterfoo'}); + Accounts.twitterfoo = {}; + + try { + // register a fake login service - twitterfoo + Accounts.oauth.registerService("twitterfoo", 1, function (query) { + return { + serviceData: { + id: twitterfooId, + screenName: twitterfooName, + accessToken: twitterfooAccessToken, + accessTokenSecret: twitterfooAccessTokenSecret + } + }; + }); + + // simulate logging in using twitterfoo + Accounts.oauth1._requestTokens[state] = twitterfooAccessToken; + + var req = { + method: "POST", + url: "/_oauth/twitterfoo?close", + query: { + state: state, + oauth_token: twitterfooAccessToken + } + }; + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is created + var user = Meteor.users.findOne( + {"services.twitterfoo.screenName": twitterfooName}); + test.notEqual(user, undefined); + test.equal(user.services.twitterfoo.accessToken, + twitterfooAccessToken); + test.equal(user.services.twitterfoo.accessTokenSecret, + twitterfooAccessTokenSecret); + + // and that that user has a login token + test.equal(user.services.resume.loginTokens.length, 1); + var token = user.services.resume.loginTokens[0].token; + test.notEqual(token, undefined); + + // and that the login result for that user is prepared + test.equal( + Accounts.oauth._loginResultForState[state].id, user._id); + test.equal( + Accounts.oauth._loginResultForState[state].token, token); + } finally { + delete Accounts.oauth._services.twitterfoo; + } +}); + + +Tinytest.add("oauth1 - error in user creation", function (test) { + var http = __meteor_bootstrap__.require('http'); + var state = Meteor.uuid(); + var twitterfailId = Meteor.uuid(); + var twitterfailName = 'nickname' + Meteor.uuid(); + var twitterfailAccessToken = Meteor.uuid(); + var twitterfailAccessTokenSecret = Meteor.uuid(); + + if (!Accounts.loginServiceConfiguration.findOne({service: 'twitterfail'})) + Accounts.loginServiceConfiguration.insert({service: 'twitterfail'}); + Accounts.twitterfail = {}; + + // Wire up access token so that verification passes + Accounts.oauth1._requestTokens[state] = twitterfailAccessToken; + + try { + // register a failing login service + Accounts.oauth.registerService("twitterfail", 1, function (query) { + return { + serviceData: { + id: twitterfailId, + screenName: twitterfailName, + accessToken: twitterfailAccessToken, + accessTokenSecret: twitterfailAccessTokenSecret + }, + options: { + profile: {invalid: true} + } + }; + }); + + // a way to fail new users. duplicated from passwords_tests, but + // shouldn't hurt. + Accounts.validateNewUser(function (user) { + return !(user.profile && user.profile.invalid); + }); + + // simulate logging in with failure + Meteor._suppress_log(1); + var req = { + method: "POST", + url: "/_oauth/twitterfail?close", + query: { + state: state, + oauth_token: twitterfailAccessToken + } + }; + + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is not created + var user = Meteor.users.findOne({"services.twitter.screenName": twitterfailName}); + test.equal(user, undefined); + + // verify an error is stored in login state + test.equal(Accounts.oauth._loginResultForState[state].error, 403); + + // verify error is handed back to login method. + test.throws(function () { + Meteor.apply('login', [{oauth: {version: 1, state: state}}]); + }); + } finally { + delete Accounts.oauth._services.twitterfail; + } +}); + + diff --git a/packages/accounts-oauth1-helper/package.js b/packages/accounts-oauth1-helper/package.js new file mode 100644 index 0000000000..0e15ba5f4f --- /dev/null +++ b/packages/accounts-oauth1-helper/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Common code for OAuth1-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts-oauth-helper', 'client'); + api.use('accounts-base', ['client', 'server']); + + api.add_files('oauth1_binding.js', 'server'); + api.add_files('oauth1_common.js', ['client', 'server']); + api.add_files('oauth1_server.js', 'server'); +}); + +Package.on_test(function (api) { + api.use('accounts-oauth1-helper', 'server'); + api.add_files("oauth1_tests.js", 'server'); +}); diff --git a/packages/accounts-oauth2-helper/oauth2_common.js b/packages/accounts-oauth2-helper/oauth2_common.js new file mode 100644 index 0000000000..0012a34cee --- /dev/null +++ b/packages/accounts-oauth2-helper/oauth2_common.js @@ -0,0 +1 @@ +Accounts.oauth2 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth2-helper/oauth2_server.js b/packages/accounts-oauth2-helper/oauth2_server.js new file mode 100644 index 0000000000..696a59afe5 --- /dev/null +++ b/packages/accounts-oauth2-helper/oauth2_server.js @@ -0,0 +1,25 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + // connect middleware + Accounts.oauth2._handleRequest = function (service, query, res) { + // check if user authorized access + if (!query.error) { + // Prepare the login results before returning. This way the + // subsequent call to the `login` method will be immediate. + + // Run service-specific handler. + var oauthResult = service.handleOauthRequest(query); + + // Get or create user doc and login token for reconnect. + Accounts.oauth._loginResultForState[query.state] = + Accounts.updateOrCreateUserFromExternalService( + service.serviceName, oauthResult.serviceData, oauthResult.options); + } + + // Either close the window, redirect, or render nothing + // if all else fails + Accounts.oauth._renderOauthResults(res, query); + }; + +})(); diff --git a/packages/accounts-oauth2-helper/oauth2_tests.js b/packages/accounts-oauth2-helper/oauth2_tests.js new file mode 100644 index 0000000000..206e509669 --- /dev/null +++ b/packages/accounts-oauth2-helper/oauth2_tests.js @@ -0,0 +1,94 @@ +Tinytest.add("oauth2 - loginResultForState is stored", function (test) { + var http = __meteor_bootstrap__.require('http'); + var foobookId = Meteor.uuid(); + var state = Meteor.uuid(); + + if (!Accounts.loginServiceConfiguration.findOne({service: 'foobook'})) + Accounts.loginServiceConfiguration.insert({service: 'foobook'}); + Accounts.foobook = {}; + + try { + // register a fake login service - foobook + Accounts.oauth.registerService("foobook", 2, function (query) { + return {serviceData: {id: foobookId}}; + }); + + // simulate logging in using foobook + var req = {method: "POST", + url: "/_oauth/foobook?close", + query: {state: state}}; + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is created + var user = Meteor.users.findOne({"services.foobook.id": foobookId}); + test.notEqual(user, undefined); + test.equal(user.services.foobook.id, foobookId); + + // and that that user has a login token + test.equal(user.services.resume.loginTokens.length, 1); + var token = user.services.resume.loginTokens[0].token; + test.notEqual(token, undefined); + + // and that the login result for that user is prepared + test.equal( + Accounts.oauth._loginResultForState[state].id, user._id); + test.equal( + Accounts.oauth._loginResultForState[state].token, token); + } finally { + delete Accounts.oauth._services.foobook; + } +}); + + +Tinytest.add("oauth2 - error in user creation", function (test) { + var http = __meteor_bootstrap__.require('http'); + var state = Meteor.uuid(); + var failbookId = Meteor.uuid(); + + if (!Accounts.loginServiceConfiguration.findOne({service: 'failbook'})) + Accounts.loginServiceConfiguration.insert({service: 'failbook'}); + Accounts.failbook = {}; + + try { + // register a failing login service + Accounts.oauth.registerService("failbook", 2, function (query) { + return { + serviceData: { + id: failbookId + }, + options: { + profile: {invalid: true} + } + }; + }); + + // a way to fail new users. duplicated from passwords_tests, but + // shouldn't hurt. + Accounts.validateNewUser(function (user) { + return !(user.profile && user.profile.invalid); + }); + + // simulate logging in with failure + Meteor._suppress_log(1); + var req = {method: "POST", + url: "/_oauth/failbook?close", + query: {state: state}}; + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is not created + var user = Meteor.users.findOne({"services.failbook.id": failbookId}); + test.equal(user, undefined); + + // verify an error is stored in login state + test.equal(Accounts.oauth._loginResultForState[state].error, 403); + + // verify error is handed back to login method. + test.throws(function () { + Meteor.apply('login', [{oauth: {version: 2, state: state}}]); + }); + } finally { + delete Accounts.oauth._services.failbook; + } +}); + + diff --git a/packages/accounts-oauth2-helper/package.js b/packages/accounts-oauth2-helper/package.js new file mode 100644 index 0000000000..8acf29c0be --- /dev/null +++ b/packages/accounts-oauth2-helper/package.js @@ -0,0 +1,17 @@ +Package.describe({ + summary: "Common code for OAuth2-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts-oauth-helper', 'client'); + api.use('accounts-base', ['client', 'server']); + + api.add_files('oauth2_common.js', ['client', 'server']); + api.add_files('oauth2_server.js', 'server'); +}); + +Package.on_test(function (api) { + api.use('accounts-oauth2-helper', 'server'); + api.add_files("oauth2_tests.js", 'server'); +}); diff --git a/packages/accounts-password/email_templates.js b/packages/accounts-password/email_templates.js new file mode 100644 index 0000000000..fbcca8419a --- /dev/null +++ b/packages/accounts-password/email_templates.js @@ -0,0 +1,53 @@ +Accounts.emailTemplates = { + from: "Meteor Accounts ", + siteName: Meteor.absoluteUrl().replace(/^https?:\/\//, '').replace(/\/$/, ''), + + resetPassword: { + subject: function(user) { + return "How to reset your password on " + Accounts.emailTemplates.siteName; + }, + text: function(user, url) { + var greeting = (user.profile && user.profile.name) ? + ("Hello " + user.profile.name + ",") : "Hello,"; + return greeting + "\n" + + "\n" + + "To reset your password, simply click the link below.\n" + + "\n" + + url + "\n" + + "\n" + + "Thanks.\n"; + } + }, + verifyEmail: { + subject: function(user) { + return "How to verify email address on " + Accounts.emailTemplates.siteName; + }, + text: function(user, url) { + var greeting = (user.profile && user.profile.name) ? + ("Hello " + user.profile.name + ",") : "Hello,"; + return greeting + "\n" + + "\n" + + "To verify your account email, simply click the link below.\n" + + "\n" + + url + "\n" + + "\n" + + "Thanks.\n"; + } + }, + enrollAccount: { + subject: function(user) { + return "An account has been created for you on " + Accounts.emailTemplates.siteName; + }, + text: function(user, url) { + var greeting = (user.profile && user.profile.name) ? + ("Hello " + user.profile.name + ",") : "Hello,"; + return greeting + "\n" + + "\n" + + "To start using the service, simply click the link below.\n" + + "\n" + + url + "\n" + + "\n" + + "Thanks.\n"; + } + } +}; diff --git a/packages/accounts-password/email_tests.js b/packages/accounts-password/email_tests.js new file mode 100644 index 0000000000..4691266c47 --- /dev/null +++ b/packages/accounts-password/email_tests.js @@ -0,0 +1,234 @@ +(function () { + // intentionally initialize later so that we can debug tests after + // they fail without trying to recreate a user with the same email + // address + var email1; + var email2; + var email3; + var email4; + + var resetPasswordToken; + var verifyEmailToken; + var enrollAccountToken; + + Accounts._isolateLoginTokenForTest(); + + testAsyncMulti("accounts emails - reset password flow", [ + function (test, expect) { + email1 = Meteor.uuid() + "-intercept@example.com"; + Accounts.createUser({email: email1, password: 'foobar'}, + expect(function (error) { + test.equal(error, undefined); + })); + }, + function (test, expect) { + Accounts.forgotPassword({email: email1}, expect(function (error) { + test.equal(error, undefined); + })); + }, + function (test, expect) { + Meteor.call("getInterceptedEmails", email1, expect(function (error, result) { + test.notEqual(result, undefined); + test.equal(result.length, 2); // the first is the email verification + var content = result[1]; + + var match = content.match( + new RegExp(window.location.protocol + "//" + + window.location.host + "/#\\/reset-password/(\\S*)")); + test.isTrue(match); + resetPasswordToken = match[1]; + })); + }, + function (test, expect) { + Accounts.resetPassword(resetPasswordToken, "newPassword", expect(function(error) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Meteor.loginWithPassword( + {email: email1}, "newPassword", + expect(function (error) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + } + ]); + + var getVerifyEmailToken = function (email, test, expect) { + Meteor.call("getInterceptedEmails", email, expect(function (error, result) { + test.isFalse(error); + test.notEqual(result, undefined); + test.equal(result.length, 1); + var content = result[0]; + + var match = content.match( + new RegExp(window.location.protocol + "//" + + window.location.host + "/#\\/verify-email/(\\S*)")); + test.isTrue(match); + verifyEmailToken = match[1]; + })); + }; + + var waitUntilLoggedIn = function (test, expect) { + var unblockNextFunction = expect(); + var quiesceCallback = function () { + Meteor.autorun(function (handle) { + if (!Meteor.userLoaded()) return; + handle.stop(); + unblockNextFunction(); + }); + }; + return expect(function (error) { + test.equal(error, undefined); + Meteor.default_connection.onQuiesce(quiesceCallback); + }); + }; + + testAsyncMulti("accounts emails - verify email flow", [ + function (test, expect) { + email2 = Meteor.uuid() + "-intercept@example.com"; + email3 = Meteor.uuid() + "-intercept@example.com"; + Accounts.createUser( + {email: email2, password: 'foobar'}, + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email2); + test.isFalse(Meteor.user().emails[0].verified); + // We should NOT be publishing things like verification tokens! + test.isFalse(_.has(Meteor.user(), 'services')); + }, + function (test, expect) { + getVerifyEmailToken(email2, test, expect); + }, + function (test, expect) { + // Log out, to test that verifyEmail logs us back in. (And if we don't + // do that, waitUntilLoggedIn won't be able to prevent race conditions.) + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Accounts.verifyEmail(verifyEmailToken, + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email2); + test.isTrue(Meteor.user().emails[0].verified); + }, + function (test, expect) { + Meteor.call( + "addEmailForTestAndVerify", email3, + expect(function (error, result) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.default_connection.onQuiesce(expect(function () { + test.equal(Meteor.user().emails.length, 2); + test.equal(Meteor.user().emails[1].address, email3); + test.isFalse(Meteor.user().emails[1].verified); + })); + }, + function (test, expect) { + getVerifyEmailToken(email3, test, expect); + }, + function (test, expect) { + // Log out, to test that verifyEmail logs us back in. (And if we don't + // do that, waitUntilLoggedIn won't be able to prevent race conditions.) + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Accounts.verifyEmail(verifyEmailToken, + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails[1].address, email3); + test.isTrue(Meteor.user().emails[1].verified); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + } + ]); + + var getEnrollAccountToken = function (email, test, expect) { + Meteor.call("getInterceptedEmails", email, expect(function (error, result) { + test.notEqual(result, undefined); + test.equal(result.length, 1); + var content = result[0]; + + var match = content.match( + new RegExp(window.location.protocol + "//" + + window.location.host + "/#\\/enroll-account/(\\S*)")); + test.isTrue(match); + enrollAccountToken = match[1]; + })); + }; + + testAsyncMulti("accounts emails - enroll account flow", [ + function (test, expect) { + email4 = Meteor.uuid() + "-intercept@example.com"; + Meteor.call("createUserOnServer", email4, + expect(function (error, result) { + test.isFalse(error); + var user = result; + test.equal(user.emails.length, 1); + test.equal(user.emails[0].address, email4); + test.isFalse(user.emails[0].verified); + })); + }, + function (test, expect) { + getEnrollAccountToken(email4, test, expect); + }, + function (test, expect) { + Accounts.resetPassword(enrollAccountToken, 'password', + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email4); + test.isTrue(Meteor.user().emails[0].verified); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Meteor.loginWithPassword({email: email4}, 'password', + waitUntilLoggedIn(test ,expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email4); + test.isTrue(Meteor.user().emails[0].verified); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + } + ]); +}) (); diff --git a/packages/accounts-password/email_tests_setup.js b/packages/accounts-password/email_tests_setup.js new file mode 100644 index 0000000000..9684f1e47c --- /dev/null +++ b/packages/accounts-password/email_tests_setup.js @@ -0,0 +1,40 @@ +(function () { + // + // a mechanism to intercept emails sent to addressing including + // the string "intercept", storing them in an array that can then + // be retrieved using the getInterceptedEmails method + // + var oldEmailSend = Email.send; + var interceptedEmails = {}; // (email address) -> (array of contents) + + Email.send = function (options) { + var to = options.to; + if (to.indexOf('intercept') === -1) { + oldEmailSend(options); + } else { + if (!interceptedEmails[to]) + interceptedEmails[to] = []; + + interceptedEmails[to].push(options.text); + } + }; + + Meteor.methods({ + getInterceptedEmails: function (email) { + return interceptedEmails[email]; + }, + + addEmailForTestAndVerify: function (email) { + Meteor.users.update( + {_id: this.userId}, + {$push: {emails: {address: email, verified: false}}}); + Accounts.sendVerificationEmail(this.userId, email); + }, + + createUserOnServer: function (email) { + var userId = Accounts.createUser({email: email}); + Accounts.sendEnrollmentEmail(userId); + return Meteor.users.findOne(userId); + } + }); +}) (); diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js new file mode 100644 index 0000000000..9eb408ad43 --- /dev/null +++ b/packages/accounts-password/package.js @@ -0,0 +1,22 @@ +Package.describe({ + summary: "Password support for accounts." +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('srp', ['client', 'server']); + api.use('email', ['server']); + + api.add_files('email_templates.js', 'server'); + api.add_files('passwords_server.js', 'server'); + api.add_files('passwords_client.js', 'client'); + api.add_files('passwords_common.js', ['server', 'client']); +}); + +Package.on_test(function(api) { + api.use(['accounts-password', 'tinytest', 'test-helpers', 'deps']); + api.add_files('passwords_tests_setup.js', 'server'); + api.add_files('passwords_tests.js', ['client', 'server']); + api.add_files('email_tests_setup.js', 'server'); + api.add_files('email_tests.js', 'client'); +}); diff --git a/packages/accounts-password/passwords_client.js b/packages/accounts-password/passwords_client.js new file mode 100644 index 0000000000..2b877c36ea --- /dev/null +++ b/packages/accounts-password/passwords_client.js @@ -0,0 +1,185 @@ +(function () { + Accounts.createUser = function (options, callback) { + options = _.clone(options); // we'll be modifying options + + if (!options.password) + throw new Error("Must set options.password"); + var verifier = Meteor._srp.generateVerifier(options.password); + // strip old password, replacing with the verifier object + delete options.password; + options.srp = verifier; + + Meteor.apply('createUser', [options], {wait: true}, + function (error, result) { + if (error || !result) { + error = error || new Error("No result"); + callback && callback(error); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(undefined, {message: 'Success'}); + }); + }; + + // @param selector {String|Object} One of the following: + // - {username: (username)} + // - {email: (email)} + // - a string which may be a username or email, depending on whether + // it contains "@". + // @param password {String} + // @param callback {Function(error|undefined)} + Meteor.loginWithPassword = function (selector, password, callback) { + var srp = new Meteor._srp.Client(password); + var request = srp.startExchange(); + + if (typeof selector === 'string') + if (selector.indexOf('@') === -1) + selector = {username: selector}; + else + selector = {email: selector}; + + request.user = selector; + + Meteor.apply('beginPasswordExchange', [request], function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to beginPasswordExchange"); + callback && callback(error); + return; + } + + var response = srp.respondToChallenge(result); + Meteor.apply('login', [ + {srp: response} + ], {wait: true}, function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to login"); + callback && callback(error); + return; + } + + if (!srp.verifyConfirmation({HAMK: result.HAMK})) { + callback && callback(new Error("Server is cheating!")); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + }); + }); + }; + + + // @param oldPassword {String|null} + // @param newPassword {String} + // @param callback {Function(error|undefined)} + Accounts.changePassword = function (oldPassword, newPassword, callback) { + if (!Meteor.user()) { + callback && callback(new Error("Must be logged in to change password.")); + return; + } + + var verifier = Meteor._srp.generateVerifier(newPassword); + + if (!oldPassword) { + Meteor.apply('changePassword', [{srp: verifier}], function (error, result) { + if (error || !result) { + callback && callback( + error || new Error("No result from changePassword.")); + } else { + callback && callback(); + } + }); + } else { // oldPassword + var srp = new Meteor._srp.Client(oldPassword); + var request = srp.startExchange(); + request.user = {id: Meteor.user()._id}; + Meteor.apply('beginPasswordExchange', [request], function (error, result) { + if (error || !result) { + callback && callback( + error || new Error("No result from call to beginPasswordExchange")); + return; + } + + var response = srp.respondToChallenge(result); + response.srp = verifier; + Meteor.apply('changePassword', [response], function (error, result) { + if (error || !result) { + callback && callback( + error || new Error("No result from changePassword.")); + } else { + if (!srp.verifyConfirmation(result)) { + // Monkey business! + callback && callback(new Error("Old password verification failed.")); + } else { + callback && callback(); + } + } + }); + }); + } + }; + + // Sends an email to a user with a link that can be used to reset + // their password + // + // @param options {Object} + // - email: (email) + // @param callback (optional) {Function(error|undefined)} + Accounts.forgotPassword = function(options, callback) { + if (!options.email) + throw new Error("Must pass options.email"); + Meteor.call("forgotPassword", options, callback); + }; + + // Resets a password based on a token originally created by + // Accounts.forgotPassword, and then logs in the matching user. + // + // @param token {String} + // @param newPassword {String} + // @param callback (optional) {Function(error|undefined)} + Accounts.resetPassword = function(token, newPassword, callback) { + if (!token) + throw new Error("Need to pass token"); + if (!newPassword) + throw new Error("Need to pass newPassword"); + + var verifier = Meteor._srp.generateVerifier(newPassword); + Meteor.apply( + "resetPassword", [token, verifier], {wait: true}, + function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to resetPassword"); + callback && callback(error); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + }); + }; + + // Verifies a user's email address based on a token originally + // created by Accounts.sendVerificationEmail + // + // @param token {String} + // @param callback (optional) {Function(error|undefined)} + Accounts.verifyEmail = function(token, callback) { + if (!token) + throw new Error("Need to pass token"); + + Meteor.call( + "verifyEmail", token, + function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to verifyEmail"); + callback && callback(error); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + }); + }; +})(); + diff --git a/packages/accounts-password/passwords_common.js b/packages/accounts-password/passwords_common.js new file mode 100644 index 0000000000..7ad0470e16 --- /dev/null +++ b/packages/accounts-password/passwords_common.js @@ -0,0 +1 @@ +Accounts.password = {}; diff --git a/packages/accounts-password/passwords_server.js b/packages/accounts-password/passwords_server.js new file mode 100644 index 0000000000..5d907de04d --- /dev/null +++ b/packages/accounts-password/passwords_server.js @@ -0,0 +1,461 @@ +(function () { + var selectorFromUserQuery = function (user) { + if (!user) + throw new Meteor.Error(400, "Must pass a user property in request"); + if (_.keys(user).length !== 1) + throw new Meteor.Error(400, "User property must have exactly one field"); + + var selector; + if (user.id) + selector = {_id: user.id}; + else if (user.username) + selector = {username: user.username}; + else if (user.email) + selector = {"emails.address": user.email}; + else + throw new Meteor.Error(400, "Must pass username, email, or id in request.user"); + + return selector; + }; + + Meteor.methods({ + // @param request {Object} with fields: + // user: either {username: (username)}, {email: (email)}, or {id: (userId)} + // A: hex encoded int. the client's public key for this exchange + // @returns {Object} with fields: + // identiy: string uuid + // salt: string uuid + // B: hex encoded int. server's public key for this exchange + beginPasswordExchange: function (request) { + var selector = selectorFromUserQuery(request.user); + + var user = Meteor.users.findOne(selector); + if (!user) + throw new Meteor.Error(403, "User not found"); + + if (!user.services || !user.services.password || + !user.services.password.srp) + throw new Meteor.Error(403, "User has no password set"); + + var verifier = user.services.password.srp; + var srp = new Meteor._srp.Server(verifier); + var challenge = srp.issueChallenge({A: request.A}); + + // save off results in the current session so we can verify them + // later. + this._sessionData.srpChallenge = + { userId: user._id, M: srp.M, HAMK: srp.HAMK }; + + return challenge; + }, + + changePassword: function (options) { + if (!this.userId) + throw new Meteor.Error(401, "Must be logged in"); + + // If options.M is set, it means we went through a challenge with + // the old password. + + if (!options.M /* could allow unsafe password changes here */) { + throw new Meteor.Error(403, "Old password required."); + } + + if (options.M) { + var serialized = this._sessionData.srpChallenge; + if (!serialized || serialized.M !== options.M) + throw new Meteor.Error(403, "Incorrect password"); + if (serialized.userId !== this.userId) + // No monkey business! + throw new Meteor.Error(403, "Incorrect password"); + // Only can use challenges once. + delete this._sessionData.srpChallenge; + } + + var verifier = options.srp; + if (!verifier && options.password) { + verifier = Meteor._srp.generateVerifier(options.password); + } + if (!verifier || !verifier.identity || !verifier.salt || + !verifier.verifier) + throw new Meteor.Error(400, "Invalid verifier"); + + // XXX this should invalidate all login tokens other than the current one + // (or it should assign a new login token, replacing existing ones) + Meteor.users.update({_id: this.userId}, + {$set: {'services.password.srp': verifier}}); + + var ret = {passwordChanged: true}; + if (serialized) + ret.HAMK = serialized.HAMK; + return ret; + }, + + forgotPassword: function (options) { + var email = options.email; + if (!email) + throw new Meteor.Error(400, "Need to set options.email"); + + var user = Meteor.users.findOne({"emails.address": email}); + if (!user) + throw new Meteor.Error(403, "User not found"); + + Accounts.sendResetPasswordEmail(user._id, email); + }, + + resetPassword: function (token, newVerifier) { + if (!token) + throw new Meteor.Error(400, "Need to pass token"); + if (!newVerifier) + throw new Meteor.Error(400, "Need to pass newVerifier"); + + var user = Meteor.users.findOne({"services.password.reset.token": token}); + if (!user) + throw new Meteor.Error(403, "Token expired"); + var email = user.services.password.reset.email; + if (!_.include(_.pluck(user.emails || [], 'address'), email)) + throw new Meteor.Error(403, "Token has invalid email address"); + + 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} + }); + + this.setUserId(user._id); + return {token: stampedLoginToken.token, id: user._id}; + }, + + verifyEmail: function (token) { + if (!token) + throw new Meteor.Error(400, "Need to pass token"); + + var user = Meteor.users.findOne( + {'services.email.verificationTokens.token': token}); + if (!user) + throw new Meteor.Error(403, "Verify email link expired"); + + var tokenRecord = _.find(user.services.email.verificationTokens, + function (t) { + return t.token == token; + }); + if (!tokenRecord) + throw new Meteor.Error(403, "Verify email link expired"); + + var emailsRecord = _.find(user.emails, function (e) { + return e.address == tokenRecord.address; + }); + if (!emailsRecord) + throw new Meteor.Error(403, "Verify email link is for unknown address"); + + // Log the user in with a new login token. + var stampedLoginToken = Accounts._generateStampedLoginToken(); + + // By including the address in the query, we can use 'emails.$' in the + // modifier to get a reference to the specific object in the emails + // array. See + // http://www.mongodb.org/display/DOCS/Updating/#Updating-The%24positionaloperator) + // http://www.mongodb.org/display/DOCS/Updating#Updating-%24pull + Meteor.users.update( + {_id: user._id, + 'emails.address': tokenRecord.address}, + {$set: {'emails.$.verified': true}, + $pull: {'services.email.verificationTokens': {token: token}}, + $push: {'services.resume.loginTokens': stampedLoginToken}}); + + this.setUserId(user._id); + return {token: stampedLoginToken.token, id: user._id}; + } + }); + + + // send the user an email with a link that when opened allows the user + // to set a new password, without the old password. + Accounts.sendResetPasswordEmail = function (userId, email) { + // Make sure the user exists, and email is one of their addresses. + var user = Meteor.users.findOne(userId); + if (!user) + throw new Error("Can't find user"); + // pick the first email if we weren't passed an email. + if (!email && user.emails && user.emails[0]) + email = user.emails[0].address; + // make sure we have a valid email + if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) + throw new Error("No such email for user."); + + var token = Meteor.uuid(); + var when = +(new Date); + Meteor.users.update(userId, {$set: { + "services.password.reset": { + token: token, + email: email, + when: when + } + }}); + + var resetPasswordUrl = Accounts.urls.resetPassword(token); + Email.send({ + to: email, + from: Accounts.emailTemplates.from, + subject: Accounts.emailTemplates.resetPassword.subject(user), + text: Accounts.emailTemplates.resetPassword.text(user, resetPasswordUrl)}); + }; + + + // send the user an email with a link that when opened marks that + // address as verified + Accounts.sendVerificationEmail = function (userId, address) { + // XXX Also generate a link using which someone can delete this + // account if they own said address but weren't those who created + // this account. + + // Make sure the user exists, and address is one of their addresses. + var user = Meteor.users.findOne(userId); + if (!user) + throw new Error("Can't find user"); + // pick the first unverified address if we weren't passed an address. + if (!address) { + var email = _.find(user.emails || [], + function (e) { return !e.verified; }); + address = (email || {}).address; + } + // make sure we have a valid address + if (!address || !_.contains(_.pluck(user.emails || [], 'address'), address)) + throw new Error("No such email address for user."); + + + var tokenRecord = { + token: Meteor.uuid(), + address: address, + when: +(new Date)}; + Meteor.users.update( + {_id: userId}, + {$push: {'services.email.verificationTokens': tokenRecord}}); + + var verifyEmailUrl = Accounts.urls.verifyEmail(tokenRecord.token); + Email.send({ + to: address, + from: Accounts.emailTemplates.from, + subject: Accounts.emailTemplates.verifyEmail.subject(user), + text: Accounts.emailTemplates.verifyEmail.text(user, verifyEmailUrl) + }); + }; + + // send the user an email informing them that their account was created, with + // a link that when opened both marks their email as verified and forces them + // to choose their password. The email must be one of the addresses in the + // user's emails field, or undefined to pick the first email automatically. + Accounts.sendEnrollmentEmail = function (userId, email) { + // XXX refactor! This is basically identical to sendResetPasswordEmail. + + // Make sure the user exists, and email is in their addresses. + var user = Meteor.users.findOne(userId); + if (!user) + throw new Error("Can't find user"); + // pick the first email if we weren't passed an email. + if (!email && user.emails && user.emails[0]) + email = user.emails[0].address; + // make sure we have a valid email + if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) + throw new Error("No such email for user."); + + + var token = Meteor.uuid(); + var when = +(new Date); + Meteor.users.update(userId, {$set: { + "services.password.reset": { + token: token, + email: email, + when: when + } + }}); + + var enrollAccountUrl = Accounts.urls.enrollAccount(token); + Email.send({ + to: email, + from: Accounts.emailTemplates.from, + subject: Accounts.emailTemplates.enrollAccount.subject(user), + text: Accounts.emailTemplates.enrollAccount.text(user, enrollAccountUrl) + }); + }; + + + // handler to login with password + Accounts.registerLoginHandler(function (options) { + if (!options.srp) + return undefined; // don't handle + if (!options.srp.M) + throw new Meteor.Error(400, "Must pass M in options.srp"); + + // we're always called from within a 'login' method, so this should + // be safe. + var currentInvocation = Meteor._CurrentInvocation.get(); + var serialized = currentInvocation._sessionData.srpChallenge; + if (!serialized || serialized.M !== options.srp.M) + throw new Meteor.Error(403, "Incorrect password"); + // Only can use challenges once. + delete currentInvocation._sessionData.srpChallenge; + + var userId = serialized.userId; + var user = Meteor.users.findOne(userId); + // Was the user deleted since the start of this challenge? + if (!user) + throw new Meteor.Error(403, "User not found"); + var stampedLoginToken = Accounts._generateStampedLoginToken(); + Meteor.users.update( + userId, {$push: {'services.resume.loginTokens': stampedLoginToken}}); + + return {token: stampedLoginToken.token, id: userId, HAMK: serialized.HAMK}; + }); + + // handler to login with plaintext password. + // + // The meteor client doesn't use this, it is for other DDP clients who + // haven't implemented SRP. Since it sends the password in plaintext + // over the wire, it should only be run over SSL! + // + // Also, it might be nice if servers could turn this off. Or maybe it + // should be opt-in, not opt-out? Accounts.config option? + Accounts.registerLoginHandler(function (options) { + if (!options.password || !options.user) + return undefined; // don't handle + + var selector = selectorFromUserQuery(options.user); + var user = Meteor.users.findOne(selector); + if (!user) + throw new Meteor.Error(403, "User not found"); + + if (!user.services || !user.services.password || + !user.services.password.srp) + throw new Meteor.Error(403, "User has no password set"); + + // Just check the verifier output when the same identity and salt + // are passed. Don't bother with a full exchange. + var verifier = user.services.password.srp; + var newVerifier = Meteor._srp.generateVerifier(options.password, { + identity: verifier.identity, salt: verifier.salt}); + + if (verifier.verifier !== newVerifier.verifier) + throw new Meteor.Error(403, "Incorrect password"); + + var stampedLoginToken = Accounts._generateStampedLoginToken(); + Meteor.users.update( + user._id, {$push: {'services.resume.loginTokens': stampedLoginToken}}); + + return {token: stampedLoginToken.token, id: user._id}; + }); + + + Meteor.setPassword = function (userId, newPassword) { + var user = Meteor.users.findOne(userId); + if (!user) + throw new Meteor.Error(403, "User not found"); + var newVerifier = Meteor._srp.generateVerifier(newPassword); + + Meteor.users.update({_id: user._id}, { + $set: {'services.password.srp': newVerifier}}); + }; + + + //////////// + // Creating users: + + + // Shared createUser function called from the createUser method, both + // if originates in client or server code. Calls user provided hooks, + // does the actual user insertion. + // + // returns an object with id: userId, and (if options.generateLoginToken is + // set) token: loginToken. + var createUser = function (options) { + var username = options.username; + var email = options.email; + if (!username && !email) + throw new Meteor.Error(400, "Need to set a username or email"); + + // Raw password. The meteor client doesn't send this, but a DDP + // client that didn't implement SRP could send this. This should + // only be done over SSL. + if (options.password) { + if (options.srp) + throw new Meteor.Error(400, "Don't pass both password and srp in options"); + options.srp = Meteor._srp.generateVerifier(options.password); + } + + var user = {services: {}}; + if (options.srp) + user.services.password = {srp: options.srp}; // XXX validate verifier + if (username) + user.username = username; + if (email) + user.emails = [{address: email, verified: false}]; + + return Accounts.insertUserDoc(options, user); + }; + + // method for create user. Requests come from the client. + Meteor.methods({ + createUser: function (options) { + options = _.clone(options); + options.generateLoginToken = true; + if (Accounts._options.forbidClientAccountCreation) + throw new Meteor.Error(403, "Signups forbidden"); + + // Create user. result contains id and token. + var result = createUser(options); + // safety belt. createUser is supposed to throw on error. send 500 error + // instead of sending a verification email with empty userid. + if (!result.id) + throw new Error("createUser failed to insert new user"); + + // If `Accounts._options.sendVerificationEmail` is set, register + // a token to verify the user's primary email, and send it to + // that address. + if (options.email && Accounts._options.sendVerificationEmail) + Accounts.sendVerificationEmail(result.id, options.email); + + // client gets logged in as the new user afterwards. + this.setUserId(result.id); + return result; + } + }); + + // Create user directly on the server. + // + // Unlike the client version, this does not log you in as this user + // after creation. + // + // returns userId or throws an error if it can't create + // + // XXX add another argument ("server options") that gets sent to onCreateUser, + // which is always empty when called from the createUser method? eg, "admin: + // true", which we want to prevent the client from setting, but which a custom + // method calling Accounts.createUser could set? + Accounts.createUser = function (options, callback) { + options = _.clone(options); + options.generateLoginToken = false; + + // XXX allow an optional callback? + if (callback) { + throw new Error("Accounts.createUser with callback not supported on the server yet."); + } + + var userId = createUser(options).id; + + return userId; + }; + + // PASSWORD-SPECIFIC INDEXES ON USERS + Meteor.users._ensureIndex('emails.validationTokens.token', + {unique: 1, sparse: 1}); + Meteor.users._ensureIndex('emails.password.reset.token', + {unique: 1, sparse: 1}); +})(); diff --git a/packages/accounts-password/passwords_tests.js b/packages/accounts-password/passwords_tests.js new file mode 100644 index 0000000000..4f8e11e19c --- /dev/null +++ b/packages/accounts-password/passwords_tests.js @@ -0,0 +1,341 @@ +if (Meteor.isClient) (function () { + + // XXX note, only one test can do login/logout things at once! for + // now, that is this test. + + Accounts._isolateLoginTokenForTest(); + + var logoutStep = function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }; + + var verifyUsername = function (someUsername, test, expect) { + var callWhenLoaded = expect(function() { + test.equal(Meteor.user().username, someUsername); + }); + return function () { + Meteor.autorun(function(handle) { + if (!Meteor.userLoaded()) return; + handle.stop(); + callWhenLoaded(); + }); + }; + }; + var loggedInAs = function (someUsername, test, expect) { + var quiesceCallback = verifyUsername(someUsername, test, expect); + return expect(function (error) { + test.equal(error, undefined); + Meteor.default_connection.onQuiesce(quiesceCallback); + }); + }; + + // 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 email; + var password, password2, password3; + + testAsyncMulti("passwords - long series", [ + function (test, expect) { + username = Meteor.uuid(); + username2 = Meteor.uuid(); + username3 = Meteor.uuid(); + // use -intercept so that we don't print to the console + email = Meteor.uuid() + '-intercept@example.com'; + password = 'password'; + password2 = 'password2'; + password3 = 'password3'; + }, + + function (test, expect) { + Accounts.createUser( + {username: username, email: email, password: password}, + loggedInAs(username, test, expect)); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword(username, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + // This next step tests reactive contexts which are reactive on + // Meteor.user() without explicitly calling Meteor.userLoaded() --- we want + // to make sure that user loading finishing invalidates them too. + function (test, expect) { + // Set up a reactive context that only refreshes when Meteor.user() is + // invalidated. + var user; + var handle1 = Meteor.autorun(function () { + user = Meteor.user(); + }); + // At the beginning, we're not logged in. + test.equal(user, null); + + // This will get called once a second context (which does explicitly call + // Meteor.userLoaded()) tells us we are ready. + var callWhenLoaded = expect(function () { + Meteor.flush(); + // ... and this means that the first context did refresh and give us + // data. + test.isTrue(user.emails); + handle1.stop(); + }); + var waitForLoaded = expect(function () { + Meteor.autorun(function(handle2) { + if (!Meteor.userLoaded()) return; + handle2.stop(); + callWhenLoaded(); + }); + }); + Meteor.loginWithPassword(username, password, expect(function (error) { + test.equal(error, undefined); + test.notEqual(Meteor.userId(), null); + // Since userId has changed, the first autorun has been invalidated, so + // flush will re-run it and user will become not null. In the *CURRENT + // IMPLEMENTATION*, we will have just called _makeClientLoggedIn which + // just started a new meteor.currentUser subscription. There is no way + // that it is complete yet because we haven't gotten back to the event + // loop to actually get the data, so user.emails hasn't been populated + // yet. (That said, if we redo how userLoaded is implemented to not + // involve unsub/sub, it's possible that this test may become flaky by + // the test.isFalse failing.) + Meteor.flush(); + test.notEqual(user, null); + test.isFalse(user.emails); + waitForLoaded(); + })); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword({username: username}, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword(email, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword({email: email}, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + // plain text password. no API for this, have to send a raw message. + function (test, expect) { + Meteor.call( + // wrong password + 'login', {user: {email: email}, password: password2}, + expect(function (error, result) { + test.isTrue(error); + test.isFalse(result); + test.isFalse(Meteor.user()); + })); + }, + function (test, expect) { + var quiesceCallback = verifyUsername(username, test, expect); + Meteor.call( + // right password + 'login', {user: {email: email}, password: password}, + expect(function (error, result) { + test.equal(error, undefined); + test.isTrue(result.id); + test.isTrue(result.token); + // emulate the real login behavior, so as not to confuse test. + Accounts._makeClientLoggedIn(result.id, result.token); + Meteor.default_connection.onQuiesce(quiesceCallback); + })); + }, + // change password with bad old password. we stay logged in. + function (test, expect) { + var quiesceCallback = verifyUsername(username, test, expect); + Accounts.changePassword(password2, password2, expect(function (error) { + test.isTrue(error); + Meteor.default_connection.onQuiesce(quiesceCallback); + })); + }, + // change password with good old password. + function (test, expect) { + Accounts.changePassword(password, password2, + loggedInAs(username, test, expect)); + }, + logoutStep, + // old password, failed login + function (test, expect) { + Meteor.loginWithPassword(email, 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)); + }, + logoutStep, + // create user with raw password + function (test, expect) { + var quiesceCallback = verifyUsername(username2, test, expect); + Meteor.call('createUser', {username: username2, password: password2}, + expect(function (error, result) { + test.equal(error, undefined); + test.isTrue(result.id); + test.isTrue(result.token); + // emulate the real login behavior, so as not to confuse test. + Accounts._makeClientLoggedIn(result.id, result.token); + Meteor.default_connection.onQuiesce(quiesceCallback); + })); + }, + 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"); + })); + }, + 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"); + })); + }, + // test Accounts.onCreateUser + function(test, expect) { + Accounts.createUser( + {username: username3, password: password3, + testOnCreateUserHook: true}, + loggedInAs(username3, test, expect)); + }, + function(test, expect) { + test.equal(Meteor.user().profile.touchedByOnCreateUser, true); + }, + // 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 clientUser = Meteor.user(); + Meteor.call('testMeteorUser', expect(function (err, result) { + test.equal(result._id, clientUser._id); + test.equal(result.profile.touchedByOnCreateUser, true); + test.equal(err, undefined); + })); + }, + function(test, expect) { + Meteor.call('clearUsernameAndProfile'); + Meteor.default_connection.onQuiesce(expect(function() { + test.isTrue(Meteor.userId()); + var user = Meteor.user(); + test.equal(user, {_id: Meteor.userId()}); + })); + }, + logoutStep, + function(test, expect) { + var clientUser = Meteor.user(); + test.equal(clientUser, null); + Meteor.call('testMeteorUser', expect(function (err, result) { + test.equal(err, undefined); + test.equal(result, null); + })); + } + + ]); + +}) (); + + +if (Meteor.isServer) (function () { + + Tinytest.add( + 'passwords - setup more than one onCreateUserHook', + function (test) { + test.throws(function() { + Accounts.onCreateUser(function () {}); + }); + }); + + + Tinytest.add( + 'passwords - createUser hooks', + function (test) { + var email = Meteor.uuid() + '@example.com'; + test.throws(function () { + // should fail the new user validators + Accounts.createUser({email: email, profile: {invalid: true}}); + }); + + // disable sending emails + var oldEmailSend = Email.send; + Email.send = function() {}; + var userId = Accounts.createUser({email: email, + testOnCreateUserHook: true}); + Email.send = oldEmailSend; + + test.isTrue(userId); + var user = Meteor.users.findOne(userId); + test.equal(user.profile.touchedByOnCreateUser, true); + }); + + + Tinytest.add( + 'passwords - setPassword', + function (test) { + var username = Meteor.uuid(); + + var userId = Accounts.createUser({username: username}); + + var user = Meteor.users.findOne(userId); + // no services yet. + test.equal(user.services.password, undefined); + + // set a new password. + Meteor.setPassword(userId, 'new password'); + user = Meteor.users.findOne(userId); + var oldVerifier = user.services.password.srp; + test.isTrue(user.services.password.srp); + + // reset with the same password, see we get a different verifier + Meteor.setPassword(userId, 'new password'); + user = Meteor.users.findOne(userId); + var newVerifier = user.services.password.srp; + test.notEqual(oldVerifier.salt, newVerifier.salt); + test.notEqual(oldVerifier.identity, newVerifier.identity); + test.notEqual(oldVerifier.verifier, newVerifier.verifier); + + // cleanup + Meteor.users.remove(userId); + }); + + + // This test properly belongs in accounts-base/accounts_tests.js, but + // this is where the tests that actually log in are. + Tinytest.add('accounts - user() out of context', function (test) { + // basic server context, no method. + test.throws(function () { + Meteor.user(); + }); + }); + + // XXX would be nice to test Accounts.config({forbidClientAccountCreation: true}) +}) (); diff --git a/packages/accounts-password/passwords_tests_setup.js b/packages/accounts-password/passwords_tests_setup.js new file mode 100644 index 0000000000..e1142de742 --- /dev/null +++ b/packages/accounts-password/passwords_tests_setup.js @@ -0,0 +1,45 @@ +Accounts.validateNewUser(function (user) { + if (user.profile && user.profile.invalidAndThrowException) + throw new Meteor.Error(403, "An exception thrown within Accounts.validateNewUser"); + return !(user.profile && user.profile.invalid); +}); + +Accounts.onCreateUser(function (options, user) { + if (options.testOnCreateUserHook) { + user.profile = user.profile || {}; + user.profile.touchedByOnCreateUser = true; + return user; + } else { + return 'TEST DEFAULT HOOK'; + } +}); + + +// Because this is global state that affects every client, we can't turn +// it on and off during the tests. Doing so would mean two simultaneous +// test runs could collide with each other. +// +// We should probably have some sort of server-isolation between +// multiple test runs. Perhaps a separate server instance per run. This +// problem isn't unique to this test, there are other places in the code +// where we do various hacky things to work around the lack of +// server-side isolation. +// +// For now, we just test the one configuration state. You can comment +// out each configuration option and see that the tests fail. +Accounts.config({ + sendVerificationEmail: true +}); + + +// 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 () { + if (!this.userId) + throw new Error("Not logged in!"); + Meteor.users.update(this.userId, + {$unset: {profile: 1, username: 1}}); + } +}); diff --git a/packages/accounts-twitter/package.js b/packages/accounts-twitter/package.js new file mode 100644 index 0000000000..bae64bbd53 --- /dev/null +++ b/packages/accounts-twitter/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Twitter accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth1-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['twitter_configure.html', 'twitter_configure.js'], + 'client'); + + api.add_files('twitter_common.js', ['client', 'server']); + api.add_files('twitter_server.js', 'server'); + api.add_files('twitter_client.js', 'client'); +}); diff --git a/packages/accounts-twitter/twitter_client.js b/packages/accounts-twitter/twitter_client.js new file mode 100644 index 0000000000..3eb979d57d --- /dev/null +++ b/packages/accounts-twitter/twitter_client.js @@ -0,0 +1,34 @@ +(function () { + // XXX support options.requestPermissions as we do for Facebook, Google, Github + Meteor.loginWithTwitter = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'twitter'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + // We need to keep state across the next two 'steps' so we're adding + // a state parameter to the url and the callback url that we'll be returned + // to by oauth provider + + // url back to app, enters "step 2" as described in + // packages/accounts-oauth1-helper/oauth1_server.js + var callbackUrl = Meteor.absoluteUrl('_oauth/twitter?close&state=' + state); + + // url to app, enters "step 1" as described in + // packages/accounts-oauth1-helper/oauth1_server.js + var url = '/_oauth/twitter/?requestTokenAndRedirect=' + + encodeURIComponent(callbackUrl) + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, url, callback); + }; + +})(); diff --git a/packages/accounts-twitter/twitter_common.js b/packages/accounts-twitter/twitter_common.js new file mode 100644 index 0000000000..3fdcd9d2bc --- /dev/null +++ b/packages/accounts-twitter/twitter_common.js @@ -0,0 +1,10 @@ +if (!Accounts.twitter) { + Accounts.twitter = {}; +} + +Accounts.twitter._urls = { + requestToken: "https://api.twitter.com/oauth/request_token", + authorize: "https://api.twitter.com/oauth/authorize", + accessToken: "https://api.twitter.com/oauth/access_token", + authenticate: "https://api.twitter.com/oauth/authenticate" +}; diff --git a/packages/accounts-twitter/twitter_configure.html b/packages/accounts-twitter/twitter_configure.html new file mode 100644 index 0000000000..67195547db --- /dev/null +++ b/packages/accounts-twitter/twitter_configure.html @@ -0,0 +1,13 @@ + diff --git a/packages/accounts-twitter/twitter_configure.js b/packages/accounts-twitter/twitter_configure.js new file mode 100644 index 0000000000..e41329cbde --- /dev/null +++ b/packages/accounts-twitter/twitter_configure.js @@ -0,0 +1,11 @@ +Template.configureLoginServiceDialogForTwitter.siteUrl = function () { + // Twitter doesn't recognize localhost as a domain name + return Meteor.absoluteUrl({replaceLocalhost: true}); +}; + +Template.configureLoginServiceDialogForTwitter.fields = function () { + return [ + {property: 'consumerKey', label: 'Consumer key'}, + {property: 'secret', label: 'Consumer secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-twitter/twitter_server.js b/packages/accounts-twitter/twitter_server.js new file mode 100644 index 0000000000..4008224b3d --- /dev/null +++ b/packages/accounts-twitter/twitter_server.js @@ -0,0 +1,20 @@ +(function () { + + Accounts.oauth.registerService('twitter', 1, function(oauthBinding) { + var identity = oauthBinding.get('https://api.twitter.com/1/account/verify_credentials.json'); + + return { + serviceData: { + id: identity.id, + screenName: identity.screen_name, + accessToken: oauthBinding.accessToken, + accessTokenSecret: oauthBinding.accessTokenSecret + }, + options: { + profile: { + name: identity.name + } + } + }; + }); +}) (); diff --git a/packages/accounts-ui-unstyled/accounts_ui.js b/packages/accounts-ui-unstyled/accounts_ui.js new file mode 100644 index 0000000000..770a77c9de --- /dev/null +++ b/packages/accounts-ui-unstyled/accounts_ui.js @@ -0,0 +1,53 @@ +if (!Accounts.ui) + Accounts.ui = {}; + +if (!Accounts.ui._options) { + Accounts.ui._options = { + requestPermissions: {} + }; +} + + +Accounts.ui.config = function(options) { + // validate options keys + var VALID_KEYS = ['passwordSignupFields', 'requestPermissions']; + _.each(_.keys(options), function (key) { + if (!_.contains(VALID_KEYS, key)) + throw new Error("Accounts.ui.config: Invalid key: " + key); + }); + + // deal with `passwordSignupFields` + if (options.passwordSignupFields) { + if (_.contains([ + "USERNAME_AND_EMAIL", + "USERNAME_AND_OPTIONAL_EMAIL", + "USERNAME_ONLY", + "EMAIL_ONLY" + ], options.passwordSignupFields)) { + if (Accounts.ui._options.passwordSignupFields) + throw new Error("Accounts.ui.config: Can't set `passwordSignupFields` more than once"); + else + Accounts.ui._options.passwordSignupFields = options.passwordSignupFields; + } else { + throw new Error("Accounts.ui.config: Invalid option for `passwordSignupFields`: " + options.passwordSignupFields); + } + } + + // deal with `requestPermissions` + if (options.requestPermissions) { + _.each(options.requestPermissions, function (scope, service) { + if (Accounts.ui._options.requestPermissions[service]) { + throw new Error("Accounts.ui.config: Can't set `requestPermissions` more than once for " + service); + } else if (!(scope instanceof Array)) { + throw new Error("Accounts.ui.config: Value for `requestPermissions` must be an array"); + } else { + Accounts.ui._options.requestPermissions[service] = scope; + } + }); + } +}; + +Accounts.ui._passwordSignupFields = function () { + return Accounts.ui._options.passwordSignupFields || "EMAIL_ONLY"; +}; + diff --git a/packages/accounts-ui-unstyled/accounts_ui_tests.js b/packages/accounts-ui-unstyled/accounts_ui_tests.js new file mode 100644 index 0000000000..e65989f5d8 --- /dev/null +++ b/packages/accounts-ui-unstyled/accounts_ui_tests.js @@ -0,0 +1,17 @@ +// XXX it'd be cool to also test that the right thing happens if options +// *are* validated, but Accouns.ui._options is global state which makes this hard +// (impossible?) +Tinytest.add('accounts-ui - config validates keys', function (test) { + test.throws(function () { + Accounts.ui.config({foo: "bar"}); + }); + + test.throws(function () { + Accounts.ui.config({passwordSignupFields: "not a valid option"}); + }); + + test.throws(function () { + Accounts.ui.config({requestPermissions: {facebook: "not an array"}}); + }); +}); + diff --git a/packages/accounts-ui-unstyled/login_buttons.html b/packages/accounts-ui-unstyled/login_buttons.html new file mode 100644 index 0000000000..4eb7846cd1 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons.html @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + diff --git a/packages/accounts-ui-unstyled/login_buttons.js b/packages/accounts-ui-unstyled/login_buttons.js new file mode 100644 index 0000000000..821eea3bda --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons.js @@ -0,0 +1,157 @@ +(function () { + if (!Accounts._loginButtons) + Accounts._loginButtons = {}; + + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + Handlebars.registerHelper( + "loginButtons", + function (options) { + if (options.hash.align === "right") + return new Handlebars.SafeString(Template._loginButtonsRight()); + else + return new Handlebars.SafeString(Template._loginButtonsLeft()); + }); + + // shared between dropdown and single mode + Template._loginButtons.events({ + 'click #login-buttons-logout': function() { + Meteor.logout(function () { + loginButtonsSession.closeDropdown(); + }); + } + }); + + Template._loginButtons.preserve({ + 'input[id]': Spark._labelFromIdOrName + }); + + // + // loginButtonLoggedOut template + // + + Template._loginButtonsLoggedOut.dropdown = function () { + return Accounts._loginButtons.dropdown(); + }; + + Template._loginButtonsLoggedOut.services = function () { + return Accounts._loginButtons.getLoginServices(); + }; + + Template._loginButtonsLoggedOut.singleService = function () { + var services = Accounts._loginButtons.getLoginServices(); + if (services.length !== 1) + throw new Error( + "Shouldn't be rendering this template with more than one configured service"); + return services[0]; + }; + + Template._loginButtonsLoggedOut.configurationLoaded = function () { + return Accounts.loginServicesConfigured(); + }; + + + // + // loginButtonsLoggedIn template + // + + // decide whether we should show a dropdown rather than a row of + // buttons + Template._loginButtonsLoggedIn.dropdown = function () { + return Accounts._loginButtons.dropdown(); + }; + + Template._loginButtonsLoggedIn.displayName = function () { + return Accounts._loginButtons.displayName(); + }; + + + + // + // loginButtonsMessage template + // + + Template._loginButtonsMessages.errorMessage = function () { + return loginButtonsSession.get('errorMessage'); + }; + + Template._loginButtonsMessages.infoMessage = function () { + return loginButtonsSession.get('infoMessage'); + }; + + + // + // helpers + // + + Accounts._loginButtons.displayName = function () { + var user = Meteor.user(); + if (!user) + return ''; + + if (user.profile && user.profile.name) + return user.profile.name; + if (user.username) + return user.username; + if (user.emails && user.emails[0] && user.emails[0].address) + return user.emails[0].address; + + return ''; + }; + + Accounts._loginButtons.getLoginServices = function () { + var ret = []; + // make sure to put password last, since this is how it is styled + // in the ui as well. + _.each( + ['facebook', 'github', 'google', 'twitter', 'weibo', 'password'], + function (service) { + if (Accounts[service]) + ret.push({name: service}); + }); + + return ret; + }; + + Accounts._loginButtons.hasPasswordService = function () { + return Accounts.password; + }; + + Accounts._loginButtons.dropdown = function () { + return Accounts._loginButtons.hasPasswordService() || Accounts._loginButtons.getLoginServices().length > 1; + }; + + // XXX improve these. should this be in accounts-password instead? + // + // XXX these will become configurable, and will be validated on + // the server as well. + Accounts._loginButtons.validateUsername = function (username) { + if (username.length >= 3) { + return true; + } else { + loginButtonsSession.set('errorMessage', "Username must be at least 3 characters long"); + return false; + } + }; + Accounts._loginButtons.validateEmail = function (email) { + if (Accounts.ui._passwordSignupFields() === "USERNAME_AND_OPTIONAL_EMAIL" && email === '') + return true; + + if (email.indexOf('@') !== -1) { + return true; + } else { + loginButtonsSession.set('errorMessage', "Invalid email"); + return false; + } + }; + Accounts._loginButtons.validatePassword = function (password) { + if (password.length >= 6) { + return true; + } else { + loginButtonsSession.set('errorMessage', "Password must be at least 6 characters long"); + return false; + } + }; + +})(); diff --git a/packages/accounts-ui-unstyled/login_buttons_dialogs.html b/packages/accounts-ui-unstyled/login_buttons_dialogs.html new file mode 100644 index 0000000000..18a035b903 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dialogs.html @@ -0,0 +1,120 @@ + + {{> _resetPasswordDialog}} + {{> _enrollAccountDialog}} + {{> _justVerifiedEmailDialog}} + {{> _configureLoginServiceDialog}} + + + {{> _loginButtonsMessagesDialog}} + + + + + + + + + + + + + diff --git a/packages/accounts-ui-unstyled/login_buttons_dialogs.js b/packages/accounts-ui-unstyled/login_buttons_dialogs.js new file mode 100644 index 0000000000..fadce1d17e --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dialogs.js @@ -0,0 +1,239 @@ +(function () { + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + + // + // populate the session so that the appropriate dialogs are + // displayed by reading variables set by accounts-urls, which parses + // special URLs. since accounts-ui depends on accounts-urls, we are + // guaranteed to have these set at this point. + // + + if (Accounts._resetPasswordToken) { + loginButtonsSession.set('resetPasswordToken', Accounts._resetPasswordToken); + } + + if (Accounts._enrollAccountToken) { + loginButtonsSession.set('enrollAccountToken', Accounts._enrollAccountToken); + } + + // Needs to be in Meteor.startup because of a package loading order + // issue. We can't be sure that accounts-password is loaded earlier + // than accounts-ui so Accounts.verifyEmail might not be defined. + Meteor.startup(function () { + if (Accounts._verifyEmailToken) { + Accounts.verifyEmail(Accounts._verifyEmailToken, function(error) { + Accounts._enableAutoLogin(); + if (!error) + loginButtonsSession.set('justVerifiedEmail', true); + // XXX show something if there was an error. + }); + } + }); + + + // + // resetPasswordDialog template + // + + Template._resetPasswordDialog.events({ + 'click #login-buttons-reset-password-button': function () { + resetPassword(); + }, + 'keypress #reset-password-new-password': function (event) { + if (event.keyCode === 13) + resetPassword(); + }, + 'click #login-buttons-cancel-reset-password': function () { + loginButtonsSession.set('resetPasswordToken', null); + Accounts._enableAutoLogin(); + } + }); + + var resetPassword = function () { + loginButtonsSession.resetMessages(); + var newPassword = document.getElementById('reset-password-new-password').value; + if (!Accounts._loginButtons.validatePassword(newPassword)) + return; + + Accounts.resetPassword( + loginButtonsSession.get('resetPasswordToken'), newPassword, + function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.set('resetPasswordToken', null); + Accounts._enableAutoLogin(); + } + }); + }; + + Template._resetPasswordDialog.inResetPasswordFlow = function () { + return loginButtonsSession.get('resetPasswordToken'); + }; + + + // + // enrollAccountDialog template + // + + Template._enrollAccountDialog.events({ + 'click #login-buttons-enroll-account-button': function () { + enrollAccount(); + }, + 'keypress #enroll-account-password': function (event) { + if (event.keyCode === 13) + enrollAccount(); + }, + 'click #login-buttons-cancel-enroll-account': function () { + loginButtonsSession.set('enrollAccountToken', null); + Accounts._enableAutoLogin(); + } + }); + + var enrollAccount = function () { + loginButtonsSession.resetMessages(); + var password = document.getElementById('enroll-account-password').value; + if (!Accounts._loginButtons.validatePassword(password)) + return; + + Accounts.resetPassword( + loginButtonsSession.get('enrollAccountToken'), password, + function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.set('enrollAccountToken', null); + Accounts._enableAutoLogin(); + } + }); + }; + + Template._enrollAccountDialog.inEnrollAccountFlow = function () { + return loginButtonsSession.get('enrollAccountToken'); + }; + + + // + // justVerifiedEmailDialog template + // + + Template._justVerifiedEmailDialog.events({ + 'click #just-verified-dismiss-button': function () { + loginButtonsSession.set('justVerifiedEmail', false); + } + }); + + Template._justVerifiedEmailDialog.visible = function () { + return loginButtonsSession.get('justVerifiedEmail'); + }; + + + // + // loginButtonsMessagesDialog template + // + + Template._loginButtonsMessagesDialog.events({ + 'click #messages-dialog-dismiss-button': function () { + loginButtonsSession.resetMessages(); + } + }); + + Template._loginButtonsMessagesDialog.visible = function () { + var hasMessage = loginButtonsSession.get('infoMessage') || loginButtonsSession.get('errorMessage'); + return !Accounts._loginButtons.dropdown() && hasMessage; + }; + + + // + // configureLoginServiceDialog template + // + + Template._configureLoginServiceDialog.events({ + 'click .configure-login-service-dismiss-button': function () { + loginButtonsSession.set('configureLoginServiceDialogVisible', false); + }, + 'click #configure-login-service-dialog-save-configuration': function () { + if (loginButtonsSession.get('configureLoginServiceDialogVisible') && + ! loginButtonsSession.get('configureLoginServiceDialogSaveDisabled')) { + // Prepare the configuration document for this login service + var serviceName = loginButtonsSession.get('configureLoginServiceDialogServiceName'); + var configuration = { + service: serviceName + }; + _.each(configurationFields(), function(field) { + configuration[field.property] = document.getElementById( + 'configure-login-service-dialog-' + field.property).value + .replace(/^\s*|\s*$/g, ""); // trim; + }); + + // Configure this login service + Meteor.call("configureLoginService", configuration, function (error, result) { + if (error) + Meteor._debug("Error configuring login service " + serviceName, error); + else + loginButtonsSession.set('configureLoginServiceDialogVisible', false); + }); + } + }, + // IE8 doesn't support the 'input' event, so we'll run this on the keyup as + // well. (Keeping the 'input' event means that this also fires when you use + // the mouse to change the contents of the field, eg 'Cut' menu item.) + 'input, keyup input': function (event) { + // if the event fired on one of the configuration input fields, + // check whether we should enable the 'save configuration' button + if (event.target.id.indexOf('configure-login-service-dialog') === 0) + updateSaveDisabled(); + } + }); + + // check whether the 'save configuration' button should be enabled. + // this is a really strange way to implement this and a Forms + // Abstraction would make all of this reactive, and simpler. + var updateSaveDisabled = function () { + var anyFieldEmpty = _.any(configurationFields(), function(field) { + return document.getElementById( + 'configure-login-service-dialog-' + field.property).value === ''; + }); + + loginButtonsSession.set('configureLoginServiceDialogSaveDisabled', anyFieldEmpty); + }; + + // Returns the appropriate template for this login service. This + // template should be defined in the service's package + var configureLoginServiceDialogTemplateForService = function () { + var serviceName = loginButtonsSession.get('configureLoginServiceDialogServiceName'); + return Template['configureLoginServiceDialogFor' + capitalize(serviceName)]; + }; + + var configurationFields = function () { + var template = configureLoginServiceDialogTemplateForService(); + return template.fields(); + }; + + Template._configureLoginServiceDialog.configurationFields = function () { + return configurationFields(); + }; + + Template._configureLoginServiceDialog.visible = function () { + return loginButtonsSession.get('configureLoginServiceDialogVisible'); + }; + + Template._configureLoginServiceDialog.configurationSteps = function () { + // renders the appropriate template + return configureLoginServiceDialogTemplateForService()(); + }; + + Template._configureLoginServiceDialog.saveDisabled = function () { + return loginButtonsSession.get('configureLoginServiceDialogSaveDisabled'); + }; + + + // XXX from http://epeli.github.com/underscore.string/lib/underscore.string.js + var capitalize = function(str){ + str = str == null ? '' : String(str); + return str.charAt(0).toUpperCase() + str.slice(1); + }; + +}) (); diff --git a/packages/accounts-ui-unstyled/login_buttons_dropdown.html b/packages/accounts-ui-unstyled/login_buttons_dropdown.html new file mode 100644 index 0000000000..2d01b95bdb --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dropdown.html @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/accounts-ui-unstyled/login_buttons_dropdown.js b/packages/accounts-ui-unstyled/login_buttons_dropdown.js new file mode 100644 index 0000000000..121c4aaeea --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dropdown.js @@ -0,0 +1,498 @@ +(function () { + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + // events shared between loginButtonsLoggedOutDropdown and + // loginButtonsLoggedInDropdown + Template._loginButtons.events({ + 'click #login-name-link, click #login-sign-in-link': function () { + loginButtonsSession.set('dropdownVisible', true); + Meteor.flush(); + correctDropdownZIndexes(); + }, + 'click .login-close-text': function () { + loginButtonsSession.closeDropdown(); + } + }); + + + // + // loginButtonsLoggedInDropdown template and related + // + + Template._loginButtonsLoggedInDropdown.events({ + 'click #login-buttons-open-change-password': function() { + loginButtonsSession.resetMessages(); + loginButtonsSession.set('inChangePasswordFlow', true); + } + }); + + Template._loginButtonsLoggedInDropdown.displayName = function () { + return Accounts._loginButtons.displayName(); + }; + + Template._loginButtonsLoggedInDropdown.inChangePasswordFlow = function () { + return loginButtonsSession.get('inChangePasswordFlow'); + }; + + Template._loginButtonsLoggedInDropdown.inMessageOnlyFlow = function () { + return loginButtonsSession.get('inMessageOnlyFlow'); + }; + + Template._loginButtonsLoggedInDropdown.dropdownVisible = function () { + return loginButtonsSession.get('dropdownVisible'); + }; + + Template._loginButtonsLoggedInDropdownActions.allowChangingPassword = function () { + // it would be more correct to check whether the user has a password set, + // but in order to do that we'd have to send more data down to the client, + // and it'd be preferable not to send down the entire service.password document. + // + // instead we use the heuristic: if the user has a username or email set. + var user = Meteor.user(); + return user.username || (user.emails && user.emails[0] && user.emails[0].address); + }; + + + // + // loginButtonsLoggedOutDropdown template and related + // + + Template._loginButtonsLoggedOutDropdown.events({ + 'click #login-buttons-password': function () { + loginOrSignup(); + }, + + 'keypress #forgot-password-email': function (event) { + if (event.keyCode === 13) + forgotPassword(); + }, + + 'click #login-buttons-forgot-password': function () { + forgotPassword(); + }, + + 'click #signup-link': function () { + loginButtonsSession.resetMessages(); + + // store values of fields before swtiching to the signup form + var username = trimmedElementValueById('login-username'); + var email = trimmedElementValueById('login-email'); + var usernameOrEmail = trimmedElementValueById('login-username-or-email'); + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + + loginButtonsSession.set('inSignupFlow', true); + loginButtonsSession.set('inForgotPasswordFlow', false); + // force the ui to update so that we have the approprate fields to fill in + Meteor.flush(); + + // update new fields with appropriate defaults + if (username !== null) + document.getElementById('login-username').value = username; + else if (email !== null) + document.getElementById('login-email').value = email; + else if (usernameOrEmail !== null) + if (usernameOrEmail.indexOf('@') === -1) + document.getElementById('login-username').value = usernameOrEmail; + else + document.getElementById('login-email').value = usernameOrEmail; + // "login-password" is preserved thanks to the preserve-inputs package + + // Force redrawing the `login-dropdown-list` element because of + // a bizarre Chrome bug in which part of the DIV is not redrawn + // in case you had tried to unsuccessfully log in before + // switching to the signup form. + // + // Found tip on how to force a redraw on + // http://stackoverflow.com/questions/3485365/how-can-i-force-webkit-to-redraw-repaint-to-propagate-style-changes/3485654#3485654 + var redraw = document.getElementById('login-dropdown-list'); + redraw.style.display = 'none'; + redraw.offsetHeight; // it seems that this line does nothing but is necessary for the redraw to work + redraw.style.display = 'block'; + }, + 'click #forgot-password-link': function () { + loginButtonsSession.resetMessages(); + + // store values of fields before swtiching to the signup form + var email = trimmedElementValueById('login-email'); + var usernameOrEmail = trimmedElementValueById('login-username-or-email'); + + loginButtonsSession.set('inSignupFlow', false); + loginButtonsSession.set('inForgotPasswordFlow', true); + // force the ui to update so that we have the approprate fields to fill in + Meteor.flush(); + + // update new fields with appropriate defaults + if (email !== null) + document.getElementById('forgot-password-email').value = email; + else if (usernameOrEmail !== null) + if (usernameOrEmail.indexOf('@') !== -1) + document.getElementById('forgot-password-email').value = usernameOrEmail; + + }, + 'click #back-to-login-link': function () { + loginButtonsSession.resetMessages(); + + var username = trimmedElementValueById('login-username'); + var email = trimmedElementValueById('login-email') + || trimmedElementValueById('forgot-password-email'); // Ughh. Standardize on names? + + loginButtonsSession.set('inSignupFlow', false); + loginButtonsSession.set('inForgotPasswordFlow', false); + // force the ui to update so that we have the approprate fields to fill in + Meteor.flush(); + + if (document.getElementById('login-username')) + document.getElementById('login-username').value = username; + if (document.getElementById('login-email')) + document.getElementById('login-email').value = email; + // "login-password" is preserved thanks to the preserve-inputs package + if (document.getElementById('login-username-or-email')) + document.getElementById('login-username-or-email').value = email || username; + }, + 'keypress #login-username, keypress #login-email, keypress #login-username-or-email, keypress #login-password, keypress #login-password-again': function (event) { + if (event.keyCode === 13) + loginOrSignup(); + } + }); + + // additional classes that can be helpful in styling the dropdown + Template._loginButtonsLoggedOutDropdown.additionalClasses = function () { + if (!Accounts.password) { + return false; + } else { + if (loginButtonsSession.get('inSignupFlow')) { + return 'login-form-create-account'; + } else if (loginButtonsSession.get('inForgotPasswordFlow')) { + return 'login-form-forgot-password'; + } else { + return 'login-form-sign-in'; + } + } + }; + + Template._loginButtonsLoggedOutDropdown.dropdownVisible = function () { + return loginButtonsSession.get('dropdownVisible'); + }; + + Template._loginButtonsLoggedOutDropdown.hasPasswordService = function () { + return Accounts._loginButtons.hasPasswordService(); + }; + + Template._loginButtonsLoggedOutAllServices.services = function () { + return Accounts._loginButtons.getLoginServices(); + }; + + Template._loginButtonsLoggedOutAllServices.isPasswordService = function () { + return this.name === 'password'; + }; + + Template._loginButtonsLoggedOutAllServices.hasOtherServices = function () { + return Accounts._loginButtons.getLoginServices().length > 1; + }; + + Template._loginButtonsLoggedOutAllServices.hasPasswordService = function () { + return Accounts._loginButtons.hasPasswordService(); + }; + + Template._loginButtonsLoggedOutPasswordService.fields = function () { + var loginFields = [ + {fieldName: 'username-or-email', fieldLabel: 'Username or Email', + visible: function () { + return _.contains( + ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL"], + Accounts.ui._passwordSignupFields()); + }}, + {fieldName: 'username', fieldLabel: 'Username', + visible: function () { + return Accounts.ui._passwordSignupFields() === "USERNAME_ONLY"; + }}, + {fieldName: 'email', fieldLabel: 'Email', inputType: 'email', + visible: function () { + return Accounts.ui._passwordSignupFields() === "EMAIL_ONLY"; + }}, + {fieldName: 'password', fieldLabel: 'Password', inputType: 'password', + visible: function () { + return true; + }} + ]; + + var signupFields = [ + {fieldName: 'username', fieldLabel: 'Username', + visible: function () { + return _.contains( + ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"], + Accounts.ui._passwordSignupFields()); + }}, + {fieldName: 'email', fieldLabel: 'Email', inputType: 'email', + visible: function () { + return _.contains( + ["USERNAME_AND_EMAIL", "EMAIL_ONLY"], + Accounts.ui._passwordSignupFields()); + }}, + {fieldName: 'email', fieldLabel: 'Email (optional)', inputType: 'email', + visible: function () { + return Accounts.ui._passwordSignupFields() === "USERNAME_AND_OPTIONAL_EMAIL"; + }}, + {fieldName: 'password', fieldLabel: 'Password', inputType: 'password', + visible: function () { + return true; + }}, + {fieldName: 'password-again', fieldLabel: 'Password (again)', + inputType: 'password', + visible: function () { + // No need to make users double-enter their password if + // they'll necessarily have an email set, since they can use + // the "forgot password" flow. + return _.contains( + ["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"], + Accounts.ui._passwordSignupFields()); + }} + ]; + + return loginButtonsSession.get('inSignupFlow') ? signupFields : loginFields; + }; + + Template._loginButtonsLoggedOutPasswordService.inForgotPasswordFlow = function () { + return loginButtonsSession.get('inForgotPasswordFlow'); + }; + + Template._loginButtonsLoggedOutPasswordService.inLoginFlow = function () { + return !loginButtonsSession.get('inSignupFlow') && !loginButtonsSession.get('inForgotPasswordFlow'); + }; + + Template._loginButtonsLoggedOutPasswordService.inSignupFlow = function () { + return loginButtonsSession.get('inSignupFlow'); + }; + + Template._loginButtonsLoggedOutPasswordService.showForgotPasswordLink = function () { + return _.contains( + ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "EMAIL_ONLY"], + Accounts.ui._passwordSignupFields()); + }; + + Template._loginButtonsFormField.inputType = function () { + return this.inputType || "text"; + }; + + + // + // loginButtonsChangePassword template + // + + Template._loginButtonsChangePassword.events({ + 'keypress #login-old-password, keypress #login-password, keypress #login-password-again': function (event) { + if (event.keyCode === 13) + changePassword(); + }, + 'click #login-buttons-do-change-password': function () { + changePassword(); + } + }); + + Template._loginButtonsChangePassword.fields = function () { + return [ + {fieldName: 'old-password', fieldLabel: 'Current Password', inputType: 'password', + visible: function () { + return true; + }}, + {fieldName: 'password', fieldLabel: 'New Password', inputType: 'password', + visible: function () { + return true; + }}, + {fieldName: 'password-again', fieldLabel: 'New Password (again)', + inputType: 'password', + visible: function () { + // No need to make users double-enter their password if + // they'll necessarily have an email set, since they can use + // the "forgot password" flow. + return _.contains( + ["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"], + Accounts.ui._passwordSignupFields()); + }} + ]; + }; + + + // + // helpers + // + + var elementValueById = function(id) { + var element = document.getElementById(id); + if (!element) + return null; + else + return element.value; + }; + + var trimmedElementValueById = function(id) { + var element = document.getElementById(id); + if (!element) + return null; + else + return element.value.replace(/^\s*|\s*$/g, ""); // trim; + }; + + var loginOrSignup = function () { + if (loginButtonsSession.get('inSignupFlow')) + signup(); + else + login(); + }; + + var login = function () { + loginButtonsSession.resetMessages(); + + var username = trimmedElementValueById('login-username'); + var email = trimmedElementValueById('login-email'); + var usernameOrEmail = trimmedElementValueById('login-username-or-email'); + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + + var loginSelector; + if (username !== null) { + if (!Accounts._loginButtons.validateUsername(username)) + return; + else + loginSelector = {username: username}; + } else if (email !== null) { + if (!Accounts._loginButtons.validateEmail(email)) + return; + else + loginSelector = {email: email}; + } else if (usernameOrEmail !== null) { + // XXX not sure how we should validate this. but this seems good enough (for now), + // since an email must have at least 3 characters anyways + if (!Accounts._loginButtons.validateUsername(usernameOrEmail)) + return; + else + loginSelector = usernameOrEmail; + } else { + throw new Error("Unexpected -- no element to use as a login user selector"); + } + + Meteor.loginWithPassword(loginSelector, password, function (error, result) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.closeDropdown(); + } + }); + }; + + var signup = function () { + loginButtonsSession.resetMessages(); + + var options = {}; // to be passed to Accounts.createUser + + var username = trimmedElementValueById('login-username'); + if (username !== null) { + if (!Accounts._loginButtons.validateUsername(username)) + return; + else + options.username = username; + } + + var email = trimmedElementValueById('login-email'); + if (email !== null) { + if (!Accounts._loginButtons.validateEmail(email)) + return; + else + options.email = email; + } + + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + if (!Accounts._loginButtons.validatePassword(password)) + return; + else + options.password = password; + + if (!matchPasswordAgainIfPresent()) + return; + + Accounts.createUser(options, function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.closeDropdown(); + } + }); + }; + + var forgotPassword = function () { + loginButtonsSession.resetMessages(); + + var email = trimmedElementValueById("forgot-password-email"); + if (email.indexOf('@') !== -1) { + Accounts.forgotPassword({email: email}, function (error) { + if (error) + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + else + loginButtonsSession.set('infoMessage', "Email sent"); + }); + } else { + loginButtonsSession.set('errorMessage', "Invalid email"); + } + }; + + var changePassword = function () { + loginButtonsSession.resetMessages(); + + // notably not trimmed. a password could (?) start or end with a space + var oldPassword = elementValueById('login-old-password'); + + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + if (!Accounts._loginButtons.validatePassword(password)) + return; + + if (!matchPasswordAgainIfPresent()) + return; + + Accounts.changePassword(oldPassword, password, function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.set('inChangePasswordFlow', false); + loginButtonsSession.set('inMessageOnlyFlow', true); + loginButtonsSession.set('infoMessage', "Password changed"); + } + }); + }; + + var matchPasswordAgainIfPresent = function () { + // notably not trimmed. a password could (?) start or end with a space + var passwordAgain = elementValueById('login-password-again'); + if (passwordAgain !== null) { + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + if (password !== passwordAgain) { + loginButtonsSession.set('errorMessage', "Passwords don't match"); + return false; + } + } + return true; + }; + + var correctDropdownZIndexes = function () { + // IE <= 7 has a z-index bug that means we can't just give the + // dropdown a z-index and expect it to stack above the rest of + // the page even if nothing else has a z-index. The nature of + // the bug is that all positioned elements are considered to + // have z-index:0 (not auto) and therefore start new stacking + // contexts, with ties broken by page order. + // + // The fix, then is to give z-index:1 to all ancestors + // of the dropdown having z-index:0. + for(var n = document.getElementById('login-dropdown-list').parentNode; + n.nodeName !== 'BODY'; + n = n.parentNode) + if (n.style.zIndex === 0) + n.style.zIndex = 1; + }; + + +}) (); diff --git a/packages/accounts-ui-unstyled/login_buttons_images.css b/packages/accounts-ui-unstyled/login_buttons_images.css new file mode 100644 index 0000000000..07e05215ba --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_images.css @@ -0,0 +1,21 @@ +/* These should be in their respective packages. https://app.asana.com/0/988582960612/1477837179813 */ + +#login-buttons-image-google { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAADCklEQVQ4jSXSy2ucVRjA4d97zvdNJpPJbTJJE9rYaCINShZtRCFIA1bbLryBUlyoLQjqVl12W7UbN4qb1gtuYhFRRBCDBITaesFbbI3RFBLSptEY05l0ZjLfnMvrov/Bs3gAcF71x6VVHTk+o8nDH+hrH89rUK9Z9Yaen57S3wVtGaMBNGC0IegWKIDxTtVaOHVugZVmH3HX3Zz+4l+W1xvkOjuZfPsspY4CNkZELEgEIJKwYlBjEwjec/mfCMVuorVs76R8+P0KYMmP30U2dT8eIZqAR2ipRcWjEYxGSCRhV08e04oYMoxYLi97EI9YCJ0FHBYbIVGDlUBLwRlLIuYW6chEmQt/rJO09RJjhjEJEYvJYGNhkbUhw43OXtIWDFRq9G87nAaSK6sVRm8r8fzRMWbOX2Xx7ypd7ZET03sQhDOz73DqSJOrd+7HSo4QIu0Nx/4rOzx+cRXZ9+z7+uqJ+3hiepxK3fHZT2tMjXYzOtzL6dmznPzhLexgN0QlxAAYxAlqUqRmkf5j59RlNQ6MFHhgcpCTTx8EUb5e+plD7x4jjg1ANCAgrRQAdR7xKXjBlGyLYi7PxaUmb8z8xcpGHVXLHaXdjI0egKyJiQYTEhSPREVIEUBNC+Mqm+xpz3j0njLPHB2nsh1QgeG+IS48dYbD5YNoo0ZUAbVEuTUoKuBSZOarX/WhyQn6eg2+usDWf0s0tq8zNPYk+WI/Lnge++hlvlyfQ3NdECzGRWKwEEA0qNY251n69kV6+Y0kbaCZoebG2X3oU7pKoyxuXOPe945zs9DCeosGIXoBDyaLdf6ce4Hbk+/Y299ksKtAuaeNsiyw8c1LKIZ95b0MdgxA5giixACpTxEPSau6QdFfI5/2cLPmEW+JAQrtJUJzDXF1dkwHzVodJMX4HFEcQQMaFdPeM0Jb/4PUtzzaLKAhRyJFwo6lbegRNFfk819muV5dR4JBQoQdQ2xFiDmSNDHiaptamR9Gq5cQ18AledrGDpOfeI5Lq8u88smbhMRisoSAgAYghdfn5H/JkHuRZ1owLAAAAABJRU5ErkJggg==); +} + +#login-buttons-image-facebook { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAq0lEQVQ4jWP8//8/AyWAhYGBgcEmauYZBgYGYxL1nj2yLN2ECcohVTNcDwsxKlXlhRm6yzwZRAS5GRgYGBhsombC5ZhwaUIGyJrRAVEuwGYzSS7AB/C64MiydKx8ZJfgNeDN+68MDAwIL8D4RLsgIHsJis0wPjKgOAyoE4hcnGwMGkpiBBUbacvA2TfuvaKiC759/3X23NUnOPMDtgTEwMBwloGBgYGR0uwMAGOPLJS9mkQHAAAAAElFTkSuQmCC); +} + +#login-buttons-image-weibo { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAKySURBVDhPY2AgEpR5sjf/nS/6//UkoX+XJltuCvVxkcOp9cyZM1w/r13TuXvmDD9MkYIwg7qrNrubnzFb6J5intPHqrnvCnIwyKIYsmrVKuaFYWEFW2Sk79zX0f6/REHhKFABC0zRsky+rXMSeZdKCTLIHqgUvLAknW8L3IAQDw/RFlbWnQ801P+DNN8D4n0qyk94GRiEjTg5Lbz4+YOCdbhjVmTxbZwex7PUW58t8O1Ukf9gA2IDAoRPWFudfayt9f+mpsb/6yrK/28qKf4/ISf7YZu83K07QMNe6On9nyWusMtVm813azH/UWctZo/vc8TABjB3CApufAzSqKjw/7apyf+nMdH/XxUX/X+RnfX/qY/3/5tqqv/vq6v936KsfB2onltaiEHGx5AteFep4EmGUEHB1Adamv9v6er8fztp0v//79////nr1/+3X778B4N///5/O3jw/0N39//nlBQ/louLd4MMAWImcPhsU1G6DfLvt717wepnz537X0FB4T8fL+//AH///2/evgWL/7l///9dE+P/b4AWTZSWXg/UzAj2/w2gs59mZYEV7d+//z8rE9N/JUXF/w62tiD//a+urIS4BAgeA712Cxg2F40M36alpXGBDTgmI/3hdUU5WEFjff3/wvx8MNvcxARsQE1VFUQ30Et37Oz+P1RV+b/J0nIjUATigmgBvtzH5mb//9++/f/mkyf/A4KC/nv7+oI1W1hb/3/1+fP//9+//39ekP//CVDzTlnZxxtnz1ZBSUDeDAyZh7W13nybOeP/7W1b/09rbf2/FhgWHy9c+P912bL/D11d/l+WEP8/SUR4Ox8DA6pmmEkpHh4ya0JCim4lJGx7kZp8821CwrN7Hh4Pr7m6nDoSET61PjDQichsA3T7//+s/16/5gXSkIAa1AAAh8dhOVd5xHAAAAAASUVORK5CYII=); +} + +#login-buttons-image-twitter { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAByklEQVQ4jaVTz0sbQRh92V10l006GaKJCtEtmqMYU0Qpwqb4B6zgXdT0WEr7B0ih4MGLP05CUWMvHkQwglhvGhsvKmJOBhTUQjWU2slilKarrAfdZROTQ8m7fPMx33szb75vXKZpohpwVbEBCNaCMUYopXppAWOMxDNsOPf3H1WIeDoSURYYYwQAKKW6y7KgLe2vam11KyMRZcEpEP6SOkwbUgc4ATAKUF8YW2fXhZejvaHPsc7gvH2DnCfQGEtdxrd/5NRJteUDpVTf+5kLp2WlA6JsCyZv9ChplPKdTfJZkYWhEF3bvnV3fb36NZSY3dP6Q/5V4hFvIAaKPckE8W5pLBIQdwHAthBdPtpJuhpeAwDu74DrP4/R1/Ts4cwBWg/gN+DowoSqTBPezAMAeAHw+suSw4Q7schFApF6af19a+2yLVIB7xR+0Zk75yCveu82FMnMViKHCXcSa3PPVBJAX5BszL2SP2kNwvdy5M1e+S2AogME4HFYPibPpxKZC03nRAp/M+Dx2UWDzTXfpttrx72ikCoVtrrAAwgdXBk9iazxxtpskfhs1O86aHXXpAEcA7ivJGDBDcDnyAsA2FMsi1KB/0bVv/EBBBSY9mZ7PAsAAAAASUVORK5CYII=); +} + +#login-buttons-image-github { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9wJGBYxHYxl31wAAAHpSURBVDjLpZI/aFNRFMZ/973bJqGRPopV4qNq/+SpTYnWRhCKilShg9BGcHOM+GfQoZuLk4iLgw4qZNBaHLuIdBNHl7Ta1qdNFI3SihnaNG1MpH3vuiQYQnwZvHCG893zffc751z4z6PX5T5gA1DAKnAaOAQEgAfAVeCpl+CeCrlRuEC6maO4h0A1wl4tPAHMqNUthvrDdHYY7A3t4rDVjeO6rBU2FaABM1WCrBNoi48Mi+nH9yj+KtPibAKwJXfQ5vcRG7soUnYmWEuQgAEIYBv4cGpoILI0Z4tyYYPegS6UguyijZQ6J45GSNmZHzUcJYD2ii2Ajv7efZ8WZ6ZwXFj79hXpayW4O0SL1Nl/8jzZlZ9dQLFS70pgvZKIyGD0yvu5eRmMnrk1PjI81ir1qBACTdPevXj95mVuNX8XKDQc/+T334bZZ104cvzYw2s3J3qAL5WXSsDbf61NNMBu+wOBs+VSyQ84Nfhg028ZGx3/qyy0lC7lgi7lghBitoon03lvB8l0/k7Wnk+8mny0cyXzEcfZxgwfZPTyRMHsOzAFXE9YhtNQIJnOx4FpJXT1eSkn2g0frqMoFrfoCXcqlCOAGwnLuO/l4JymcWl5uRxzXUKghBAiZ5r+WaV4lrCM555zqO+x2d0ftGmpiA/0k70AAAAASUVORK5CYII=); +} diff --git a/packages/accounts-ui-unstyled/login_buttons_session.js b/packages/accounts-ui-unstyled/login_buttons_session.js new file mode 100644 index 0000000000..f55ad6c737 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_session.js @@ -0,0 +1,62 @@ +(function () { + var VALID_KEYS = [ + 'dropdownVisible', + + // XXX consider replacing these with one key that has an enum for values. + 'inSignupFlow', + 'inForgotPasswordFlow', + 'inChangePasswordFlow', + 'inMessageOnlyFlow', + + 'errorMessage', + 'infoMessage', + + 'resetPasswordToken', + 'enrollAccountToken', + 'justVerifiedEmail', + + 'configureLoginServiceDialogVisible', + 'configureLoginServiceDialogServiceName', + 'configureLoginServiceDialogSaveDisabled' + ]; + + var validateKey = function (key) { + if (!_.contains(VALID_KEYS, key)) + throw new Error("Invalid key in loginButtonsSession: " + key); + }; + + var KEY_PREFIX = "Meteor.loginButtons."; + + // XXX we should have a better pattern for code private to a package like this one + Accounts._loginButtonsSession = { + set: function(key, value) { + validateKey(key); + Session.set(KEY_PREFIX + key, value); + }, + + get: function(key) { + validateKey(key); + return Session.get(KEY_PREFIX + key); + }, + + closeDropdown: function () { + this.set('inSignupFlow', false); + this.set('inForgotPasswordFlow', false); + this.set('inChangePasswordFlow', false); + this.set('inMessageOnlyFlow', false); + this.set('dropdownVisible', false); + this.resetMessages(); + }, + + resetMessages: function () { + this.set("errorMessage", null); + this.set("infoMessage", null); + }, + + configureService: function (name) { + this.set('configureLoginServiceDialogVisible', true); + this.set('configureLoginServiceDialogServiceName', name); + this.set('configureLoginServiceDialogSaveDisabled', true); + } + }; +}) (); diff --git a/packages/accounts-ui-unstyled/login_buttons_single.html b/packages/accounts-ui-unstyled/login_buttons_single.html new file mode 100644 index 0000000000..a11dc82d20 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_single.html @@ -0,0 +1,11 @@ + diff --git a/packages/accounts-ui-unstyled/login_buttons_single.js b/packages/accounts-ui-unstyled/login_buttons_single.js new file mode 100644 index 0000000000..d7aafb3c5e --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_single.js @@ -0,0 +1,48 @@ +(function () { + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + Template._loginButtonsLoggedOutSingleLoginButton.events({ + 'click .login-button': function () { + var serviceName = this.name; + loginButtonsSession.resetMessages(); + var callback = function (err) { + if (!err) { + loginButtonsSession.closeDropdown(); + } else if (err instanceof Accounts.LoginCancelledError) { + // do nothing + } else if (err instanceof Accounts.ConfigError) { + loginButtonsSession.configureService(serviceName); + } else { + loginButtonsSession.set('errorMessage', err.reason || "Unknown error"); + } + }; + + var loginWithService = Meteor["loginWith" + capitalize(serviceName)]; + + var options = {}; // use default scope unless specified + if (Accounts.ui._options.requestPermissions[serviceName]) + options.requestPermissions = Accounts.ui._options.requestPermissions[serviceName]; + + loginWithService(options, callback); + } + }); + + Template._loginButtonsLoggedOutSingleLoginButton.configured = function () { + return !!Accounts.loginServiceConfiguration.findOne({service: this.name}); + }; + + Template._loginButtonsLoggedOutSingleLoginButton.capitalizedName = function () { + if (this.name === 'github') + // XXX we should allow service packages to set their capitalized name + return 'GitHub'; + else + return capitalize(this.name); + }; + + // XXX from http://epeli.github.com/underscore.string/lib/underscore.string.js + var capitalize = function(str){ + str = str == null ? '' : String(str); + return str.charAt(0).toUpperCase() + str.slice(1); + }; +}) (); \ No newline at end of file diff --git a/packages/accounts-ui-unstyled/package.js b/packages/accounts-ui-unstyled/package.js new file mode 100644 index 0000000000..ef41ac3bf0 --- /dev/null +++ b/packages/accounts-ui-unstyled/package.js @@ -0,0 +1,29 @@ +Package.describe({ + summary: "Unstyled version of login widgets" +}); + +Package.on_use(function (api) { + api.use(['accounts-urls', 'accounts-base', 'underscore', 'templating'], 'client'); + + api.add_files([ + 'accounts_ui.js', + + 'login_buttons_images.css', + 'login_buttons.html', + 'login_buttons_single.html', + 'login_buttons_dropdown.html', + 'login_buttons_dialogs.html', + + 'login_buttons_session.js', + + 'login_buttons.js', + 'login_buttons_single.js', + 'login_buttons_dropdown.js', + 'login_buttons_dialogs.js'], 'client'); +}); + +Package.on_test(function (api) { + api.use('accounts-ui-unstyled'); + api.use('tinytest'); + api.add_files('accounts_ui_tests.js', 'client'); +}); diff --git a/packages/accounts-ui/login_buttons.less b/packages/accounts-ui/login_buttons.less new file mode 100644 index 0000000000..07dd3aa917 --- /dev/null +++ b/packages/accounts-ui/login_buttons.less @@ -0,0 +1,398 @@ +//////////////////// MIXINS + +// Minimal, well-documented, general-purpose CSS mixins. +// (Some are same as Bootstrap.) + +////////// Box-Sizing: Border-Box + +// Setting `box-sizing: border-box` on an element causes the CSS +// layout algorithm to interpret `width` and `height` declarations +// as referring to the size of the border box (outside the border), +// not the content box as usual (inside the padding). +// +// This is especially useful for stretching a form element to the +// width of its container even if the form element has arbitrary +// padding and borders, which can be done using `width: 100%`. +// +// Browser support is IE 8+ and all modern browsers, with the caveat +// that `-moz-box-sizing` in Firefox is considered to have some +// buggy or non-compliant behavior. For example, min/max-width/height +// may not interact correctly. Box-sizing is thus not a feature to +// base a major layout system around. +// +// See https://bugzilla.mozilla.org/show_bug.cgi?id=243412. +.box-sizing-by-border () { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +////////// Box-Shadow + +.box-shadow (...) { + box-shadow: @arguments; + -webkit-box-shadow: @arguments; // For Android +} + +////////// Display: Inline-block + +.display-inline-block () { + display: inline-block; + + // IE 7 hacks: + //*display: inline; + //*zoom: 1; +} + +////////// Gradients + +.vertical-gradient (@topColor: #fff, @bottomColor: #000) { + // Fallback in absence of gradients + background-color: mix(@topColor, @bottomColor, 60%); + // FF 3.6+ + background-image: -moz-linear-gradient(top, @topColor, @bottomColor); + // Safari 4+, Chrome 2+ + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(@topColor), to(@bottomColor)); + // Safari 5.1+, Chrome 10+ + background-image: -webkit-linear-gradient(top, @topColor, @bottomColor); + // Opera 11.10 + background-image: -o-linear-gradient(top, @topColor, @bottomColor); + // Standard, IE10 + background-image: linear-gradient(to bottom, @topColor, @bottomColor); + background-repeat: repeat-x; + // IE9 and down + // XXX This gradient hack causes gradients to overflow the rounded corners + // in IE9. We make the same call as Bootstrap here: keep the rounded + // corners and withhold the gradients. + // filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)",argb(@topColor),argb(@bottomColor))); +} + +.reset-ie-gradient () { + filter: e(%("progid:DXImageTransform.Microsoft.gradient(enabled = false)")); +} + +////////// Unselectable + +.unselectable () { + -webkit-user-select: none; // Chrome/Safari + -moz-user-select: none; // Firefox + -ms-user-select: none; // IE10+ + + // These delarations not implemented in browsers yet: + -o-user-select: none; + user-select: none; + + // In IE <= 9 and Opera, need unselectable="on" in the HTML. +} + +//////////////////// LOGIN BUTTONS + +@login-buttons-accounts-dialog-width: 198px; +#login-buttons { + + .display-inline-block(); + + // This seems to keep the height of the line from + // being sensitive to the presence of the unicode down arrow, + // which otherwise bumps the baseline down by 1px. + line-height: 1; + + .login-header { + display: inline-block; + padding-right: 2px; + line-height: 1.5; + font-family: 'Helvetica Neue', Helvetica, Arial, default; + } + .loading { + line-height: 1; + background-image: url(data:image/gif;base64,R0lGODlhEAALAPQAAP///wAAANra2tDQ0Orq6gYGBgAAAC4uLoKCgmBgYLq6uiIiIkpKSoqKimRkZL6+viYmJgQEBE5OTubm5tjY2PT09Dg4ONzc3PLy8ra2tqCgoMrKyu7u7gAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCwAAACwAAAAAEAALAAAFLSAgjmRpnqSgCuLKAq5AEIM4zDVw03ve27ifDgfkEYe04kDIDC5zrtYKRa2WQgAh+QQJCwAAACwAAAAAEAALAAAFJGBhGAVgnqhpHIeRvsDawqns0qeN5+y967tYLyicBYE7EYkYAgAh+QQJCwAAACwAAAAAEAALAAAFNiAgjothLOOIJAkiGgxjpGKiKMkbz7SN6zIawJcDwIK9W/HISxGBzdHTuBNOmcJVCyoUlk7CEAAh+QQJCwAAACwAAAAAEAALAAAFNSAgjqQIRRFUAo3jNGIkSdHqPI8Tz3V55zuaDacDyIQ+YrBH+hWPzJFzOQQaeavWi7oqnVIhACH5BAkLAAAALAAAAAAQAAsAAAUyICCOZGme1rJY5kRRk7hI0mJSVUXJtF3iOl7tltsBZsNfUegjAY3I5sgFY55KqdX1GgIAIfkECQsAAAAsAAAAABAACwAABTcgII5kaZ4kcV2EqLJipmnZhWGXaOOitm2aXQ4g7P2Ct2ER4AMul00kj5g0Al8tADY2y6C+4FIIACH5BAkLAAAALAAAAAAQAAsAAAUvICCOZGme5ERRk6iy7qpyHCVStA3gNa/7txxwlwv2isSacYUc+l4tADQGQ1mvpBAAIfkECQsAAAAsAAAAABAACwAABS8gII5kaZ7kRFGTqLLuqnIcJVK0DeA1r/u3HHCXC/aKxJpxhRz6Xi0ANAZDWa+kEAA7AAAAAAAAAAAA); + width: 60px; + background-position: center center; + background-repeat: no-repeat; + } + + .single-login-button { display: inline-block; } +} + +#login-buttons .login-button, .accounts-dialog .login-button { + cursor: pointer; + .unselectable(); + padding: 4px 8px; + + font-size: 80%; + font-family: 'Helvetica Neue', Helvetica, Arial, default; + line-height: 1.5; + + text-align: center; + color: #fff; + text-shadow: 0px -1px 1px rgba(0, 0, 0, 0.5); + + @topColor: #a5acc9; + @bottomColor: darken(@topColor, 25%); + + .vertical-gradient(@topColor, @bottomColor); + border-radius: 4px; + border: 1px solid mix(@bottomColor, #000, 30%); + .box-shadow(0 1px 3px rgba(0,0,0,0.5)); + + &:active { + .box-shadow(none); + .vertical-gradient(mix(@bottomColor, @topColor, 30%), + mix(@bottomColor, #000, 80%)); + } + + &.login-button-disabled, &.login-button-disabled:active { + color: #ddd; + .box-shadow(none); + background: #999; + } +} + +// precendence of this selector is significant +.accounts-dialog * { + // A base for our dialog CSS, to reset browser styles and protect against + // the app's CSS. Dialogs include the dropdown, config modals, and the + // reset password modal. We can't completely isolate the dialogs from + // the app's CSS, and that isn't the goal because the app can style them. + // This rule is a compromise that should take precedence over some very + // broad rules but be overridden by more specific ones. + + // Add more declarations here if they help the dialogs look good + // out-of-the-box in more apps. + + padding: 0; + margin: 0; + line-height: inherit; + color: inherit; + font: inherit; +} + +.accounts-dialog .login-button { + display: block; + width: auto; + margin-bottom: 4px; +} + +#login-buttons { + .login-header { margin-right: 4px; } + .configure-button { + + .vertical-gradient(#f53, darken(#f53, 15%)); + .box-shadow(0 1px 3px rgba(0,0,0,0.5)); + + &:active { background: #b10; .box-shadow(0 1px 3px rgba(0,0,0,0.5) inset); } + } + + .login-image { + float: left; + margin: 2px 5px 2px 0px; + width: 16px; + height: 16px; + } + + .no-services { color: red; } + + .login-link-and-dropdown-list { + position: relative; + } + .login-close-text { + float: left; + position: relative; + padding-bottom: 8px; + } + + .login-close-text-clear { clear: both; } + + .or { text-align: center; } + .hline { text-decoration: line-through; color: lightgrey; } + .or-text { font-weight: bold; } + + #signup-link { float: right; } + #forgot-password-link { float: left; } + #back-to-login-link { float: right; } +} + +#login-buttons a, .accounts-dialog a { + cursor: pointer; + text-decoration: underline; +} + +.login-buttons-dropdown-align-right #login-buttons .login-close-text { + float: right; +} + +@meteor-accounts-base-padding: 8px; +@meteor-accounts-dialog-border-width: 1px; + +.accounts-dialog { + border: @meteor-accounts-dialog-border-width solid #777; + z-index: 1000; + background: white; + + .box-shadow(0 3px 6px 1px rgba(0, 0, 0, 0.3)); + border-radius: 4px; + + padding: 8px 12px; + margin: -8px -12px 0 -12px; + + width: @login-buttons-accounts-dialog-width; + + // Labels and links inherit app's font with this line commented out: + //font-family: 'Helvetica Neue', Helvetica, Arial, default; + font-size: 16px; + color: #333; + + // XXX Make the dropdown and dialogs look good without a top-level + // line-height: 1.6. For now, we apply it to everything except + // the "Close" link, which we want to have the same line-height + // as the "Sign in" link. + & > * { line-height: 1.6; } + & > .login-close-text { line-height: inherit; } + + label, .title { + font-weight: bold; + font-size: 80%; + } + input { + // Be pixel-accurate in IE 8+ regardless of our borders and + // paddings, at the expense of IE 7. + // Any heights or widths applied to this element will set the + // size of the border box (including padding and borders) + // instead of the content box. This makes it possible to + // do width 100%. + .box-sizing-by-border(); + width: 100%; + // A fix purely for the "meteor add bootstrap" experience. + // Bootstrap sets "height: 20px" on form fields, which is too + // small when applied to the border box. People have complained + // that Bootstrap takes this approach for the sake of IE 7: + // https://github.com/twitter/bootstrap/issues/2935 + // Our work-around is to override Bootstrap's rule (with higher + // precedence). + &[type] { height: auto; } + } + + .login-button-form-submit { margin-top: 8px; } + .message { font-size: 80%; margin-top: 2px; line-height: 1.3; } + .error-message { color: red; } + .info-message { color: green; } + .additional-link { font-size: 75%; } + + .accounts-close { + position: absolute; + top: 0; + right: 5px; + + font-size: 20px; + font-weight: bold; + line-height: 20px; + text-decoration: none; + color: #000; + opacity: 0.4; + + &:hover { + opacity: 0.8; + } + } + + #login-buttons-cancel-reset-password { float: right; } + #login-buttons-cancel-enroll-account { float: right; } +} + +#login-dropdown-list { + position: absolute; + // The top-left of the border-box of the dropdown is absolutely + // positioned within its container, so we need to compensate + // for the border. The padding is already compensated for by + // negative margins on the dropdown. + // XXX We could use negative margins to compensate for the + // border too. + top: -@meteor-accounts-dialog-border-width; + left: -@meteor-accounts-dialog-border-width; +} + +.login-buttons-dropdown-align-right { + display: inline-block; +} + +.login-buttons-dropdown-align-right #login-dropdown-list { + left: auto; + right: -@meteor-accounts-dialog-border-width; +} + +#login-buttons-message-dialog .message { + /* we intentionally want it bigger on this dialog since it's the only thing displayed */ + font-size: 100%; +} + +.accounts-centered-dialog { + font-family: 'Helvetica Neue', Helvetica, Arial, default; + + z-index: 1001; + position: fixed; + + left: 50%; + margin-left: -(@login-buttons-accounts-dialog-width + + @meteor-accounts-base-padding) / 2; + + top: 50%; + margin-top: -40px; /* = approximately -height/2, though height can change */ +} + +@configure-login-service-dialog-width: 530px; + +#configure-login-service-dialog { + width: @configure-login-service-dialog-width; + margin-left: -(@configure-login-service-dialog-width + + @meteor-accounts-base-padding) / 2; + margin-top: -180px; /* = approximately -height/2, though height can change */ + + table { width: 100%; } + input { + width: 100%; + font-family: "Courier New", Courier, monospace; + } + ol { + margin-top: 10px; + margin-bottom: 10px; + + li { margin-left: 30px; } + } + .configuration_labels { width: 30%; } + .configuration_inputs { width: 70%; } + .new-section { margin-top: 10px; } + .url { font-family: "Courier New", Courier, monospace; } +} + +#configure-login-service-dialog-save-configuration { + float: right; +} + +.configure-login-service-dismiss-button { + float: left; +} + +#just-verified-dismiss-button, #messages-dialog-dismiss-button { + margin-top: 4px; +} + +.hide-background { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 999; + + /* XXX consider replacing with DXImageTransform */ + background-color: rgb(0.2, 0.2, 0.2); /* fallback for IE7-8 */ + + background-color: rgba(0, 0, 0, 0.7); +} + +input[type=text], input[type=email], input[type=password] { + padding: 4px; + border: 1px solid #999; + border-radius: 3px; + line-height: 1; +} diff --git a/packages/accounts-ui/package.js b/packages/accounts-ui/package.js new file mode 100644 index 0000000000..4557c75bb2 --- /dev/null +++ b/packages/accounts-ui/package.js @@ -0,0 +1,10 @@ +Package.describe({ + summary: "Simple templates to add login widgets to an app." +}); + +Package.on_use(function (api) { + api.use('accounts-ui-unstyled', 'client'); + api.use('less', 'server'); + + api.add_files(['login_buttons.less'], 'client'); +}); diff --git a/packages/accounts-urls/package.js b/packages/accounts-urls/package.js new file mode 100644 index 0000000000..e1fd55e3b9 --- /dev/null +++ b/packages/accounts-urls/package.js @@ -0,0 +1,9 @@ +Package.describe({ + summary: "Generate and consume reset password and verify account URLs", + internal: true +}); + +Package.on_use(function (api) { + api.add_files('url_client.js', 'client'); + api.add_files('url_server.js', 'server'); +}); diff --git a/packages/accounts-urls/url_client.js b/packages/accounts-urls/url_client.js new file mode 100644 index 0000000000..0d244506ec --- /dev/null +++ b/packages/accounts-urls/url_client.js @@ -0,0 +1,47 @@ +(function () { + if (typeof Accounts === 'undefined') + Accounts = {}; + + // reads a reset password token from the url's hash fragment, if it's + // there. if so prevent automatically logging in since it could be + // confusing to be logged in as user A while resetting password for + // user B + // + // reset password urls use hash fragments instead of url paths/query + // strings so that the reset password token is not sent over the wire + // on the http request + var match; + match = window.location.hash.match(/^\#\/reset-password\/(.*)$/); + if (match) { + Accounts._preventAutoLogin = true; + Accounts._resetPasswordToken = match[1]; + window.location.hash = ''; + } + + // reads a verify email token from the url's hash fragment, if + // it's there. also don't automatically log the user is, as for + // reset password links. + // + // XXX we don't need to use hash fragments in this case, and having + // the token appear in the url's path would allow us to use a custom + // middleware instead of verifying the email on pageload, which + // would be faster but less DDP-ish (and more specifically, any + // non-web DDP app, such as an iOS client, would do something more + // in line with the hash fragment approach) + match = window.location.hash.match(/^\#\/verify-email\/(.*)$/); + if (match) { + Accounts._preventAutoLogin = true; + Accounts._verifyEmailToken = match[1]; + window.location.hash = ''; + } + + // reads an account enrollment token from the url's hash fragment, if + // it's there. also don't automatically log the user is, as for + // reset password links. + match = window.location.hash.match(/^\#\/enroll-account\/(.*)$/); + if (match) { + Accounts._preventAutoLogin = true; + Accounts._enrollAccountToken = match[1]; + window.location.hash = ''; + } +})(); diff --git a/packages/accounts-urls/url_server.js b/packages/accounts-urls/url_server.js new file mode 100644 index 0000000000..898b8f3cd9 --- /dev/null +++ b/packages/accounts-urls/url_server.js @@ -0,0 +1,17 @@ +if (typeof Accounts === 'undefined') + Accounts = {}; + +if (!Accounts.urls) + Accounts.urls = {}; + +Accounts.urls.resetPassword = function (token) { + return Meteor.absoluteUrl('#/reset-password/' + token); +}; + +Accounts.urls.verifyEmail = function (token) { + return Meteor.absoluteUrl('#/verify-email/' + token); +}; + +Accounts.urls.enrollAccount = function (token) { + return Meteor.absoluteUrl('#/enroll-account/' + token); +}; diff --git a/packages/accounts-weibo/package.js b/packages/accounts-weibo/package.js new file mode 100644 index 0000000000..c178954131 --- /dev/null +++ b/packages/accounts-weibo/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Sina Weibo accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['weibo_configure.html', 'weibo_configure.js'], + 'client'); + + api.add_files('weibo_common.js', ['client', 'server']); + api.add_files('weibo_server.js', 'server'); + api.add_files('weibo_client.js', 'client'); +}); diff --git a/packages/accounts-weibo/weibo_client.js b/packages/accounts-weibo/weibo_client.js new file mode 100644 index 0000000000..7f2aea042f --- /dev/null +++ b/packages/accounts-weibo/weibo_client.js @@ -0,0 +1,28 @@ +(function () { + // XXX support options.requestPermissions as we do for Facebook, Google, Github + Meteor.loginWithWeibo = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + // XXX need to support configuring access_type and scope + var loginUrl = + 'https://api.weibo.com/oauth2/authorize' + + '?response_type=code' + + '&client_id=' + config.clientId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/weibo?close', {replaceLocalhost: true}) + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback); + }; + +}) (); diff --git a/packages/accounts-weibo/weibo_common.js b/packages/accounts-weibo/weibo_common.js new file mode 100644 index 0000000000..19ec575ef6 --- /dev/null +++ b/packages/accounts-weibo/weibo_common.js @@ -0,0 +1,3 @@ +if (!Accounts.weibo) { + Accounts.weibo = {}; +} diff --git a/packages/accounts-weibo/weibo_configure.html b/packages/accounts-weibo/weibo_configure.html new file mode 100644 index 0000000000..91f441b9bd --- /dev/null +++ b/packages/accounts-weibo/weibo_configure.html @@ -0,0 +1,25 @@ + diff --git a/packages/accounts-weibo/weibo_configure.js b/packages/accounts-weibo/weibo_configure.js new file mode 100644 index 0000000000..b5848a16ba --- /dev/null +++ b/packages/accounts-weibo/weibo_configure.js @@ -0,0 +1,11 @@ +Template.configureLoginServiceDialogForWeibo.siteUrl = function () { + // Weibo doesn't recognize localhost as a domain + return Meteor.absoluteUrl({replaceLocalhost: true}); +}; + +Template.configureLoginServiceDialogForWeibo.fields = function () { + return [ + {property: 'clientId', label: 'App Key'}, + {property: 'secret', label: 'App Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-weibo/weibo_server.js b/packages/accounts-weibo/weibo_server.js new file mode 100644 index 0000000000..a061d5054d --- /dev/null +++ b/packages/accounts-weibo/weibo_server.js @@ -0,0 +1,50 @@ +(function () { + + Accounts.oauth.registerService('weibo', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken.access_token, parseInt(accessToken.uid, 10)); + + return { + serviceData: { + id: accessToken.uid, + accessToken: accessToken.access_token, + screenName: identity.screen_name + }, + options: {profile: {name: identity.screen_name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + var result = Meteor.http.post( + "https://api.weibo.com/oauth2/access_token", {params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/weibo?close", {replaceLocalhost: true}), + grant_type: 'authorization_code' + }}); + + if (result.error) // if the http response was an error + throw result.error; + if (typeof result.content === "string") + result.content = JSON.parse(result.content); + if (result.content.error) // if the http response was a json object with an error attribute + throw result.content; + return result.content; + }; + + var getIdentity = function (accessToken, userId) { + var result = Meteor.http.get( + "https://api.weibo.com/2/users/show.json", + {params: {access_token: accessToken, uid: userId}}); + + if (result.error) + throw result.error; + return result.data; + }; +})(); diff --git a/packages/amplify/package.js b/packages/amplify/package.js index b70c41913a..c808eda438 100644 --- a/packages/amplify/package.js +++ b/packages/amplify/package.js @@ -1,5 +1,5 @@ Package.describe({ - summary: "Cross browser API for Persistant Storage, PubSub and Request." + summary: "API for Persistant Storage, PubSub and Request" }); Package.on_use(function (api) { diff --git a/packages/autopublish/package.js b/packages/autopublish/package.js index 7833caae42..ae8c12f22a 100644 --- a/packages/autopublish/package.js +++ b/packages/autopublish/package.js @@ -1,5 +1,5 @@ Package.describe({ - summary: "Automatically publish all data in the database to every client" + summary: "Automatically publish the entire database to all clients" }); Package.on_use(function (api, where) { diff --git a/packages/d3/d3.v2.js b/packages/d3/d3.v2.js new file mode 100644 index 0000000000..44fc0b072c --- /dev/null +++ b/packages/d3/d3.v2.js @@ -0,0 +1,7026 @@ +(function() { + function d3_class(ctor, properties) { + try { + for (var key in properties) { + Object.defineProperty(ctor.prototype, key, { + value: properties[key], + enumerable: false + }); + } + } catch (e) { + ctor.prototype = properties; + } + } + function d3_arrayCopy(pseudoarray) { + var i = -1, n = pseudoarray.length, array = []; + while (++i < n) array.push(pseudoarray[i]); + return array; + } + function d3_arraySlice(pseudoarray) { + return Array.prototype.slice.call(pseudoarray); + } + function d3_Map() {} + function d3_identity(d) { + return d; + } + function d3_this() { + return this; + } + function d3_true() { + return true; + } + function d3_functor(v) { + return typeof v === "function" ? v : function() { + return v; + }; + } + function d3_rebind(target, source, method) { + return function() { + var value = method.apply(source, arguments); + return arguments.length ? target : value; + }; + } + function d3_number(x) { + return x != null && !isNaN(x); + } + function d3_zipLength(d) { + return d.length; + } + function d3_splitter(d) { + return d == null; + } + function d3_collapse(s) { + return s.trim().replace(/\s+/g, " "); + } + function d3_range_integerScale(x) { + var k = 1; + while (x * k % 1) k *= 10; + return k; + } + function d3_dispatch() {} + function d3_dispatch_event(dispatch) { + function event() { + var z = listeners, i = -1, n = z.length, l; + while (++i < n) if (l = z[i].on) l.apply(this, arguments); + return dispatch; + } + var listeners = [], listenerByName = new d3_Map; + event.on = function(name, listener) { + var l = listenerByName.get(name), i; + if (arguments.length < 2) return l && l.on; + if (l) { + l.on = null; + listeners = listeners.slice(0, i = listeners.indexOf(l)).concat(listeners.slice(i + 1)); + listenerByName.remove(name); + } + if (listener) listeners.push(listenerByName.set(name, { + on: listener + })); + return dispatch; + }; + return event; + } + function d3_format_precision(x, p) { + return p - (x ? 1 + Math.floor(Math.log(x + Math.pow(10, 1 + Math.floor(Math.log(x) / Math.LN10) - p)) / Math.LN10) : 1); + } + function d3_format_typeDefault(x) { + return x + ""; + } + function d3_format_group(value) { + var i = value.lastIndexOf("."), f = i >= 0 ? value.substring(i) : (i = value.length, ""), t = []; + while (i > 0) t.push(value.substring(i -= 3, i + 3)); + return t.reverse().join(",") + f; + } + function d3_formatPrefix(d, i) { + var k = Math.pow(10, Math.abs(8 - i) * 3); + return { + scale: i > 8 ? function(d) { + return d / k; + } : function(d) { + return d * k; + }, + symbol: d + }; + } + function d3_ease_clamp(f) { + return function(t) { + return t <= 0 ? 0 : t >= 1 ? 1 : f(t); + }; + } + function d3_ease_reverse(f) { + return function(t) { + return 1 - f(1 - t); + }; + } + function d3_ease_reflect(f) { + return function(t) { + return .5 * (t < .5 ? f(2 * t) : 2 - f(2 - 2 * t)); + }; + } + function d3_ease_identity(t) { + return t; + } + function d3_ease_poly(e) { + return function(t) { + return Math.pow(t, e); + }; + } + function d3_ease_sin(t) { + return 1 - Math.cos(t * Math.PI / 2); + } + function d3_ease_exp(t) { + return Math.pow(2, 10 * (t - 1)); + } + function d3_ease_circle(t) { + return 1 - Math.sqrt(1 - t * t); + } + function d3_ease_elastic(a, p) { + var s; + if (arguments.length < 2) p = .45; + if (arguments.length < 1) { + a = 1; + s = p / 4; + } else s = p / (2 * Math.PI) * Math.asin(1 / a); + return function(t) { + return 1 + a * Math.pow(2, 10 * -t) * Math.sin((t - s) * 2 * Math.PI / p); + }; + } + function d3_ease_back(s) { + if (!s) s = 1.70158; + return function(t) { + return t * t * ((s + 1) * t - s); + }; + } + function d3_ease_bounce(t) { + return t < 1 / 2.75 ? 7.5625 * t * t : t < 2 / 2.75 ? 7.5625 * (t -= 1.5 / 2.75) * t + .75 : t < 2.5 / 2.75 ? 7.5625 * (t -= 2.25 / 2.75) * t + .9375 : 7.5625 * (t -= 2.625 / 2.75) * t + .984375; + } + function d3_eventCancel() { + d3.event.stopPropagation(); + d3.event.preventDefault(); + } + function d3_eventSource() { + var e = d3.event, s; + while (s = e.sourceEvent) e = s; + return e; + } + function d3_eventDispatch(target) { + var dispatch = new d3_dispatch, i = 0, n = arguments.length; + while (++i < n) dispatch[arguments[i]] = d3_dispatch_event(dispatch); + dispatch.of = function(thiz, argumentz) { + return function(e1) { + try { + var e0 = e1.sourceEvent = d3.event; + e1.target = target; + d3.event = e1; + dispatch[e1.type].apply(thiz, argumentz); + } finally { + d3.event = e0; + } + }; + }; + return dispatch; + } + function d3_transform(m) { + var r0 = [ m.a, m.b ], r1 = [ m.c, m.d ], kx = d3_transformNormalize(r0), kz = d3_transformDot(r0, r1), ky = d3_transformNormalize(d3_transformCombine(r1, r0, -kz)) || 0; + if (r0[0] * r1[1] < r1[0] * r0[1]) { + r0[0] *= -1; + r0[1] *= -1; + kx *= -1; + kz *= -1; + } + this.rotate = (kx ? Math.atan2(r0[1], r0[0]) : Math.atan2(-r1[0], r1[1])) * d3_transformDegrees; + this.translate = [ m.e, m.f ]; + this.scale = [ kx, ky ]; + this.skew = ky ? Math.atan2(kz, ky) * d3_transformDegrees : 0; + } + function d3_transformDot(a, b) { + return a[0] * b[0] + a[1] * b[1]; + } + function d3_transformNormalize(a) { + var k = Math.sqrt(d3_transformDot(a, a)); + if (k) { + a[0] /= k; + a[1] /= k; + } + return k; + } + function d3_transformCombine(a, b, k) { + a[0] += k * b[0]; + a[1] += k * b[1]; + return a; + } + function d3_interpolateByName(name) { + return name == "transform" ? d3.interpolateTransform : d3.interpolate; + } + function d3_uninterpolateNumber(a, b) { + b = b - (a = +a) ? 1 / (b - a) : 0; + return function(x) { + return (x - a) * b; + }; + } + function d3_uninterpolateClamp(a, b) { + b = b - (a = +a) ? 1 / (b - a) : 0; + return function(x) { + return Math.max(0, Math.min(1, (x - a) * b)); + }; + } + function d3_Color() {} + function d3_rgb(r, g, b) { + return new d3_Rgb(r, g, b); + } + function d3_Rgb(r, g, b) { + this.r = r; + this.g = g; + this.b = b; + } + function d3_rgb_hex(v) { + return v < 16 ? "0" + Math.max(0, v).toString(16) : Math.min(255, v).toString(16); + } + function d3_rgb_parse(format, rgb, hsl) { + var r = 0, g = 0, b = 0, m1, m2, name; + m1 = /([a-z]+)\((.*)\)/i.exec(format); + if (m1) { + m2 = m1[2].split(","); + switch (m1[1]) { + case "hsl": + { + return hsl(parseFloat(m2[0]), parseFloat(m2[1]) / 100, parseFloat(m2[2]) / 100); + } + case "rgb": + { + return rgb(d3_rgb_parseNumber(m2[0]), d3_rgb_parseNumber(m2[1]), d3_rgb_parseNumber(m2[2])); + } + } + } + if (name = d3_rgb_names.get(format)) return rgb(name.r, name.g, name.b); + if (format != null && format.charAt(0) === "#") { + if (format.length === 4) { + r = format.charAt(1); + r += r; + g = format.charAt(2); + g += g; + b = format.charAt(3); + b += b; + } else if (format.length === 7) { + r = format.substring(1, 3); + g = format.substring(3, 5); + b = format.substring(5, 7); + } + r = parseInt(r, 16); + g = parseInt(g, 16); + b = parseInt(b, 16); + } + return rgb(r, g, b); + } + function d3_rgb_hsl(r, g, b) { + var min = Math.min(r /= 255, g /= 255, b /= 255), max = Math.max(r, g, b), d = max - min, h, s, l = (max + min) / 2; + if (d) { + s = l < .5 ? d / (max + min) : d / (2 - max - min); + if (r == max) h = (g - b) / d + (g < b ? 6 : 0); else if (g == max) h = (b - r) / d + 2; else h = (r - g) / d + 4; + h *= 60; + } else { + s = h = 0; + } + return d3_hsl(h, s, l); + } + function d3_rgb_lab(r, g, b) { + r = d3_rgb_xyz(r); + g = d3_rgb_xyz(g); + b = d3_rgb_xyz(b); + var x = d3_xyz_lab((.4124564 * r + .3575761 * g + .1804375 * b) / d3_lab_X), y = d3_xyz_lab((.2126729 * r + .7151522 * g + .072175 * b) / d3_lab_Y), z = d3_xyz_lab((.0193339 * r + .119192 * g + .9503041 * b) / d3_lab_Z); + return d3_lab(116 * y - 16, 500 * (x - y), 200 * (y - z)); + } + function d3_rgb_xyz(r) { + return (r /= 255) <= .04045 ? r / 12.92 : Math.pow((r + .055) / 1.055, 2.4); + } + function d3_rgb_parseNumber(c) { + var f = parseFloat(c); + return c.charAt(c.length - 1) === "%" ? Math.round(f * 2.55) : f; + } + function d3_hsl(h, s, l) { + return new d3_Hsl(h, s, l); + } + function d3_Hsl(h, s, l) { + this.h = h; + this.s = s; + this.l = l; + } + function d3_hsl_rgb(h, s, l) { + function v(h) { + if (h > 360) h -= 360; else if (h < 0) h += 360; + if (h < 60) return m1 + (m2 - m1) * h / 60; + if (h < 180) return m2; + if (h < 240) return m1 + (m2 - m1) * (240 - h) / 60; + return m1; + } + function vv(h) { + return Math.round(v(h) * 255); + } + var m1, m2; + h = h % 360; + if (h < 0) h += 360; + s = s < 0 ? 0 : s > 1 ? 1 : s; + l = l < 0 ? 0 : l > 1 ? 1 : l; + m2 = l <= .5 ? l * (1 + s) : l + s - l * s; + m1 = 2 * l - m2; + return d3_rgb(vv(h + 120), vv(h), vv(h - 120)); + } + function d3_hcl(h, c, l) { + return new d3_Hcl(h, c, l); + } + function d3_Hcl(h, c, l) { + this.h = h; + this.c = c; + this.l = l; + } + function d3_hcl_lab(h, c, l) { + return d3_lab(l, Math.cos(h *= Math.PI / 180) * c, Math.sin(h) * c); + } + function d3_lab(l, a, b) { + return new d3_Lab(l, a, b); + } + function d3_Lab(l, a, b) { + this.l = l; + this.a = a; + this.b = b; + } + function d3_lab_rgb(l, a, b) { + var y = (l + 16) / 116, x = y + a / 500, z = y - b / 200; + x = d3_lab_xyz(x) * d3_lab_X; + y = d3_lab_xyz(y) * d3_lab_Y; + z = d3_lab_xyz(z) * d3_lab_Z; + return d3_rgb(d3_xyz_rgb(3.2404542 * x - 1.5371385 * y - .4985314 * z), d3_xyz_rgb(-.969266 * x + 1.8760108 * y + .041556 * z), d3_xyz_rgb(.0556434 * x - .2040259 * y + 1.0572252 * z)); + } + function d3_lab_hcl(l, a, b) { + return d3_hcl(Math.atan2(b, a) / Math.PI * 180, Math.sqrt(a * a + b * b), l); + } + function d3_lab_xyz(x) { + return x > .206893034 ? x * x * x : (x - 4 / 29) / 7.787037; + } + function d3_xyz_lab(x) { + return x > .008856 ? Math.pow(x, 1 / 3) : 7.787037 * x + 4 / 29; + } + function d3_xyz_rgb(r) { + return Math.round(255 * (r <= .00304 ? 12.92 * r : 1.055 * Math.pow(r, 1 / 2.4) - .055)); + } + function d3_selection(groups) { + d3_arraySubclass(groups, d3_selectionPrototype); + return groups; + } + function d3_selection_selector(selector) { + return function() { + return d3_select(selector, this); + }; + } + function d3_selection_selectorAll(selector) { + return function() { + return d3_selectAll(selector, this); + }; + } + function d3_selection_attr(name, value) { + function attrNull() { + this.removeAttribute(name); + } + function attrNullNS() { + this.removeAttributeNS(name.space, name.local); + } + function attrConstant() { + this.setAttribute(name, value); + } + function attrConstantNS() { + this.setAttributeNS(name.space, name.local, value); + } + function attrFunction() { + var x = value.apply(this, arguments); + if (x == null) this.removeAttribute(name); else this.setAttribute(name, x); + } + function attrFunctionNS() { + var x = value.apply(this, arguments); + if (x == null) this.removeAttributeNS(name.space, name.local); else this.setAttributeNS(name.space, name.local, x); + } + name = d3.ns.qualify(name); + return value == null ? name.local ? attrNullNS : attrNull : typeof value === "function" ? name.local ? attrFunctionNS : attrFunction : name.local ? attrConstantNS : attrConstant; + } + function d3_selection_classedRe(name) { + return new RegExp("(?:^|\\s+)" + d3.requote(name) + "(?:\\s+|$)", "g"); + } + function d3_selection_classed(name, value) { + function classedConstant() { + var i = -1; + while (++i < n) name[i](this, value); + } + function classedFunction() { + var i = -1, x = value.apply(this, arguments); + while (++i < n) name[i](this, x); + } + name = name.trim().split(/\s+/).map(d3_selection_classedName); + var n = name.length; + return typeof value === "function" ? classedFunction : classedConstant; + } + function d3_selection_classedName(name) { + var re = d3_selection_classedRe(name); + return function(node, value) { + if (c = node.classList) return value ? c.add(name) : c.remove(name); + var c = node.className, cb = c.baseVal != null, cv = cb ? c.baseVal : c; + if (value) { + re.lastIndex = 0; + if (!re.test(cv)) { + cv = d3_collapse(cv + " " + name); + if (cb) c.baseVal = cv; else node.className = cv; + } + } else if (cv) { + cv = d3_collapse(cv.replace(re, " ")); + if (cb) c.baseVal = cv; else node.className = cv; + } + }; + } + function d3_selection_style(name, value, priority) { + function styleNull() { + this.style.removeProperty(name); + } + function styleConstant() { + this.style.setProperty(name, value, priority); + } + function styleFunction() { + var x = value.apply(this, arguments); + if (x == null) this.style.removeProperty(name); else this.style.setProperty(name, x, priority); + } + return value == null ? styleNull : typeof value === "function" ? styleFunction : styleConstant; + } + function d3_selection_property(name, value) { + function propertyNull() { + delete this[name]; + } + function propertyConstant() { + this[name] = value; + } + function propertyFunction() { + var x = value.apply(this, arguments); + if (x == null) delete this[name]; else this[name] = x; + } + return value == null ? propertyNull : typeof value === "function" ? propertyFunction : propertyConstant; + } + function d3_selection_dataNode(data) { + return { + __data__: data + }; + } + function d3_selection_filter(selector) { + return function() { + return d3_selectMatches(this, selector); + }; + } + function d3_selection_sortComparator(comparator) { + if (!arguments.length) comparator = d3.ascending; + return function(a, b) { + return comparator(a && a.__data__, b && b.__data__); + }; + } + function d3_selection_on(type, listener, capture) { + function onRemove() { + var wrapper = this[name]; + if (wrapper) { + this.removeEventListener(type, wrapper, wrapper.$); + delete this[name]; + } + } + function onAdd() { + function wrapper(e) { + var o = d3.event; + d3.event = e; + args[0] = node.__data__; + try { + listener.apply(node, args); + } finally { + d3.event = o; + } + } + var node = this, args = arguments; + onRemove.call(this); + this.addEventListener(type, this[name] = wrapper, wrapper.$ = capture); + wrapper._ = listener; + } + var name = "__on" + type, i = type.indexOf("."); + if (i > 0) type = type.substring(0, i); + return listener ? onAdd : onRemove; + } + function d3_selection_each(groups, callback) { + for (var j = 0, m = groups.length; j < m; j++) { + for (var group = groups[j], i = 0, n = group.length, node; i < n; i++) { + if (node = group[i]) callback(node, i, j); + } + } + return groups; + } + function d3_selection_enter(selection) { + d3_arraySubclass(selection, d3_selection_enterPrototype); + return selection; + } + function d3_transition(groups, id, time) { + d3_arraySubclass(groups, d3_transitionPrototype); + var tweens = new d3_Map, event = d3.dispatch("start", "end"), ease = d3_transitionEase; + groups.id = id; + groups.time = time; + groups.tween = function(name, tween) { + if (arguments.length < 2) return tweens.get(name); + if (tween == null) tweens.remove(name); else tweens.set(name, tween); + return groups; + }; + groups.ease = function(value) { + if (!arguments.length) return ease; + ease = typeof value === "function" ? value : d3.ease.apply(d3, arguments); + return groups; + }; + groups.each = function(type, listener) { + if (arguments.length < 2) return d3_transition_each.call(groups, type); + event.on(type, listener); + return groups; + }; + d3.timer(function(elapsed) { + return d3_selection_each(groups, function(node, i, j) { + function start(elapsed) { + if (lock.active > id) return stop(); + lock.active = id; + tweens.forEach(function(key, value) { + if (value = value.call(node, d, i)) { + tweened.push(value); + } + }); + event.start.call(node, d, i); + if (!tick(elapsed)) d3.timer(tick, 0, time); + return 1; + } + function tick(elapsed) { + if (lock.active !== id) return stop(); + var t = (elapsed - delay) / duration, e = ease(t), n = tweened.length; + while (n > 0) { + tweened[--n].call(node, e); + } + if (t >= 1) { + stop(); + d3_transitionId = id; + event.end.call(node, d, i); + d3_transitionId = 0; + return 1; + } + } + function stop() { + if (!--lock.count) delete node.__transition__; + return 1; + } + var tweened = [], delay = node.delay, duration = node.duration, lock = (node = node.node).__transition__ || (node.__transition__ = { + active: 0, + count: 0 + }), d = node.__data__; + ++lock.count; + delay <= elapsed ? start(elapsed) : d3.timer(start, delay, time); + }); + }, 0, time); + return groups; + } + function d3_transition_each(callback) { + var id = d3_transitionId, ease = d3_transitionEase, delay = d3_transitionDelay, duration = d3_transitionDuration; + d3_transitionId = this.id; + d3_transitionEase = this.ease(); + d3_selection_each(this, function(node, i, j) { + d3_transitionDelay = node.delay; + d3_transitionDuration = node.duration; + callback.call(node = node.node, node.__data__, i, j); + }); + d3_transitionId = id; + d3_transitionEase = ease; + d3_transitionDelay = delay; + d3_transitionDuration = duration; + return this; + } + function d3_tweenNull(d, i, a) { + return a != "" && d3_tweenRemove; + } + function d3_tweenByName(b, name) { + return d3.tween(b, d3_interpolateByName(name)); + } + function d3_timer_step() { + var elapsed, now = Date.now(), t1 = d3_timer_queue; + while (t1) { + elapsed = now - t1.then; + if (elapsed >= t1.delay) t1.flush = t1.callback(elapsed); + t1 = t1.next; + } + var delay = d3_timer_flush() - now; + if (delay > 24) { + if (isFinite(delay)) { + clearTimeout(d3_timer_timeout); + d3_timer_timeout = setTimeout(d3_timer_step, delay); + } + d3_timer_interval = 0; + } else { + d3_timer_interval = 1; + d3_timer_frame(d3_timer_step); + } + } + function d3_timer_flush() { + var t0 = null, t1 = d3_timer_queue, then = Infinity; + while (t1) { + if (t1.flush) { + delete d3_timer_byId[t1.callback.id]; + t1 = t0 ? t0.next = t1.next : d3_timer_queue = t1.next; + } else { + then = Math.min(then, t1.then + t1.delay); + t1 = (t0 = t1).next; + } + } + return then; + } + function d3_mousePoint(container, e) { + var svg = container.ownerSVGElement || container; + if (svg.createSVGPoint) { + var point = svg.createSVGPoint(); + if (d3_mouse_bug44083 < 0 && (window.scrollX || window.scrollY)) { + svg = d3.select(document.body).append("svg").style("position", "absolute").style("top", 0).style("left", 0); + var ctm = svg[0][0].getScreenCTM(); + d3_mouse_bug44083 = !(ctm.f || ctm.e); + svg.remove(); + } + if (d3_mouse_bug44083) { + point.x = e.pageX; + point.y = e.pageY; + } else { + point.x = e.clientX; + point.y = e.clientY; + } + point = point.matrixTransform(container.getScreenCTM().inverse()); + return [ point.x, point.y ]; + } + var rect = container.getBoundingClientRect(); + return [ e.clientX - rect.left - container.clientLeft, e.clientY - rect.top - container.clientTop ]; + } + function d3_noop() {} + function d3_scaleExtent(domain) { + var start = domain[0], stop = domain[domain.length - 1]; + return start < stop ? [ start, stop ] : [ stop, start ]; + } + function d3_scaleRange(scale) { + return scale.rangeExtent ? scale.rangeExtent() : d3_scaleExtent(scale.range()); + } + function d3_scale_nice(domain, nice) { + var i0 = 0, i1 = domain.length - 1, x0 = domain[i0], x1 = domain[i1], dx; + if (x1 < x0) { + dx = i0, i0 = i1, i1 = dx; + dx = x0, x0 = x1, x1 = dx; + } + if (nice = nice(x1 - x0)) { + domain[i0] = nice.floor(x0); + domain[i1] = nice.ceil(x1); + } + return domain; + } + function d3_scale_niceDefault() { + return Math; + } + function d3_scale_linear(domain, range, interpolate, clamp) { + function rescale() { + var linear = Math.min(domain.length, range.length) > 2 ? d3_scale_polylinear : d3_scale_bilinear, uninterpolate = clamp ? d3_uninterpolateClamp : d3_uninterpolateNumber; + output = linear(domain, range, uninterpolate, interpolate); + input = linear(range, domain, uninterpolate, d3.interpolate); + return scale; + } + function scale(x) { + return output(x); + } + var output, input; + scale.invert = function(y) { + return input(y); + }; + scale.domain = function(x) { + if (!arguments.length) return domain; + domain = x.map(Number); + return rescale(); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + return rescale(); + }; + scale.rangeRound = function(x) { + return scale.range(x).interpolate(d3.interpolateRound); + }; + scale.clamp = function(x) { + if (!arguments.length) return clamp; + clamp = x; + return rescale(); + }; + scale.interpolate = function(x) { + if (!arguments.length) return interpolate; + interpolate = x; + return rescale(); + }; + scale.ticks = function(m) { + return d3_scale_linearTicks(domain, m); + }; + scale.tickFormat = function(m) { + return d3_scale_linearTickFormat(domain, m); + }; + scale.nice = function() { + d3_scale_nice(domain, d3_scale_linearNice); + return rescale(); + }; + scale.copy = function() { + return d3_scale_linear(domain, range, interpolate, clamp); + }; + return rescale(); + } + function d3_scale_linearRebind(scale, linear) { + return d3.rebind(scale, linear, "range", "rangeRound", "interpolate", "clamp"); + } + function d3_scale_linearNice(dx) { + dx = Math.pow(10, Math.round(Math.log(dx) / Math.LN10) - 1); + return dx && { + floor: function(x) { + return Math.floor(x / dx) * dx; + }, + ceil: function(x) { + return Math.ceil(x / dx) * dx; + } + }; + } + function d3_scale_linearTickRange(domain, m) { + var extent = d3_scaleExtent(domain), span = extent[1] - extent[0], step = Math.pow(10, Math.floor(Math.log(span / m) / Math.LN10)), err = m / span * step; + if (err <= .15) step *= 10; else if (err <= .35) step *= 5; else if (err <= .75) step *= 2; + extent[0] = Math.ceil(extent[0] / step) * step; + extent[1] = Math.floor(extent[1] / step) * step + step * .5; + extent[2] = step; + return extent; + } + function d3_scale_linearTicks(domain, m) { + return d3.range.apply(d3, d3_scale_linearTickRange(domain, m)); + } + function d3_scale_linearTickFormat(domain, m) { + return d3.format(",." + Math.max(0, -Math.floor(Math.log(d3_scale_linearTickRange(domain, m)[2]) / Math.LN10 + .01)) + "f"); + } + function d3_scale_bilinear(domain, range, uninterpolate, interpolate) { + var u = uninterpolate(domain[0], domain[1]), i = interpolate(range[0], range[1]); + return function(x) { + return i(u(x)); + }; + } + function d3_scale_polylinear(domain, range, uninterpolate, interpolate) { + var u = [], i = [], j = 0, k = Math.min(domain.length, range.length) - 1; + if (domain[k] < domain[0]) { + domain = domain.slice().reverse(); + range = range.slice().reverse(); + } + while (++j <= k) { + u.push(uninterpolate(domain[j - 1], domain[j])); + i.push(interpolate(range[j - 1], range[j])); + } + return function(x) { + var j = d3.bisect(domain, x, 1, k) - 1; + return i[j](u[j](x)); + }; + } + function d3_scale_log(linear, log) { + function scale(x) { + return linear(log(x)); + } + var pow = log.pow; + scale.invert = function(x) { + return pow(linear.invert(x)); + }; + scale.domain = function(x) { + if (!arguments.length) return linear.domain().map(pow); + log = x[0] < 0 ? d3_scale_logn : d3_scale_logp; + pow = log.pow; + linear.domain(x.map(log)); + return scale; + }; + scale.nice = function() { + linear.domain(d3_scale_nice(linear.domain(), d3_scale_niceDefault)); + return scale; + }; + scale.ticks = function() { + var extent = d3_scaleExtent(linear.domain()), ticks = []; + if (extent.every(isFinite)) { + var i = Math.floor(extent[0]), j = Math.ceil(extent[1]), u = pow(extent[0]), v = pow(extent[1]); + if (log === d3_scale_logn) { + ticks.push(pow(i)); + for (; i++ < j; ) for (var k = 9; k > 0; k--) ticks.push(pow(i) * k); + } else { + for (; i < j; i++) for (var k = 1; k < 10; k++) ticks.push(pow(i) * k); + ticks.push(pow(i)); + } + for (i = 0; ticks[i] < u; i++) {} + for (j = ticks.length; ticks[j - 1] > v; j--) {} + ticks = ticks.slice(i, j); + } + return ticks; + }; + scale.tickFormat = function(n, format) { + if (arguments.length < 2) format = d3_scale_logFormat; + if (arguments.length < 1) return format; + var k = Math.max(.1, n / scale.ticks().length), f = log === d3_scale_logn ? (e = -1e-12, Math.floor) : (e = 1e-12, Math.ceil), e; + return function(d) { + return d / pow(f(log(d) + e)) <= k ? format(d) : ""; + }; + }; + scale.copy = function() { + return d3_scale_log(linear.copy(), log); + }; + return d3_scale_linearRebind(scale, linear); + } + function d3_scale_logp(x) { + return Math.log(x < 0 ? 0 : x) / Math.LN10; + } + function d3_scale_logn(x) { + return -Math.log(x > 0 ? 0 : -x) / Math.LN10; + } + function d3_scale_pow(linear, exponent) { + function scale(x) { + return linear(powp(x)); + } + var powp = d3_scale_powPow(exponent), powb = d3_scale_powPow(1 / exponent); + scale.invert = function(x) { + return powb(linear.invert(x)); + }; + scale.domain = function(x) { + if (!arguments.length) return linear.domain().map(powb); + linear.domain(x.map(powp)); + return scale; + }; + scale.ticks = function(m) { + return d3_scale_linearTicks(scale.domain(), m); + }; + scale.tickFormat = function(m) { + return d3_scale_linearTickFormat(scale.domain(), m); + }; + scale.nice = function() { + return scale.domain(d3_scale_nice(scale.domain(), d3_scale_linearNice)); + }; + scale.exponent = function(x) { + if (!arguments.length) return exponent; + var domain = scale.domain(); + powp = d3_scale_powPow(exponent = x); + powb = d3_scale_powPow(1 / exponent); + return scale.domain(domain); + }; + scale.copy = function() { + return d3_scale_pow(linear.copy(), exponent); + }; + return d3_scale_linearRebind(scale, linear); + } + function d3_scale_powPow(e) { + return function(x) { + return x < 0 ? -Math.pow(-x, e) : Math.pow(x, e); + }; + } + function d3_scale_ordinal(domain, ranger) { + function scale(x) { + return range[((index.get(x) || index.set(x, domain.push(x))) - 1) % range.length]; + } + function steps(start, step) { + return d3.range(domain.length).map(function(i) { + return start + step * i; + }); + } + var index, range, rangeBand; + scale.domain = function(x) { + if (!arguments.length) return domain; + domain = []; + index = new d3_Map; + var i = -1, n = x.length, xi; + while (++i < n) if (!index.has(xi = x[i])) index.set(xi, domain.push(xi)); + return scale[ranger.t].apply(scale, ranger.a); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + rangeBand = 0; + ranger = { + t: "range", + a: arguments + }; + return scale; + }; + scale.rangePoints = function(x, padding) { + if (arguments.length < 2) padding = 0; + var start = x[0], stop = x[1], step = (stop - start) / (Math.max(1, domain.length - 1) + padding); + range = steps(domain.length < 2 ? (start + stop) / 2 : start + step * padding / 2, step); + rangeBand = 0; + ranger = { + t: "rangePoints", + a: arguments + }; + return scale; + }; + scale.rangeBands = function(x, padding, outerPadding) { + if (arguments.length < 2) padding = 0; + if (arguments.length < 3) outerPadding = padding; + var reverse = x[1] < x[0], start = x[reverse - 0], stop = x[1 - reverse], step = (stop - start) / (domain.length - padding + 2 * outerPadding); + range = steps(start + step * outerPadding, step); + if (reverse) range.reverse(); + rangeBand = step * (1 - padding); + ranger = { + t: "rangeBands", + a: arguments + }; + return scale; + }; + scale.rangeRoundBands = function(x, padding, outerPadding) { + if (arguments.length < 2) padding = 0; + if (arguments.length < 3) outerPadding = padding; + var reverse = x[1] < x[0], start = x[reverse - 0], stop = x[1 - reverse], step = Math.floor((stop - start) / (domain.length - padding + 2 * outerPadding)), error = stop - start - (domain.length - padding) * step; + range = steps(start + Math.round(error / 2), step); + if (reverse) range.reverse(); + rangeBand = Math.round(step * (1 - padding)); + ranger = { + t: "rangeRoundBands", + a: arguments + }; + return scale; + }; + scale.rangeBand = function() { + return rangeBand; + }; + scale.rangeExtent = function() { + return d3_scaleExtent(ranger.a[0]); + }; + scale.copy = function() { + return d3_scale_ordinal(domain, ranger); + }; + return scale.domain(domain); + } + function d3_scale_quantile(domain, range) { + function rescale() { + var k = 0, n = domain.length, q = range.length; + thresholds = []; + while (++k < q) thresholds[k - 1] = d3.quantile(domain, k / q); + return scale; + } + function scale(x) { + if (isNaN(x = +x)) return NaN; + return range[d3.bisect(thresholds, x)]; + } + var thresholds; + scale.domain = function(x) { + if (!arguments.length) return domain; + domain = x.filter(function(d) { + return !isNaN(d); + }).sort(d3.ascending); + return rescale(); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + return rescale(); + }; + scale.quantiles = function() { + return thresholds; + }; + scale.copy = function() { + return d3_scale_quantile(domain, range); + }; + return rescale(); + } + function d3_scale_quantize(x0, x1, range) { + function scale(x) { + return range[Math.max(0, Math.min(i, Math.floor(kx * (x - x0))))]; + } + function rescale() { + kx = range.length / (x1 - x0); + i = range.length - 1; + return scale; + } + var kx, i; + scale.domain = function(x) { + if (!arguments.length) return [ x0, x1 ]; + x0 = +x[0]; + x1 = +x[x.length - 1]; + return rescale(); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + return rescale(); + }; + scale.copy = function() { + return d3_scale_quantize(x0, x1, range); + }; + return rescale(); + } + function d3_scale_threshold(domain, range) { + function scale(x) { + return range[d3.bisect(domain, x)]; + } + scale.domain = function(_) { + if (!arguments.length) return domain; + domain = _; + return scale; + }; + scale.range = function(_) { + if (!arguments.length) return range; + range = _; + return scale; + }; + scale.copy = function() { + return d3_scale_threshold(domain, range); + }; + return scale; + } + function d3_scale_identity(domain) { + function identity(x) { + return +x; + } + identity.invert = identity; + identity.domain = identity.range = function(x) { + if (!arguments.length) return domain; + domain = x.map(identity); + return identity; + }; + identity.ticks = function(m) { + return d3_scale_linearTicks(domain, m); + }; + identity.tickFormat = function(m) { + return d3_scale_linearTickFormat(domain, m); + }; + identity.copy = function() { + return d3_scale_identity(domain); + }; + return identity; + } + function d3_svg_arcInnerRadius(d) { + return d.innerRadius; + } + function d3_svg_arcOuterRadius(d) { + return d.outerRadius; + } + function d3_svg_arcStartAngle(d) { + return d.startAngle; + } + function d3_svg_arcEndAngle(d) { + return d.endAngle; + } + function d3_svg_line(projection) { + function line(data) { + function segment() { + segments.push("M", interpolate(projection(points), tension)); + } + var segments = [], points = [], i = -1, n = data.length, d, fx = d3_functor(x), fy = d3_functor(y); + while (++i < n) { + if (defined.call(this, d = data[i], i)) { + points.push([ +fx.call(this, d, i), +fy.call(this, d, i) ]); + } else if (points.length) { + segment(); + points = []; + } + } + if (points.length) segment(); + return segments.length ? segments.join("") : null; + } + var x = d3_svg_lineX, y = d3_svg_lineY, defined = d3_true, interpolate = d3_svg_lineLinear, interpolateKey = interpolate.key, tension = .7; + line.x = function(_) { + if (!arguments.length) return x; + x = _; + return line; + }; + line.y = function(_) { + if (!arguments.length) return y; + y = _; + return line; + }; + line.defined = function(_) { + if (!arguments.length) return defined; + defined = _; + return line; + }; + line.interpolate = function(_) { + if (!arguments.length) return interpolateKey; + if (typeof _ === "function") interpolateKey = interpolate = _; else interpolateKey = (interpolate = d3_svg_lineInterpolators.get(_) || d3_svg_lineLinear).key; + return line; + }; + line.tension = function(_) { + if (!arguments.length) return tension; + tension = _; + return line; + }; + return line; + } + function d3_svg_lineX(d) { + return d[0]; + } + function d3_svg_lineY(d) { + return d[1]; + } + function d3_svg_lineLinear(points) { + return points.join("L"); + } + function d3_svg_lineLinearClosed(points) { + return d3_svg_lineLinear(points) + "Z"; + } + function d3_svg_lineStepBefore(points) { + var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ]; + while (++i < n) path.push("V", (p = points[i])[1], "H", p[0]); + return path.join(""); + } + function d3_svg_lineStepAfter(points) { + var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ]; + while (++i < n) path.push("H", (p = points[i])[0], "V", p[1]); + return path.join(""); + } + function d3_svg_lineCardinalOpen(points, tension) { + return points.length < 4 ? d3_svg_lineLinear(points) : points[1] + d3_svg_lineHermite(points.slice(1, points.length - 1), d3_svg_lineCardinalTangents(points, tension)); + } + function d3_svg_lineCardinalClosed(points, tension) { + return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite((points.push(points[0]), points), d3_svg_lineCardinalTangents([ points[points.length - 2] ].concat(points, [ points[1] ]), tension)); + } + function d3_svg_lineCardinal(points, tension, closed) { + return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite(points, d3_svg_lineCardinalTangents(points, tension)); + } + function d3_svg_lineHermite(points, tangents) { + if (tangents.length < 1 || points.length != tangents.length && points.length != tangents.length + 2) { + return d3_svg_lineLinear(points); + } + var quad = points.length != tangents.length, path = "", p0 = points[0], p = points[1], t0 = tangents[0], t = t0, pi = 1; + if (quad) { + path += "Q" + (p[0] - t0[0] * 2 / 3) + "," + (p[1] - t0[1] * 2 / 3) + "," + p[0] + "," + p[1]; + p0 = points[1]; + pi = 2; + } + if (tangents.length > 1) { + t = tangents[1]; + p = points[pi]; + pi++; + path += "C" + (p0[0] + t0[0]) + "," + (p0[1] + t0[1]) + "," + (p[0] - t[0]) + "," + (p[1] - t[1]) + "," + p[0] + "," + p[1]; + for (var i = 2; i < tangents.length; i++, pi++) { + p = points[pi]; + t = tangents[i]; + path += "S" + (p[0] - t[0]) + "," + (p[1] - t[1]) + "," + p[0] + "," + p[1]; + } + } + if (quad) { + var lp = points[pi]; + path += "Q" + (p[0] + t[0] * 2 / 3) + "," + (p[1] + t[1] * 2 / 3) + "," + lp[0] + "," + lp[1]; + } + return path; + } + function d3_svg_lineCardinalTangents(points, tension) { + var tangents = [], a = (1 - tension) / 2, p0, p1 = points[0], p2 = points[1], i = 1, n = points.length; + while (++i < n) { + p0 = p1; + p1 = p2; + p2 = points[i]; + tangents.push([ a * (p2[0] - p0[0]), a * (p2[1] - p0[1]) ]); + } + return tangents; + } + function d3_svg_lineBasis(points) { + if (points.length < 3) return d3_svg_lineLinear(points); + var i = 1, n = points.length, pi = points[0], x0 = pi[0], y0 = pi[1], px = [ x0, x0, x0, (pi = points[1])[0] ], py = [ y0, y0, y0, pi[1] ], path = [ x0, ",", y0 ]; + d3_svg_lineBasisBezier(path, px, py); + while (++i < n) { + pi = points[i]; + px.shift(); + px.push(pi[0]); + py.shift(); + py.push(pi[1]); + d3_svg_lineBasisBezier(path, px, py); + } + i = -1; + while (++i < 2) { + px.shift(); + px.push(pi[0]); + py.shift(); + py.push(pi[1]); + d3_svg_lineBasisBezier(path, px, py); + } + return path.join(""); + } + function d3_svg_lineBasisOpen(points) { + if (points.length < 4) return d3_svg_lineLinear(points); + var path = [], i = -1, n = points.length, pi, px = [ 0 ], py = [ 0 ]; + while (++i < 3) { + pi = points[i]; + px.push(pi[0]); + py.push(pi[1]); + } + path.push(d3_svg_lineDot4(d3_svg_lineBasisBezier3, px) + "," + d3_svg_lineDot4(d3_svg_lineBasisBezier3, py)); + --i; + while (++i < n) { + pi = points[i]; + px.shift(); + px.push(pi[0]); + py.shift(); + py.push(pi[1]); + d3_svg_lineBasisBezier(path, px, py); + } + return path.join(""); + } + function d3_svg_lineBasisClosed(points) { + var path, i = -1, n = points.length, m = n + 4, pi, px = [], py = []; + while (++i < 4) { + pi = points[i % n]; + px.push(pi[0]); + py.push(pi[1]); + } + path = [ d3_svg_lineDot4(d3_svg_lineBasisBezier3, px), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, py) ]; + --i; + while (++i < m) { + pi = points[i % n]; + px.shift(); + px.push(pi[0]); + py.shift(); + py.push(pi[1]); + d3_svg_lineBasisBezier(path, px, py); + } + return path.join(""); + } + function d3_svg_lineBundle(points, tension) { + var n = points.length - 1; + if (n) { + var x0 = points[0][0], y0 = points[0][1], dx = points[n][0] - x0, dy = points[n][1] - y0, i = -1, p, t; + while (++i <= n) { + p = points[i]; + t = i / n; + p[0] = tension * p[0] + (1 - tension) * (x0 + t * dx); + p[1] = tension * p[1] + (1 - tension) * (y0 + t * dy); + } + } + return d3_svg_lineBasis(points); + } + function d3_svg_lineDot4(a, b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; + } + function d3_svg_lineBasisBezier(path, x, y) { + path.push("C", d3_svg_lineDot4(d3_svg_lineBasisBezier1, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier1, y), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier2, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier2, y), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, y)); + } + function d3_svg_lineSlope(p0, p1) { + return (p1[1] - p0[1]) / (p1[0] - p0[0]); + } + function d3_svg_lineFiniteDifferences(points) { + var i = 0, j = points.length - 1, m = [], p0 = points[0], p1 = points[1], d = m[0] = d3_svg_lineSlope(p0, p1); + while (++i < j) { + m[i] = (d + (d = d3_svg_lineSlope(p0 = p1, p1 = points[i + 1]))) / 2; + } + m[i] = d; + return m; + } + function d3_svg_lineMonotoneTangents(points) { + var tangents = [], d, a, b, s, m = d3_svg_lineFiniteDifferences(points), i = -1, j = points.length - 1; + while (++i < j) { + d = d3_svg_lineSlope(points[i], points[i + 1]); + if (Math.abs(d) < 1e-6) { + m[i] = m[i + 1] = 0; + } else { + a = m[i] / d; + b = m[i + 1] / d; + s = a * a + b * b; + if (s > 9) { + s = d * 3 / Math.sqrt(s); + m[i] = s * a; + m[i + 1] = s * b; + } + } + } + i = -1; + while (++i <= j) { + s = (points[Math.min(j, i + 1)][0] - points[Math.max(0, i - 1)][0]) / (6 * (1 + m[i] * m[i])); + tangents.push([ s || 0, m[i] * s || 0 ]); + } + return tangents; + } + function d3_svg_lineMonotone(points) { + return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite(points, d3_svg_lineMonotoneTangents(points)); + } + function d3_svg_lineRadial(points) { + var point, i = -1, n = points.length, r, a; + while (++i < n) { + point = points[i]; + r = point[0]; + a = point[1] + d3_svg_arcOffset; + point[0] = r * Math.cos(a); + point[1] = r * Math.sin(a); + } + return points; + } + function d3_svg_area(projection) { + function area(data) { + function segment() { + segments.push("M", interpolate(projection(points1), tension), L, interpolateReverse(projection(points0.reverse()), tension), "Z"); + } + var segments = [], points0 = [], points1 = [], i = -1, n = data.length, d, fx0 = d3_functor(x0), fy0 = d3_functor(y0), fx1 = x0 === x1 ? function() { + return x; + } : d3_functor(x1), fy1 = y0 === y1 ? function() { + return y; + } : d3_functor(y1), x, y; + while (++i < n) { + if (defined.call(this, d = data[i], i)) { + points0.push([ x = +fx0.call(this, d, i), y = +fy0.call(this, d, i) ]); + points1.push([ +fx1.call(this, d, i), +fy1.call(this, d, i) ]); + } else if (points0.length) { + segment(); + points0 = []; + points1 = []; + } + } + if (points0.length) segment(); + return segments.length ? segments.join("") : null; + } + var x0 = d3_svg_lineX, x1 = d3_svg_lineX, y0 = 0, y1 = d3_svg_lineY, defined = d3_true, interpolate = d3_svg_lineLinear, interpolateKey = interpolate.key, interpolateReverse = interpolate, L = "L", tension = .7; + area.x = function(_) { + if (!arguments.length) return x1; + x0 = x1 = _; + return area; + }; + area.x0 = function(_) { + if (!arguments.length) return x0; + x0 = _; + return area; + }; + area.x1 = function(_) { + if (!arguments.length) return x1; + x1 = _; + return area; + }; + area.y = function(_) { + if (!arguments.length) return y1; + y0 = y1 = _; + return area; + }; + area.y0 = function(_) { + if (!arguments.length) return y0; + y0 = _; + return area; + }; + area.y1 = function(_) { + if (!arguments.length) return y1; + y1 = _; + return area; + }; + area.defined = function(_) { + if (!arguments.length) return defined; + defined = _; + return area; + }; + area.interpolate = function(_) { + if (!arguments.length) return interpolateKey; + if (typeof _ === "function") interpolateKey = interpolate = _; else interpolateKey = (interpolate = d3_svg_lineInterpolators.get(_) || d3_svg_lineLinear).key; + interpolateReverse = interpolate.reverse || interpolate; + L = interpolate.closed ? "M" : "L"; + return area; + }; + area.tension = function(_) { + if (!arguments.length) return tension; + tension = _; + return area; + }; + return area; + } + function d3_svg_chordSource(d) { + return d.source; + } + function d3_svg_chordTarget(d) { + return d.target; + } + function d3_svg_chordRadius(d) { + return d.radius; + } + function d3_svg_chordStartAngle(d) { + return d.startAngle; + } + function d3_svg_chordEndAngle(d) { + return d.endAngle; + } + function d3_svg_diagonalProjection(d) { + return [ d.x, d.y ]; + } + function d3_svg_diagonalRadialProjection(projection) { + return function() { + var d = projection.apply(this, arguments), r = d[0], a = d[1] + d3_svg_arcOffset; + return [ r * Math.cos(a), r * Math.sin(a) ]; + }; + } + function d3_svg_symbolSize() { + return 64; + } + function d3_svg_symbolType() { + return "circle"; + } + function d3_svg_symbolCircle(size) { + var r = Math.sqrt(size / Math.PI); + return "M0," + r + "A" + r + "," + r + " 0 1,1 0," + -r + "A" + r + "," + r + " 0 1,1 0," + r + "Z"; + } + function d3_svg_axisX(selection, x) { + selection.attr("transform", function(d) { + return "translate(" + x(d) + ",0)"; + }); + } + function d3_svg_axisY(selection, y) { + selection.attr("transform", function(d) { + return "translate(0," + y(d) + ")"; + }); + } + function d3_svg_axisSubdivide(scale, ticks, m) { + subticks = []; + if (m && ticks.length > 1) { + var extent = d3_scaleExtent(scale.domain()), subticks, i = -1, n = ticks.length, d = (ticks[1] - ticks[0]) / ++m, j, v; + while (++i < n) { + for (j = m; --j > 0; ) { + if ((v = +ticks[i] - j * d) >= extent[0]) { + subticks.push(v); + } + } + } + for (--i, j = 0; ++j < m && (v = +ticks[i] + j * d) < extent[1]; ) { + subticks.push(v); + } + } + return subticks; + } + function d3_behavior_zoomDelta() { + if (!d3_behavior_zoomDiv) { + d3_behavior_zoomDiv = d3.select("body").append("div").style("visibility", "hidden").style("top", 0).style("height", 0).style("width", 0).style("overflow-y", "scroll").append("div").style("height", "2000px").node().parentNode; + } + var e = d3.event, delta; + try { + d3_behavior_zoomDiv.scrollTop = 1e3; + d3_behavior_zoomDiv.dispatchEvent(e); + delta = 1e3 - d3_behavior_zoomDiv.scrollTop; + } catch (error) { + delta = e.wheelDelta || -e.detail * 5; + } + return delta; + } + function d3_layout_bundlePath(link) { + var start = link.source, end = link.target, lca = d3_layout_bundleLeastCommonAncestor(start, end), points = [ start ]; + while (start !== lca) { + start = start.parent; + points.push(start); + } + var k = points.length; + while (end !== lca) { + points.splice(k, 0, end); + end = end.parent; + } + return points; + } + function d3_layout_bundleAncestors(node) { + var ancestors = [], parent = node.parent; + while (parent != null) { + ancestors.push(node); + node = parent; + parent = parent.parent; + } + ancestors.push(node); + return ancestors; + } + function d3_layout_bundleLeastCommonAncestor(a, b) { + if (a === b) return a; + var aNodes = d3_layout_bundleAncestors(a), bNodes = d3_layout_bundleAncestors(b), aNode = aNodes.pop(), bNode = bNodes.pop(), sharedNode = null; + while (aNode === bNode) { + sharedNode = aNode; + aNode = aNodes.pop(); + bNode = bNodes.pop(); + } + return sharedNode; + } + function d3_layout_forceDragstart(d) { + d.fixed |= 2; + } + function d3_layout_forceDragend(d) { + d.fixed &= 1; + } + function d3_layout_forceMouseover(d) { + d.fixed |= 4; + } + function d3_layout_forceMouseout(d) { + d.fixed &= 3; + } + function d3_layout_forceAccumulate(quad, alpha, charges) { + var cx = 0, cy = 0; + quad.charge = 0; + if (!quad.leaf) { + var nodes = quad.nodes, n = nodes.length, i = -1, c; + while (++i < n) { + c = nodes[i]; + if (c == null) continue; + d3_layout_forceAccumulate(c, alpha, charges); + quad.charge += c.charge; + cx += c.charge * c.cx; + cy += c.charge * c.cy; + } + } + if (quad.point) { + if (!quad.leaf) { + quad.point.x += Math.random() - .5; + quad.point.y += Math.random() - .5; + } + var k = alpha * charges[quad.point.index]; + quad.charge += quad.pointCharge = k; + cx += k * quad.point.x; + cy += k * quad.point.y; + } + quad.cx = cx / quad.charge; + quad.cy = cy / quad.charge; + } + function d3_layout_forceLinkDistance(link) { + return 20; + } + function d3_layout_forceLinkStrength(link) { + return 1; + } + function d3_layout_stackX(d) { + return d.x; + } + function d3_layout_stackY(d) { + return d.y; + } + function d3_layout_stackOut(d, y0, y) { + d.y0 = y0; + d.y = y; + } + function d3_layout_stackOrderDefault(data) { + return d3.range(data.length); + } + function d3_layout_stackOffsetZero(data) { + var j = -1, m = data[0].length, y0 = []; + while (++j < m) y0[j] = 0; + return y0; + } + function d3_layout_stackMaxIndex(array) { + var i = 1, j = 0, v = array[0][1], k, n = array.length; + for (; i < n; ++i) { + if ((k = array[i][1]) > v) { + j = i; + v = k; + } + } + return j; + } + function d3_layout_stackReduceSum(d) { + return d.reduce(d3_layout_stackSum, 0); + } + function d3_layout_stackSum(p, d) { + return p + d[1]; + } + function d3_layout_histogramBinSturges(range, values) { + return d3_layout_histogramBinFixed(range, Math.ceil(Math.log(values.length) / Math.LN2 + 1)); + } + function d3_layout_histogramBinFixed(range, n) { + var x = -1, b = +range[0], m = (range[1] - b) / n, f = []; + while (++x <= n) f[x] = m * x + b; + return f; + } + function d3_layout_histogramRange(values) { + return [ d3.min(values), d3.max(values) ]; + } + function d3_layout_hierarchyRebind(object, hierarchy) { + d3.rebind(object, hierarchy, "sort", "children", "value"); + object.links = d3_layout_hierarchyLinks; + object.nodes = function(d) { + d3_layout_hierarchyInline = true; + return (object.nodes = object)(d); + }; + return object; + } + function d3_layout_hierarchyChildren(d) { + return d.children; + } + function d3_layout_hierarchyValue(d) { + return d.value; + } + function d3_layout_hierarchySort(a, b) { + return b.value - a.value; + } + function d3_layout_hierarchyLinks(nodes) { + return d3.merge(nodes.map(function(parent) { + return (parent.children || []).map(function(child) { + return { + source: parent, + target: child + }; + }); + })); + } + function d3_layout_packSort(a, b) { + return a.value - b.value; + } + function d3_layout_packInsert(a, b) { + var c = a._pack_next; + a._pack_next = b; + b._pack_prev = a; + b._pack_next = c; + c._pack_prev = b; + } + function d3_layout_packSplice(a, b) { + a._pack_next = b; + b._pack_prev = a; + } + function d3_layout_packIntersects(a, b) { + var dx = b.x - a.x, dy = b.y - a.y, dr = a.r + b.r; + return dr * dr - dx * dx - dy * dy > .001; + } + function d3_layout_packSiblings(node) { + function bound(node) { + xMin = Math.min(node.x - node.r, xMin); + xMax = Math.max(node.x + node.r, xMax); + yMin = Math.min(node.y - node.r, yMin); + yMax = Math.max(node.y + node.r, yMax); + } + if (!(nodes = node.children) || !(n = nodes.length)) return; + var nodes, xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity, a, b, c, i, j, k, n; + nodes.forEach(d3_layout_packLink); + a = nodes[0]; + a.x = -a.r; + a.y = 0; + bound(a); + if (n > 1) { + b = nodes[1]; + b.x = b.r; + b.y = 0; + bound(b); + if (n > 2) { + c = nodes[2]; + d3_layout_packPlace(a, b, c); + bound(c); + d3_layout_packInsert(a, c); + a._pack_prev = c; + d3_layout_packInsert(c, b); + b = a._pack_next; + for (i = 3; i < n; i++) { + d3_layout_packPlace(a, b, c = nodes[i]); + var isect = 0, s1 = 1, s2 = 1; + for (j = b._pack_next; j !== b; j = j._pack_next, s1++) { + if (d3_layout_packIntersects(j, c)) { + isect = 1; + break; + } + } + if (isect == 1) { + for (k = a._pack_prev; k !== j._pack_prev; k = k._pack_prev, s2++) { + if (d3_layout_packIntersects(k, c)) { + break; + } + } + } + if (isect) { + if (s1 < s2 || s1 == s2 && b.r < a.r) d3_layout_packSplice(a, b = j); else d3_layout_packSplice(a = k, b); + i--; + } else { + d3_layout_packInsert(a, c); + b = c; + bound(c); + } + } + } + } + var cx = (xMin + xMax) / 2, cy = (yMin + yMax) / 2, cr = 0; + for (i = 0; i < n; i++) { + c = nodes[i]; + c.x -= cx; + c.y -= cy; + cr = Math.max(cr, c.r + Math.sqrt(c.x * c.x + c.y * c.y)); + } + node.r = cr; + nodes.forEach(d3_layout_packUnlink); + } + function d3_layout_packLink(node) { + node._pack_next = node._pack_prev = node; + } + function d3_layout_packUnlink(node) { + delete node._pack_next; + delete node._pack_prev; + } + function d3_layout_packTransform(node, x, y, k) { + var children = node.children; + node.x = x += k * node.x; + node.y = y += k * node.y; + node.r *= k; + if (children) { + var i = -1, n = children.length; + while (++i < n) d3_layout_packTransform(children[i], x, y, k); + } + } + function d3_layout_packPlace(a, b, c) { + var db = a.r + c.r, dx = b.x - a.x, dy = b.y - a.y; + if (db && (dx || dy)) { + var da = b.r + c.r, dc = dx * dx + dy * dy; + da *= da; + db *= db; + var x = .5 + (db - da) / (2 * dc), y = Math.sqrt(Math.max(0, 2 * da * (db + dc) - (db -= dc) * db - da * da)) / (2 * dc); + c.x = a.x + x * dx + y * dy; + c.y = a.y + x * dy - y * dx; + } else { + c.x = a.x + db; + c.y = a.y; + } + } + function d3_layout_clusterY(children) { + return 1 + d3.max(children, function(child) { + return child.y; + }); + } + function d3_layout_clusterX(children) { + return children.reduce(function(x, child) { + return x + child.x; + }, 0) / children.length; + } + function d3_layout_clusterLeft(node) { + var children = node.children; + return children && children.length ? d3_layout_clusterLeft(children[0]) : node; + } + function d3_layout_clusterRight(node) { + var children = node.children, n; + return children && (n = children.length) ? d3_layout_clusterRight(children[n - 1]) : node; + } + function d3_layout_treeSeparation(a, b) { + return a.parent == b.parent ? 1 : 2; + } + function d3_layout_treeLeft(node) { + var children = node.children; + return children && children.length ? children[0] : node._tree.thread; + } + function d3_layout_treeRight(node) { + var children = node.children, n; + return children && (n = children.length) ? children[n - 1] : node._tree.thread; + } + function d3_layout_treeSearch(node, compare) { + var children = node.children; + if (children && (n = children.length)) { + var child, n, i = -1; + while (++i < n) { + if (compare(child = d3_layout_treeSearch(children[i], compare), node) > 0) { + node = child; + } + } + } + return node; + } + function d3_layout_treeRightmost(a, b) { + return a.x - b.x; + } + function d3_layout_treeLeftmost(a, b) { + return b.x - a.x; + } + function d3_layout_treeDeepest(a, b) { + return a.depth - b.depth; + } + function d3_layout_treeVisitAfter(node, callback) { + function visit(node, previousSibling) { + var children = node.children; + if (children && (n = children.length)) { + var child, previousChild = null, i = -1, n; + while (++i < n) { + child = children[i]; + visit(child, previousChild); + previousChild = child; + } + } + callback(node, previousSibling); + } + visit(node, null); + } + function d3_layout_treeShift(node) { + var shift = 0, change = 0, children = node.children, i = children.length, child; + while (--i >= 0) { + child = children[i]._tree; + child.prelim += shift; + child.mod += shift; + shift += child.shift + (change += child.change); + } + } + function d3_layout_treeMove(ancestor, node, shift) { + ancestor = ancestor._tree; + node = node._tree; + var change = shift / (node.number - ancestor.number); + ancestor.change += change; + node.change -= change; + node.shift += shift; + node.prelim += shift; + node.mod += shift; + } + function d3_layout_treeAncestor(vim, node, ancestor) { + return vim._tree.ancestor.parent == node.parent ? vim._tree.ancestor : ancestor; + } + function d3_layout_treemapPadNull(node) { + return { + x: node.x, + y: node.y, + dx: node.dx, + dy: node.dy + }; + } + function d3_layout_treemapPad(node, padding) { + var x = node.x + padding[3], y = node.y + padding[0], dx = node.dx - padding[1] - padding[3], dy = node.dy - padding[0] - padding[2]; + if (dx < 0) { + x += dx / 2; + dx = 0; + } + if (dy < 0) { + y += dy / 2; + dy = 0; + } + return { + x: x, + y: y, + dx: dx, + dy: dy + }; + } + function d3_dsv(delimiter, mimeType) { + function dsv(url, callback) { + d3.text(url, mimeType, function(text) { + callback(text && dsv.parse(text)); + }); + } + function formatRow(row) { + return row.map(formatValue).join(delimiter); + } + function formatValue(text) { + return reFormat.test(text) ? '"' + text.replace(/\"/g, '""') + '"' : text; + } + var reParse = new RegExp("\r\n|[" + delimiter + "\r\n]", "g"), reFormat = new RegExp('["' + delimiter + "\n]"), delimiterCode = delimiter.charCodeAt(0); + dsv.parse = function(text) { + var header; + return dsv.parseRows(text, function(row, i) { + if (i) { + var o = {}, j = -1, m = header.length; + while (++j < m) o[header[j]] = row[j]; + return o; + } else { + header = row; + return null; + } + }); + }; + dsv.parseRows = function(text, f) { + function token() { + if (reParse.lastIndex >= text.length) return EOF; + if (eol) { + eol = false; + return EOL; + } + var j = reParse.lastIndex; + if (text.charCodeAt(j) === 34) { + var i = j; + while (i++ < text.length) { + if (text.charCodeAt(i) === 34) { + if (text.charCodeAt(i + 1) !== 34) break; + i++; + } + } + reParse.lastIndex = i + 2; + var c = text.charCodeAt(i + 1); + if (c === 13) { + eol = true; + if (text.charCodeAt(i + 2) === 10) reParse.lastIndex++; + } else if (c === 10) { + eol = true; + } + return text.substring(j + 1, i).replace(/""/g, '"'); + } + var m = reParse.exec(text); + if (m) { + eol = m[0].charCodeAt(0) !== delimiterCode; + return text.substring(j, m.index); + } + reParse.lastIndex = text.length; + return text.substring(j); + } + var EOL = {}, EOF = {}, rows = [], n = 0, t, eol; + reParse.lastIndex = 0; + while ((t = token()) !== EOF) { + var a = []; + while (t !== EOL && t !== EOF) { + a.push(t); + t = token(); + } + if (f && !(a = f(a, n++))) continue; + rows.push(a); + } + return rows; + }; + dsv.format = function(rows) { + return rows.map(formatRow).join("\n"); + }; + return dsv; + } + function d3_geo_type(types, defaultValue) { + return function(object) { + return object && types.hasOwnProperty(object.type) ? types[object.type](object) : defaultValue; + }; + } + function d3_path_circle(radius) { + return "m0," + radius + "a" + radius + "," + radius + " 0 1,1 0," + -2 * radius + "a" + radius + "," + radius + " 0 1,1 0," + +2 * radius + "z"; + } + function d3_geo_bounds(o, f) { + if (d3_geo_boundsTypes.hasOwnProperty(o.type)) d3_geo_boundsTypes[o.type](o, f); + } + function d3_geo_boundsFeature(o, f) { + d3_geo_bounds(o.geometry, f); + } + function d3_geo_boundsFeatureCollection(o, f) { + for (var a = o.features, i = 0, n = a.length; i < n; i++) { + d3_geo_bounds(a[i].geometry, f); + } + } + function d3_geo_boundsGeometryCollection(o, f) { + for (var a = o.geometries, i = 0, n = a.length; i < n; i++) { + d3_geo_bounds(a[i], f); + } + } + function d3_geo_boundsLineString(o, f) { + for (var a = o.coordinates, i = 0, n = a.length; i < n; i++) { + f.apply(null, a[i]); + } + } + function d3_geo_boundsMultiLineString(o, f) { + for (var a = o.coordinates, i = 0, n = a.length; i < n; i++) { + for (var b = a[i], j = 0, m = b.length; j < m; j++) { + f.apply(null, b[j]); + } + } + } + function d3_geo_boundsMultiPolygon(o, f) { + for (var a = o.coordinates, i = 0, n = a.length; i < n; i++) { + for (var b = a[i][0], j = 0, m = b.length; j < m; j++) { + f.apply(null, b[j]); + } + } + } + function d3_geo_boundsPoint(o, f) { + f.apply(null, o.coordinates); + } + function d3_geo_boundsPolygon(o, f) { + for (var a = o.coordinates[0], i = 0, n = a.length; i < n; i++) { + f.apply(null, a[i]); + } + } + function d3_geo_greatArcSource(d) { + return d.source; + } + function d3_geo_greatArcTarget(d) { + return d.target; + } + function d3_geo_greatArcInterpolator() { + function interpolate(t) { + var B = Math.sin(t *= d) * k, A = Math.sin(d - t) * k, x = A * kx0 + B * kx1, y = A * ky0 + B * ky1, z = A * sy0 + B * sy1; + return [ Math.atan2(y, x) / d3_geo_radians, Math.atan2(z, Math.sqrt(x * x + y * y)) / d3_geo_radians ]; + } + var x0, y0, cy0, sy0, kx0, ky0, x1, y1, cy1, sy1, kx1, ky1, d, k; + interpolate.distance = function() { + if (d == null) k = 1 / Math.sin(d = Math.acos(Math.max(-1, Math.min(1, sy0 * sy1 + cy0 * cy1 * Math.cos(x1 - x0))))); + return d; + }; + interpolate.source = function(_) { + var cx0 = Math.cos(x0 = _[0] * d3_geo_radians), sx0 = Math.sin(x0); + cy0 = Math.cos(y0 = _[1] * d3_geo_radians); + sy0 = Math.sin(y0); + kx0 = cy0 * cx0; + ky0 = cy0 * sx0; + d = null; + return interpolate; + }; + interpolate.target = function(_) { + var cx1 = Math.cos(x1 = _[0] * d3_geo_radians), sx1 = Math.sin(x1); + cy1 = Math.cos(y1 = _[1] * d3_geo_radians); + sy1 = Math.sin(y1); + kx1 = cy1 * cx1; + ky1 = cy1 * sx1; + d = null; + return interpolate; + }; + return interpolate; + } + function d3_geo_greatArcInterpolate(a, b) { + var i = d3_geo_greatArcInterpolator().source(a).target(b); + i.distance(); + return i; + } + function d3_geom_contourStart(grid) { + var x = 0, y = 0; + while (true) { + if (grid(x, y)) { + return [ x, y ]; + } + if (x === 0) { + x = y + 1; + y = 0; + } else { + x = x - 1; + y = y + 1; + } + } + } + function d3_geom_hullCCW(i1, i2, i3, v) { + var t, a, b, c, d, e, f; + t = v[i1]; + a = t[0]; + b = t[1]; + t = v[i2]; + c = t[0]; + d = t[1]; + t = v[i3]; + e = t[0]; + f = t[1]; + return (f - b) * (c - a) - (d - b) * (e - a) > 0; + } + function d3_geom_polygonInside(p, a, b) { + return (b[0] - a[0]) * (p[1] - a[1]) < (b[1] - a[1]) * (p[0] - a[0]); + } + function d3_geom_polygonIntersect(c, d, a, b) { + var x1 = c[0], x2 = d[0], x3 = a[0], x4 = b[0], y1 = c[1], y2 = d[1], y3 = a[1], y4 = b[1], x13 = x1 - x3, x21 = x2 - x1, x43 = x4 - x3, y13 = y1 - y3, y21 = y2 - y1, y43 = y4 - y3, ua = (x43 * y13 - y43 * x13) / (y43 * x21 - x43 * y21); + return [ x1 + ua * x21, y1 + ua * y21 ]; + } + function d3_voronoi_tessellate(vertices, callback) { + var Sites = { + list: vertices.map(function(v, i) { + return { + index: i, + x: v[0], + y: v[1] + }; + }).sort(function(a, b) { + return a.y < b.y ? -1 : a.y > b.y ? 1 : a.x < b.x ? -1 : a.x > b.x ? 1 : 0; + }), + bottomSite: null + }; + var EdgeList = { + list: [], + leftEnd: null, + rightEnd: null, + init: function() { + EdgeList.leftEnd = EdgeList.createHalfEdge(null, "l"); + EdgeList.rightEnd = EdgeList.createHalfEdge(null, "l"); + EdgeList.leftEnd.r = EdgeList.rightEnd; + EdgeList.rightEnd.l = EdgeList.leftEnd; + EdgeList.list.unshift(EdgeList.leftEnd, EdgeList.rightEnd); + }, + createHalfEdge: function(edge, side) { + return { + edge: edge, + side: side, + vertex: null, + l: null, + r: null + }; + }, + insert: function(lb, he) { + he.l = lb; + he.r = lb.r; + lb.r.l = he; + lb.r = he; + }, + leftBound: function(p) { + var he = EdgeList.leftEnd; + do { + he = he.r; + } while (he != EdgeList.rightEnd && Geom.rightOf(he, p)); + he = he.l; + return he; + }, + del: function(he) { + he.l.r = he.r; + he.r.l = he.l; + he.edge = null; + }, + right: function(he) { + return he.r; + }, + left: function(he) { + return he.l; + }, + leftRegion: function(he) { + return he.edge == null ? Sites.bottomSite : he.edge.region[he.side]; + }, + rightRegion: function(he) { + return he.edge == null ? Sites.bottomSite : he.edge.region[d3_voronoi_opposite[he.side]]; + } + }; + var Geom = { + bisect: function(s1, s2) { + var newEdge = { + region: { + l: s1, + r: s2 + }, + ep: { + l: null, + r: null + } + }; + var dx = s2.x - s1.x, dy = s2.y - s1.y, adx = dx > 0 ? dx : -dx, ady = dy > 0 ? dy : -dy; + newEdge.c = s1.x * dx + s1.y * dy + (dx * dx + dy * dy) * .5; + if (adx > ady) { + newEdge.a = 1; + newEdge.b = dy / dx; + newEdge.c /= dx; + } else { + newEdge.b = 1; + newEdge.a = dx / dy; + newEdge.c /= dy; + } + return newEdge; + }, + intersect: function(el1, el2) { + var e1 = el1.edge, e2 = el2.edge; + if (!e1 || !e2 || e1.region.r == e2.region.r) { + return null; + } + var d = e1.a * e2.b - e1.b * e2.a; + if (Math.abs(d) < 1e-10) { + return null; + } + var xint = (e1.c * e2.b - e2.c * e1.b) / d, yint = (e2.c * e1.a - e1.c * e2.a) / d, e1r = e1.region.r, e2r = e2.region.r, el, e; + if (e1r.y < e2r.y || e1r.y == e2r.y && e1r.x < e2r.x) { + el = el1; + e = e1; + } else { + el = el2; + e = e2; + } + var rightOfSite = xint >= e.region.r.x; + if (rightOfSite && el.side === "l" || !rightOfSite && el.side === "r") { + return null; + } + return { + x: xint, + y: yint + }; + }, + rightOf: function(he, p) { + var e = he.edge, topsite = e.region.r, rightOfSite = p.x > topsite.x; + if (rightOfSite && he.side === "l") { + return 1; + } + if (!rightOfSite && he.side === "r") { + return 0; + } + if (e.a === 1) { + var dyp = p.y - topsite.y, dxp = p.x - topsite.x, fast = 0, above = 0; + if (!rightOfSite && e.b < 0 || rightOfSite && e.b >= 0) { + above = fast = dyp >= e.b * dxp; + } else { + above = p.x + p.y * e.b > e.c; + if (e.b < 0) { + above = !above; + } + if (!above) { + fast = 1; + } + } + if (!fast) { + var dxs = topsite.x - e.region.l.x; + above = e.b * (dxp * dxp - dyp * dyp) < dxs * dyp * (1 + 2 * dxp / dxs + e.b * e.b); + if (e.b < 0) { + above = !above; + } + } + } else { + var yl = e.c - e.a * p.x, t1 = p.y - yl, t2 = p.x - topsite.x, t3 = yl - topsite.y; + above = t1 * t1 > t2 * t2 + t3 * t3; + } + return he.side === "l" ? above : !above; + }, + endPoint: function(edge, side, site) { + edge.ep[side] = site; + if (!edge.ep[d3_voronoi_opposite[side]]) return; + callback(edge); + }, + distance: function(s, t) { + var dx = s.x - t.x, dy = s.y - t.y; + return Math.sqrt(dx * dx + dy * dy); + } + }; + var EventQueue = { + list: [], + insert: function(he, site, offset) { + he.vertex = site; + he.ystar = site.y + offset; + for (var i = 0, list = EventQueue.list, l = list.length; i < l; i++) { + var next = list[i]; + if (he.ystar > next.ystar || he.ystar == next.ystar && site.x > next.vertex.x) { + continue; + } else { + break; + } + } + list.splice(i, 0, he); + }, + del: function(he) { + for (var i = 0, ls = EventQueue.list, l = ls.length; i < l && ls[i] != he; ++i) {} + ls.splice(i, 1); + }, + empty: function() { + return EventQueue.list.length === 0; + }, + nextEvent: function(he) { + for (var i = 0, ls = EventQueue.list, l = ls.length; i < l; ++i) { + if (ls[i] == he) return ls[i + 1]; + } + return null; + }, + min: function() { + var elem = EventQueue.list[0]; + return { + x: elem.vertex.x, + y: elem.ystar + }; + }, + extractMin: function() { + return EventQueue.list.shift(); + } + }; + EdgeList.init(); + Sites.bottomSite = Sites.list.shift(); + var newSite = Sites.list.shift(), newIntStar; + var lbnd, rbnd, llbnd, rrbnd, bisector; + var bot, top, temp, p, v; + var e, pm; + while (true) { + if (!EventQueue.empty()) { + newIntStar = EventQueue.min(); + } + if (newSite && (EventQueue.empty() || newSite.y < newIntStar.y || newSite.y == newIntStar.y && newSite.x < newIntStar.x)) { + lbnd = EdgeList.leftBound(newSite); + rbnd = EdgeList.right(lbnd); + bot = EdgeList.rightRegion(lbnd); + e = Geom.bisect(bot, newSite); + bisector = EdgeList.createHalfEdge(e, "l"); + EdgeList.insert(lbnd, bisector); + p = Geom.intersect(lbnd, bisector); + if (p) { + EventQueue.del(lbnd); + EventQueue.insert(lbnd, p, Geom.distance(p, newSite)); + } + lbnd = bisector; + bisector = EdgeList.createHalfEdge(e, "r"); + EdgeList.insert(lbnd, bisector); + p = Geom.intersect(bisector, rbnd); + if (p) { + EventQueue.insert(bisector, p, Geom.distance(p, newSite)); + } + newSite = Sites.list.shift(); + } else if (!EventQueue.empty()) { + lbnd = EventQueue.extractMin(); + llbnd = EdgeList.left(lbnd); + rbnd = EdgeList.right(lbnd); + rrbnd = EdgeList.right(rbnd); + bot = EdgeList.leftRegion(lbnd); + top = EdgeList.rightRegion(rbnd); + v = lbnd.vertex; + Geom.endPoint(lbnd.edge, lbnd.side, v); + Geom.endPoint(rbnd.edge, rbnd.side, v); + EdgeList.del(lbnd); + EventQueue.del(rbnd); + EdgeList.del(rbnd); + pm = "l"; + if (bot.y > top.y) { + temp = bot; + bot = top; + top = temp; + pm = "r"; + } + e = Geom.bisect(bot, top); + bisector = EdgeList.createHalfEdge(e, pm); + EdgeList.insert(llbnd, bisector); + Geom.endPoint(e, d3_voronoi_opposite[pm], v); + p = Geom.intersect(llbnd, bisector); + if (p) { + EventQueue.del(llbnd); + EventQueue.insert(llbnd, p, Geom.distance(p, bot)); + } + p = Geom.intersect(bisector, rrbnd); + if (p) { + EventQueue.insert(bisector, p, Geom.distance(p, bot)); + } + } else { + break; + } + } + for (lbnd = EdgeList.right(EdgeList.leftEnd); lbnd != EdgeList.rightEnd; lbnd = EdgeList.right(lbnd)) { + callback(lbnd.edge); + } + } + function d3_geom_quadtreeNode() { + return { + leaf: true, + nodes: [], + point: null + }; + } + function d3_geom_quadtreeVisit(f, node, x1, y1, x2, y2) { + if (!f(node, x1, y1, x2, y2)) { + var sx = (x1 + x2) * .5, sy = (y1 + y2) * .5, children = node.nodes; + if (children[0]) d3_geom_quadtreeVisit(f, children[0], x1, y1, sx, sy); + if (children[1]) d3_geom_quadtreeVisit(f, children[1], sx, y1, x2, sy); + if (children[2]) d3_geom_quadtreeVisit(f, children[2], x1, sy, sx, y2); + if (children[3]) d3_geom_quadtreeVisit(f, children[3], sx, sy, x2, y2); + } + } + function d3_geom_quadtreePoint(p) { + return { + x: p[0], + y: p[1] + }; + } + function d3_time_utc() { + this._ = new Date(arguments.length > 1 ? Date.UTC.apply(this, arguments) : arguments[0]); + } + function d3_time_formatAbbreviate(name) { + return name.substring(0, 3); + } + function d3_time_parse(date, template, string, j) { + var c, p, i = 0, n = template.length, m = string.length; + while (i < n) { + if (j >= m) return -1; + c = template.charCodeAt(i++); + if (c == 37) { + p = d3_time_parsers[template.charAt(i++)]; + if (!p || (j = p(date, string, j)) < 0) return -1; + } else if (c != string.charCodeAt(j++)) { + return -1; + } + } + return j; + } + function d3_time_formatRe(names) { + return new RegExp("^(?:" + names.map(d3.requote).join("|") + ")", "i"); + } + function d3_time_formatLookup(names) { + var map = new d3_Map, i = -1, n = names.length; + while (++i < n) map.set(names[i].toLowerCase(), i); + return map; + } + function d3_time_parseWeekdayAbbrev(date, string, i) { + d3_time_dayAbbrevRe.lastIndex = 0; + var n = d3_time_dayAbbrevRe.exec(string.substring(i)); + return n ? i += n[0].length : -1; + } + function d3_time_parseWeekday(date, string, i) { + d3_time_dayRe.lastIndex = 0; + var n = d3_time_dayRe.exec(string.substring(i)); + return n ? i += n[0].length : -1; + } + function d3_time_parseMonthAbbrev(date, string, i) { + d3_time_monthAbbrevRe.lastIndex = 0; + var n = d3_time_monthAbbrevRe.exec(string.substring(i)); + return n ? (date.m = d3_time_monthAbbrevLookup.get(n[0].toLowerCase()), i += n[0].length) : -1; + } + function d3_time_parseMonth(date, string, i) { + d3_time_monthRe.lastIndex = 0; + var n = d3_time_monthRe.exec(string.substring(i)); + return n ? (date.m = d3_time_monthLookup.get(n[0].toLowerCase()), i += n[0].length) : -1; + } + function d3_time_parseLocaleFull(date, string, i) { + return d3_time_parse(date, d3_time_formats.c.toString(), string, i); + } + function d3_time_parseLocaleDate(date, string, i) { + return d3_time_parse(date, d3_time_formats.x.toString(), string, i); + } + function d3_time_parseLocaleTime(date, string, i) { + return d3_time_parse(date, d3_time_formats.X.toString(), string, i); + } + function d3_time_parseFullYear(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 4)); + return n ? (date.y = +n[0], i += n[0].length) : -1; + } + function d3_time_parseYear(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 2)); + return n ? (date.y = d3_time_expandYear(+n[0]), i += n[0].length) : -1; + } + function d3_time_expandYear(d) { + return d + (d > 68 ? 1900 : 2e3); + } + function d3_time_parseMonthNumber(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 2)); + return n ? (date.m = n[0] - 1, i += n[0].length) : -1; + } + function d3_time_parseDay(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 2)); + return n ? (date.d = +n[0], i += n[0].length) : -1; + } + function d3_time_parseHour24(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 2)); + return n ? (date.H = +n[0], i += n[0].length) : -1; + } + function d3_time_parseMinutes(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 2)); + return n ? (date.M = +n[0], i += n[0].length) : -1; + } + function d3_time_parseSeconds(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 2)); + return n ? (date.S = +n[0], i += n[0].length) : -1; + } + function d3_time_parseMilliseconds(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.substring(i, i + 3)); + return n ? (date.L = +n[0], i += n[0].length) : -1; + } + function d3_time_parseAmPm(date, string, i) { + var n = d3_time_amPmLookup.get(string.substring(i, i += 2).toLowerCase()); + return n == null ? -1 : (date.p = n, i); + } + function d3_time_zone(d) { + var z = d.getTimezoneOffset(), zs = z > 0 ? "-" : "+", zh = ~~(Math.abs(z) / 60), zm = Math.abs(z) % 60; + return zs + d3_time_zfill2(zh) + d3_time_zfill2(zm); + } + function d3_time_formatIsoNative(date) { + return date.toISOString(); + } + function d3_time_interval(local, step, number) { + function round(date) { + var d0 = local(date), d1 = offset(d0, 1); + return date - d0 < d1 - date ? d0 : d1; + } + function ceil(date) { + step(date = local(new d3_time(date - 1)), 1); + return date; + } + function offset(date, k) { + step(date = new d3_time(+date), k); + return date; + } + function range(t0, t1, dt) { + var time = ceil(t0), times = []; + if (dt > 1) { + while (time < t1) { + if (!(number(time) % dt)) times.push(new Date(+time)); + step(time, 1); + } + } else { + while (time < t1) times.push(new Date(+time)), step(time, 1); + } + return times; + } + function range_utc(t0, t1, dt) { + try { + d3_time = d3_time_utc; + var utc = new d3_time_utc; + utc._ = t0; + return range(utc, t1, dt); + } finally { + d3_time = Date; + } + } + local.floor = local; + local.round = round; + local.ceil = ceil; + local.offset = offset; + local.range = range; + var utc = local.utc = d3_time_interval_utc(local); + utc.floor = utc; + utc.round = d3_time_interval_utc(round); + utc.ceil = d3_time_interval_utc(ceil); + utc.offset = d3_time_interval_utc(offset); + utc.range = range_utc; + return local; + } + function d3_time_interval_utc(method) { + return function(date, k) { + try { + d3_time = d3_time_utc; + var utc = new d3_time_utc; + utc._ = date; + return method(utc, k)._; + } finally { + d3_time = Date; + } + }; + } + function d3_time_scale(linear, methods, format) { + function scale(x) { + return linear(x); + } + scale.invert = function(x) { + return d3_time_scaleDate(linear.invert(x)); + }; + scale.domain = function(x) { + if (!arguments.length) return linear.domain().map(d3_time_scaleDate); + linear.domain(x); + return scale; + }; + scale.nice = function(m) { + return scale.domain(d3_scale_nice(scale.domain(), function() { + return m; + })); + }; + scale.ticks = function(m, k) { + var extent = d3_time_scaleExtent(scale.domain()); + if (typeof m !== "function") { + var span = extent[1] - extent[0], target = span / m, i = d3.bisect(d3_time_scaleSteps, target); + if (i == d3_time_scaleSteps.length) return methods.year(extent, m); + if (!i) return linear.ticks(m).map(d3_time_scaleDate); + if (Math.log(target / d3_time_scaleSteps[i - 1]) < Math.log(d3_time_scaleSteps[i] / target)) --i; + m = methods[i]; + k = m[1]; + m = m[0].range; + } + return m(extent[0], new Date(+extent[1] + 1), k); + }; + scale.tickFormat = function() { + return format; + }; + scale.copy = function() { + return d3_time_scale(linear.copy(), methods, format); + }; + return d3.rebind(scale, linear, "range", "rangeRound", "interpolate", "clamp"); + } + function d3_time_scaleExtent(domain) { + var start = domain[0], stop = domain[domain.length - 1]; + return start < stop ? [ start, stop ] : [ stop, start ]; + } + function d3_time_scaleDate(t) { + return new Date(t); + } + function d3_time_scaleFormat(formats) { + return function(date) { + var i = formats.length - 1, f = formats[i]; + while (!f[1](date)) f = formats[--i]; + return f[0](date); + }; + } + function d3_time_scaleSetYear(y) { + var d = new Date(y, 0, 1); + d.setFullYear(y); + return d; + } + function d3_time_scaleGetYear(d) { + var y = d.getFullYear(), d0 = d3_time_scaleSetYear(y), d1 = d3_time_scaleSetYear(y + 1); + return y + (d - d0) / (d1 - d0); + } + function d3_time_scaleUTCSetYear(y) { + var d = new Date(Date.UTC(y, 0, 1)); + d.setUTCFullYear(y); + return d; + } + function d3_time_scaleUTCGetYear(d) { + var y = d.getUTCFullYear(), d0 = d3_time_scaleUTCSetYear(y), d1 = d3_time_scaleUTCSetYear(y + 1); + return y + (d - d0) / (d1 - d0); + } + if (!Date.now) Date.now = function() { + return +(new Date); + }; + try { + document.createElement("div").style.setProperty("opacity", 0, ""); + } catch (error) { + var d3_style_prototype = CSSStyleDeclaration.prototype, d3_style_setProperty = d3_style_prototype.setProperty; + d3_style_prototype.setProperty = function(name, value, priority) { + d3_style_setProperty.call(this, name, value + "", priority); + }; + } + d3 = { + version: "2.10.3" + }; + var d3_array = d3_arraySlice; + try { + d3_array(document.documentElement.childNodes)[0].nodeType; + } catch (e) { + d3_array = d3_arrayCopy; + } + var d3_arraySubclass = [].__proto__ ? function(array, prototype) { + array.__proto__ = prototype; + } : function(array, prototype) { + for (var property in prototype) array[property] = prototype[property]; + }; + d3.map = function(object) { + var map = new d3_Map; + for (var key in object) map.set(key, object[key]); + return map; + }; + d3_class(d3_Map, { + has: function(key) { + return d3_map_prefix + key in this; + }, + get: function(key) { + return this[d3_map_prefix + key]; + }, + set: function(key, value) { + return this[d3_map_prefix + key] = value; + }, + remove: function(key) { + key = d3_map_prefix + key; + return key in this && delete this[key]; + }, + keys: function() { + var keys = []; + this.forEach(function(key) { + keys.push(key); + }); + return keys; + }, + values: function() { + var values = []; + this.forEach(function(key, value) { + values.push(value); + }); + return values; + }, + entries: function() { + var entries = []; + this.forEach(function(key, value) { + entries.push({ + key: key, + value: value + }); + }); + return entries; + }, + forEach: function(f) { + for (var key in this) { + if (key.charCodeAt(0) === d3_map_prefixCode) { + f.call(this, key.substring(1), this[key]); + } + } + } + }); + var d3_map_prefix = "\0", d3_map_prefixCode = d3_map_prefix.charCodeAt(0); + d3.functor = d3_functor; + d3.rebind = function(target, source) { + var i = 1, n = arguments.length, method; + while (++i < n) target[method = arguments[i]] = d3_rebind(target, source, source[method]); + return target; + }; + d3.ascending = function(a, b) { + return a < b ? -1 : a > b ? 1 : a >= b ? 0 : NaN; + }; + d3.descending = function(a, b) { + return b < a ? -1 : b > a ? 1 : b >= a ? 0 : NaN; + }; + d3.mean = function(array, f) { + var n = array.length, a, m = 0, i = -1, j = 0; + if (arguments.length === 1) { + while (++i < n) if (d3_number(a = array[i])) m += (a - m) / ++j; + } else { + while (++i < n) if (d3_number(a = f.call(array, array[i], i))) m += (a - m) / ++j; + } + return j ? m : undefined; + }; + d3.median = function(array, f) { + if (arguments.length > 1) array = array.map(f); + array = array.filter(d3_number); + return array.length ? d3.quantile(array.sort(d3.ascending), .5) : undefined; + }; + d3.min = function(array, f) { + var i = -1, n = array.length, a, b; + if (arguments.length === 1) { + while (++i < n && ((a = array[i]) == null || a != a)) a = undefined; + while (++i < n) if ((b = array[i]) != null && a > b) a = b; + } else { + while (++i < n && ((a = f.call(array, array[i], i)) == null || a != a)) a = undefined; + while (++i < n) if ((b = f.call(array, array[i], i)) != null && a > b) a = b; + } + return a; + }; + d3.max = function(array, f) { + var i = -1, n = array.length, a, b; + if (arguments.length === 1) { + while (++i < n && ((a = array[i]) == null || a != a)) a = undefined; + while (++i < n) if ((b = array[i]) != null && b > a) a = b; + } else { + while (++i < n && ((a = f.call(array, array[i], i)) == null || a != a)) a = undefined; + while (++i < n) if ((b = f.call(array, array[i], i)) != null && b > a) a = b; + } + return a; + }; + d3.extent = function(array, f) { + var i = -1, n = array.length, a, b, c; + if (arguments.length === 1) { + while (++i < n && ((a = c = array[i]) == null || a != a)) a = c = undefined; + while (++i < n) if ((b = array[i]) != null) { + if (a > b) a = b; + if (c < b) c = b; + } + } else { + while (++i < n && ((a = c = f.call(array, array[i], i)) == null || a != a)) a = undefined; + while (++i < n) if ((b = f.call(array, array[i], i)) != null) { + if (a > b) a = b; + if (c < b) c = b; + } + } + return [ a, c ]; + }; + d3.random = { + normal: function(µ, σ) { + var n = arguments.length; + if (n < 2) σ = 1; + if (n < 1) µ = 0; + return function() { + var x, y, r; + do { + x = Math.random() * 2 - 1; + y = Math.random() * 2 - 1; + r = x * x + y * y; + } while (!r || r > 1); + return µ + σ * x * Math.sqrt(-2 * Math.log(r) / r); + }; + }, + logNormal: function(µ, σ) { + var n = arguments.length; + if (n < 2) σ = 1; + if (n < 1) µ = 0; + var random = d3.random.normal(); + return function() { + return Math.exp(µ + σ * random()); + }; + }, + irwinHall: function(m) { + return function() { + for (var s = 0, j = 0; j < m; j++) s += Math.random(); + return s / m; + }; + } + }; + d3.sum = function(array, f) { + var s = 0, n = array.length, a, i = -1; + if (arguments.length === 1) { + while (++i < n) if (!isNaN(a = +array[i])) s += a; + } else { + while (++i < n) if (!isNaN(a = +f.call(array, array[i], i))) s += a; + } + return s; + }; + d3.quantile = function(values, p) { + var H = (values.length - 1) * p + 1, h = Math.floor(H), v = values[h - 1], e = H - h; + return e ? v + e * (values[h] - v) : v; + }; + d3.transpose = function(matrix) { + return d3.zip.apply(d3, matrix); + }; + d3.zip = function() { + if (!(n = arguments.length)) return []; + for (var i = -1, m = d3.min(arguments, d3_zipLength), zips = new Array(m); ++i < m; ) { + for (var j = -1, n, zip = zips[i] = new Array(n); ++j < n; ) { + zip[j] = arguments[j][i]; + } + } + return zips; + }; + d3.bisector = function(f) { + return { + left: function(a, x, lo, hi) { + if (arguments.length < 3) lo = 0; + if (arguments.length < 4) hi = a.length; + while (lo < hi) { + var mid = lo + hi >>> 1; + if (f.call(a, a[mid], mid) < x) lo = mid + 1; else hi = mid; + } + return lo; + }, + right: function(a, x, lo, hi) { + if (arguments.length < 3) lo = 0; + if (arguments.length < 4) hi = a.length; + while (lo < hi) { + var mid = lo + hi >>> 1; + if (x < f.call(a, a[mid], mid)) hi = mid; else lo = mid + 1; + } + return lo; + } + }; + }; + var d3_bisector = d3.bisector(function(d) { + return d; + }); + d3.bisectLeft = d3_bisector.left; + d3.bisect = d3.bisectRight = d3_bisector.right; + d3.first = function(array, f) { + var i = 0, n = array.length, a = array[0], b; + if (arguments.length === 1) f = d3.ascending; + while (++i < n) { + if (f.call(array, a, b = array[i]) > 0) { + a = b; + } + } + return a; + }; + d3.last = function(array, f) { + var i = 0, n = array.length, a = array[0], b; + if (arguments.length === 1) f = d3.ascending; + while (++i < n) { + if (f.call(array, a, b = array[i]) <= 0) { + a = b; + } + } + return a; + }; + d3.nest = function() { + function map(array, depth) { + if (depth >= keys.length) return rollup ? rollup.call(nest, array) : sortValues ? array.sort(sortValues) : array; + var i = -1, n = array.length, key = keys[depth++], keyValue, object, valuesByKey = new d3_Map, values, o = {}; + while (++i < n) { + if (values = valuesByKey.get(keyValue = key(object = array[i]))) { + values.push(object); + } else { + valuesByKey.set(keyValue, [ object ]); + } + } + valuesByKey.forEach(function(keyValue, values) { + o[keyValue] = map(values, depth); + }); + return o; + } + function entries(map, depth) { + if (depth >= keys.length) return map; + var a = [], sortKey = sortKeys[depth++], key; + for (key in map) { + a.push({ + key: key, + values: entries(map[key], depth) + }); + } + if (sortKey) a.sort(function(a, b) { + return sortKey(a.key, b.key); + }); + return a; + } + var nest = {}, keys = [], sortKeys = [], sortValues, rollup; + nest.map = function(array) { + return map(array, 0); + }; + nest.entries = function(array) { + return entries(map(array, 0), 0); + }; + nest.key = function(d) { + keys.push(d); + return nest; + }; + nest.sortKeys = function(order) { + sortKeys[keys.length - 1] = order; + return nest; + }; + nest.sortValues = function(order) { + sortValues = order; + return nest; + }; + nest.rollup = function(f) { + rollup = f; + return nest; + }; + return nest; + }; + d3.keys = function(map) { + var keys = []; + for (var key in map) keys.push(key); + return keys; + }; + d3.values = function(map) { + var values = []; + for (var key in map) values.push(map[key]); + return values; + }; + d3.entries = function(map) { + var entries = []; + for (var key in map) entries.push({ + key: key, + value: map[key] + }); + return entries; + }; + d3.permute = function(array, indexes) { + var permutes = [], i = -1, n = indexes.length; + while (++i < n) permutes[i] = array[indexes[i]]; + return permutes; + }; + d3.merge = function(arrays) { + return Array.prototype.concat.apply([], arrays); + }; + d3.split = function(array, f) { + var arrays = [], values = [], value, i = -1, n = array.length; + if (arguments.length < 2) f = d3_splitter; + while (++i < n) { + if (f.call(values, value = array[i], i)) { + values = []; + } else { + if (!values.length) arrays.push(values); + values.push(value); + } + } + return arrays; + }; + d3.range = function(start, stop, step) { + if (arguments.length < 3) { + step = 1; + if (arguments.length < 2) { + stop = start; + start = 0; + } + } + if ((stop - start) / step === Infinity) throw new Error("infinite range"); + var range = [], k = d3_range_integerScale(Math.abs(step)), i = -1, j; + start *= k, stop *= k, step *= k; + if (step < 0) while ((j = start + step * ++i) > stop) range.push(j / k); else while ((j = start + step * ++i) < stop) range.push(j / k); + return range; + }; + d3.requote = function(s) { + return s.replace(d3_requote_re, "\\$&"); + }; + var d3_requote_re = /[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g; + d3.round = function(x, n) { + return n ? Math.round(x * (n = Math.pow(10, n))) / n : Math.round(x); + }; + d3.xhr = function(url, mime, callback) { + var req = new XMLHttpRequest; + if (arguments.length < 3) callback = mime, mime = null; else if (mime && req.overrideMimeType) req.overrideMimeType(mime); + req.open("GET", url, true); + if (mime) req.setRequestHeader("Accept", mime); + req.onreadystatechange = function() { + if (req.readyState === 4) { + var s = req.status; + callback(!s && req.response || s >= 200 && s < 300 || s === 304 ? req : null); + } + }; + req.send(null); + }; + d3.text = function(url, mime, callback) { + function ready(req) { + callback(req && req.responseText); + } + if (arguments.length < 3) { + callback = mime; + mime = null; + } + d3.xhr(url, mime, ready); + }; + d3.json = function(url, callback) { + d3.text(url, "application/json", function(text) { + callback(text ? JSON.parse(text) : null); + }); + }; + d3.html = function(url, callback) { + d3.text(url, "text/html", function(text) { + if (text != null) { + var range = document.createRange(); + range.selectNode(document.body); + text = range.createContextualFragment(text); + } + callback(text); + }); + }; + d3.xml = function(url, mime, callback) { + function ready(req) { + callback(req && req.responseXML); + } + if (arguments.length < 3) { + callback = mime; + mime = null; + } + d3.xhr(url, mime, ready); + }; + var d3_nsPrefix = { + svg: "http://www.w3.org/2000/svg", + xhtml: "http://www.w3.org/1999/xhtml", + xlink: "http://www.w3.org/1999/xlink", + xml: "http://www.w3.org/XML/1998/namespace", + xmlns: "http://www.w3.org/2000/xmlns/" + }; + d3.ns = { + prefix: d3_nsPrefix, + qualify: function(name) { + var i = name.indexOf(":"), prefix = name; + if (i >= 0) { + prefix = name.substring(0, i); + name = name.substring(i + 1); + } + return d3_nsPrefix.hasOwnProperty(prefix) ? { + space: d3_nsPrefix[prefix], + local: name + } : name; + } + }; + d3.dispatch = function() { + var dispatch = new d3_dispatch, i = -1, n = arguments.length; + while (++i < n) dispatch[arguments[i]] = d3_dispatch_event(dispatch); + return dispatch; + }; + d3_dispatch.prototype.on = function(type, listener) { + var i = type.indexOf("."), name = ""; + if (i > 0) { + name = type.substring(i + 1); + type = type.substring(0, i); + } + return arguments.length < 2 ? this[type].on(name) : this[type].on(name, listener); + }; + d3.format = function(specifier) { + var match = d3_format_re.exec(specifier), fill = match[1] || " ", sign = match[3] || "", zfill = match[5], width = +match[6], comma = match[7], precision = match[8], type = match[9], scale = 1, suffix = "", integer = false; + if (precision) precision = +precision.substring(1); + if (zfill) { + fill = "0"; + if (comma) width -= Math.floor((width - 1) / 4); + } + switch (type) { + case "n": + comma = true; + type = "g"; + break; + case "%": + scale = 100; + suffix = "%"; + type = "f"; + break; + case "p": + scale = 100; + suffix = "%"; + type = "r"; + break; + case "d": + integer = true; + precision = 0; + break; + case "s": + scale = -1; + type = "r"; + break; + } + if (type == "r" && !precision) type = "g"; + type = d3_format_types.get(type) || d3_format_typeDefault; + return function(value) { + if (integer && value % 1) return ""; + var negative = value < 0 && (value = -value) ? "-" : sign; + if (scale < 0) { + var prefix = d3.formatPrefix(value, precision); + value = prefix.scale(value); + suffix = prefix.symbol; + } else { + value *= scale; + } + value = type(value, precision); + if (zfill) { + var length = value.length + negative.length; + if (length < width) value = (new Array(width - length + 1)).join(fill) + value; + if (comma) value = d3_format_group(value); + value = negative + value; + } else { + if (comma) value = d3_format_group(value); + value = negative + value; + var length = value.length; + if (length < width) value = (new Array(width - length + 1)).join(fill) + value; + } + return value + suffix; + }; + }; + var d3_format_re = /(?:([^{])?([<>=^]))?([+\- ])?(#)?(0)?([0-9]+)?(,)?(\.[0-9]+)?([a-zA-Z%])?/; + var d3_format_types = d3.map({ + g: function(x, p) { + return x.toPrecision(p); + }, + e: function(x, p) { + return x.toExponential(p); + }, + f: function(x, p) { + return x.toFixed(p); + }, + r: function(x, p) { + return d3.round(x, p = d3_format_precision(x, p)).toFixed(Math.max(0, Math.min(20, p))); + } + }); + var d3_formatPrefixes = [ "y", "z", "a", "f", "p", "n", "μ", "m", "", "k", "M", "G", "T", "P", "E", "Z", "Y" ].map(d3_formatPrefix); + d3.formatPrefix = function(value, precision) { + var i = 0; + if (value) { + if (value < 0) value *= -1; + if (precision) value = d3.round(value, d3_format_precision(value, precision)); + i = 1 + Math.floor(1e-12 + Math.log(value) / Math.LN10); + i = Math.max(-24, Math.min(24, Math.floor((i <= 0 ? i + 1 : i - 1) / 3) * 3)); + } + return d3_formatPrefixes[8 + i / 3]; + }; + var d3_ease_quad = d3_ease_poly(2), d3_ease_cubic = d3_ease_poly(3), d3_ease_default = function() { + return d3_ease_identity; + }; + var d3_ease = d3.map({ + linear: d3_ease_default, + poly: d3_ease_poly, + quad: function() { + return d3_ease_quad; + }, + cubic: function() { + return d3_ease_cubic; + }, + sin: function() { + return d3_ease_sin; + }, + exp: function() { + return d3_ease_exp; + }, + circle: function() { + return d3_ease_circle; + }, + elastic: d3_ease_elastic, + back: d3_ease_back, + bounce: function() { + return d3_ease_bounce; + } + }); + var d3_ease_mode = d3.map({ + "in": d3_ease_identity, + out: d3_ease_reverse, + "in-out": d3_ease_reflect, + "out-in": function(f) { + return d3_ease_reflect(d3_ease_reverse(f)); + } + }); + d3.ease = function(name) { + var i = name.indexOf("-"), t = i >= 0 ? name.substring(0, i) : name, m = i >= 0 ? name.substring(i + 1) : "in"; + t = d3_ease.get(t) || d3_ease_default; + m = d3_ease_mode.get(m) || d3_ease_identity; + return d3_ease_clamp(m(t.apply(null, Array.prototype.slice.call(arguments, 1)))); + }; + d3.event = null; + d3.transform = function(string) { + var g = document.createElementNS(d3.ns.prefix.svg, "g"); + return (d3.transform = function(string) { + g.setAttribute("transform", string); + var t = g.transform.baseVal.consolidate(); + return new d3_transform(t ? t.matrix : d3_transformIdentity); + })(string); + }; + d3_transform.prototype.toString = function() { + return "translate(" + this.translate + ")rotate(" + this.rotate + ")skewX(" + this.skew + ")scale(" + this.scale + ")"; + }; + var d3_transformDegrees = 180 / Math.PI, d3_transformIdentity = { + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0 + }; + d3.interpolate = function(a, b) { + var i = d3.interpolators.length, f; + while (--i >= 0 && !(f = d3.interpolators[i](a, b))) ; + return f; + }; + d3.interpolateNumber = function(a, b) { + b -= a; + return function(t) { + return a + b * t; + }; + }; + d3.interpolateRound = function(a, b) { + b -= a; + return function(t) { + return Math.round(a + b * t); + }; + }; + d3.interpolateString = function(a, b) { + var m, i, j, s0 = 0, s1 = 0, s = [], q = [], n, o; + d3_interpolate_number.lastIndex = 0; + for (i = 0; m = d3_interpolate_number.exec(b); ++i) { + if (m.index) s.push(b.substring(s0, s1 = m.index)); + q.push({ + i: s.length, + x: m[0] + }); + s.push(null); + s0 = d3_interpolate_number.lastIndex; + } + if (s0 < b.length) s.push(b.substring(s0)); + for (i = 0, n = q.length; (m = d3_interpolate_number.exec(a)) && i < n; ++i) { + o = q[i]; + if (o.x == m[0]) { + if (o.i) { + if (s[o.i + 1] == null) { + s[o.i - 1] += o.x; + s.splice(o.i, 1); + for (j = i + 1; j < n; ++j) q[j].i--; + } else { + s[o.i - 1] += o.x + s[o.i + 1]; + s.splice(o.i, 2); + for (j = i + 1; j < n; ++j) q[j].i -= 2; + } + } else { + if (s[o.i + 1] == null) { + s[o.i] = o.x; + } else { + s[o.i] = o.x + s[o.i + 1]; + s.splice(o.i + 1, 1); + for (j = i + 1; j < n; ++j) q[j].i--; + } + } + q.splice(i, 1); + n--; + i--; + } else { + o.x = d3.interpolateNumber(parseFloat(m[0]), parseFloat(o.x)); + } + } + while (i < n) { + o = q.pop(); + if (s[o.i + 1] == null) { + s[o.i] = o.x; + } else { + s[o.i] = o.x + s[o.i + 1]; + s.splice(o.i + 1, 1); + } + n--; + } + if (s.length === 1) { + return s[0] == null ? q[0].x : function() { + return b; + }; + } + return function(t) { + for (i = 0; i < n; ++i) s[(o = q[i]).i] = o.x(t); + return s.join(""); + }; + }; + d3.interpolateTransform = function(a, b) { + var s = [], q = [], n, A = d3.transform(a), B = d3.transform(b), ta = A.translate, tb = B.translate, ra = A.rotate, rb = B.rotate, wa = A.skew, wb = B.skew, ka = A.scale, kb = B.scale; + if (ta[0] != tb[0] || ta[1] != tb[1]) { + s.push("translate(", null, ",", null, ")"); + q.push({ + i: 1, + x: d3.interpolateNumber(ta[0], tb[0]) + }, { + i: 3, + x: d3.interpolateNumber(ta[1], tb[1]) + }); + } else if (tb[0] || tb[1]) { + s.push("translate(" + tb + ")"); + } else { + s.push(""); + } + if (ra != rb) { + if (ra - rb > 180) rb += 360; else if (rb - ra > 180) ra += 360; + q.push({ + i: s.push(s.pop() + "rotate(", null, ")") - 2, + x: d3.interpolateNumber(ra, rb) + }); + } else if (rb) { + s.push(s.pop() + "rotate(" + rb + ")"); + } + if (wa != wb) { + q.push({ + i: s.push(s.pop() + "skewX(", null, ")") - 2, + x: d3.interpolateNumber(wa, wb) + }); + } else if (wb) { + s.push(s.pop() + "skewX(" + wb + ")"); + } + if (ka[0] != kb[0] || ka[1] != kb[1]) { + n = s.push(s.pop() + "scale(", null, ",", null, ")"); + q.push({ + i: n - 4, + x: d3.interpolateNumber(ka[0], kb[0]) + }, { + i: n - 2, + x: d3.interpolateNumber(ka[1], kb[1]) + }); + } else if (kb[0] != 1 || kb[1] != 1) { + s.push(s.pop() + "scale(" + kb + ")"); + } + n = q.length; + return function(t) { + var i = -1, o; + while (++i < n) s[(o = q[i]).i] = o.x(t); + return s.join(""); + }; + }; + d3.interpolateRgb = function(a, b) { + a = d3.rgb(a); + b = d3.rgb(b); + var ar = a.r, ag = a.g, ab = a.b, br = b.r - ar, bg = b.g - ag, bb = b.b - ab; + return function(t) { + return "#" + d3_rgb_hex(Math.round(ar + br * t)) + d3_rgb_hex(Math.round(ag + bg * t)) + d3_rgb_hex(Math.round(ab + bb * t)); + }; + }; + d3.interpolateHsl = function(a, b) { + a = d3.hsl(a); + b = d3.hsl(b); + var h0 = a.h, s0 = a.s, l0 = a.l, h1 = b.h - h0, s1 = b.s - s0, l1 = b.l - l0; + if (h1 > 180) h1 -= 360; else if (h1 < -180) h1 += 360; + return function(t) { + return d3_hsl_rgb(h0 + h1 * t, s0 + s1 * t, l0 + l1 * t) + ""; + }; + }; + d3.interpolateLab = function(a, b) { + a = d3.lab(a); + b = d3.lab(b); + var al = a.l, aa = a.a, ab = a.b, bl = b.l - al, ba = b.a - aa, bb = b.b - ab; + return function(t) { + return d3_lab_rgb(al + bl * t, aa + ba * t, ab + bb * t) + ""; + }; + }; + d3.interpolateHcl = function(a, b) { + a = d3.hcl(a); + b = d3.hcl(b); + var ah = a.h, ac = a.c, al = a.l, bh = b.h - ah, bc = b.c - ac, bl = b.l - al; + if (bh > 180) bh -= 360; else if (bh < -180) bh += 360; + return function(t) { + return d3_hcl_lab(ah + bh * t, ac + bc * t, al + bl * t) + ""; + }; + }; + d3.interpolateArray = function(a, b) { + var x = [], c = [], na = a.length, nb = b.length, n0 = Math.min(a.length, b.length), i; + for (i = 0; i < n0; ++i) x.push(d3.interpolate(a[i], b[i])); + for (; i < na; ++i) c[i] = a[i]; + for (; i < nb; ++i) c[i] = b[i]; + return function(t) { + for (i = 0; i < n0; ++i) c[i] = x[i](t); + return c; + }; + }; + d3.interpolateObject = function(a, b) { + var i = {}, c = {}, k; + for (k in a) { + if (k in b) { + i[k] = d3_interpolateByName(k)(a[k], b[k]); + } else { + c[k] = a[k]; + } + } + for (k in b) { + if (!(k in a)) { + c[k] = b[k]; + } + } + return function(t) { + for (k in i) c[k] = i[k](t); + return c; + }; + }; + var d3_interpolate_number = /[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g; + d3.interpolators = [ d3.interpolateObject, function(a, b) { + return b instanceof Array && d3.interpolateArray(a, b); + }, function(a, b) { + return (typeof a === "string" || typeof b === "string") && d3.interpolateString(a + "", b + ""); + }, function(a, b) { + return (typeof b === "string" ? d3_rgb_names.has(b) || /^(#|rgb\(|hsl\()/.test(b) : b instanceof d3_Color) && d3.interpolateRgb(a, b); + }, function(a, b) { + return !isNaN(a = +a) && !isNaN(b = +b) && d3.interpolateNumber(a, b); + } ]; + d3_Color.prototype.toString = function() { + return this.rgb() + ""; + }; + d3.rgb = function(r, g, b) { + return arguments.length === 1 ? r instanceof d3_Rgb ? d3_rgb(r.r, r.g, r.b) : d3_rgb_parse("" + r, d3_rgb, d3_hsl_rgb) : d3_rgb(~~r, ~~g, ~~b); + }; + var d3_rgbPrototype = d3_Rgb.prototype = new d3_Color; + d3_rgbPrototype.brighter = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + var r = this.r, g = this.g, b = this.b, i = 30; + if (!r && !g && !b) return d3_rgb(i, i, i); + if (r && r < i) r = i; + if (g && g < i) g = i; + if (b && b < i) b = i; + return d3_rgb(Math.min(255, Math.floor(r / k)), Math.min(255, Math.floor(g / k)), Math.min(255, Math.floor(b / k))); + }; + d3_rgbPrototype.darker = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + return d3_rgb(Math.floor(k * this.r), Math.floor(k * this.g), Math.floor(k * this.b)); + }; + d3_rgbPrototype.hsl = function() { + return d3_rgb_hsl(this.r, this.g, this.b); + }; + d3_rgbPrototype.toString = function() { + return "#" + d3_rgb_hex(this.r) + d3_rgb_hex(this.g) + d3_rgb_hex(this.b); + }; + var d3_rgb_names = d3.map({ + aliceblue: "#f0f8ff", + antiquewhite: "#faebd7", + aqua: "#00ffff", + aquamarine: "#7fffd4", + azure: "#f0ffff", + beige: "#f5f5dc", + bisque: "#ffe4c4", + black: "#000000", + blanchedalmond: "#ffebcd", + blue: "#0000ff", + blueviolet: "#8a2be2", + brown: "#a52a2a", + burlywood: "#deb887", + cadetblue: "#5f9ea0", + chartreuse: "#7fff00", + chocolate: "#d2691e", + coral: "#ff7f50", + cornflowerblue: "#6495ed", + cornsilk: "#fff8dc", + crimson: "#dc143c", + cyan: "#00ffff", + darkblue: "#00008b", + darkcyan: "#008b8b", + darkgoldenrod: "#b8860b", + darkgray: "#a9a9a9", + darkgreen: "#006400", + darkgrey: "#a9a9a9", + darkkhaki: "#bdb76b", + darkmagenta: "#8b008b", + darkolivegreen: "#556b2f", + darkorange: "#ff8c00", + darkorchid: "#9932cc", + darkred: "#8b0000", + darksalmon: "#e9967a", + darkseagreen: "#8fbc8f", + darkslateblue: "#483d8b", + darkslategray: "#2f4f4f", + darkslategrey: "#2f4f4f", + darkturquoise: "#00ced1", + darkviolet: "#9400d3", + deeppink: "#ff1493", + deepskyblue: "#00bfff", + dimgray: "#696969", + dimgrey: "#696969", + dodgerblue: "#1e90ff", + firebrick: "#b22222", + floralwhite: "#fffaf0", + forestgreen: "#228b22", + fuchsia: "#ff00ff", + gainsboro: "#dcdcdc", + ghostwhite: "#f8f8ff", + gold: "#ffd700", + goldenrod: "#daa520", + gray: "#808080", + green: "#008000", + greenyellow: "#adff2f", + grey: "#808080", + honeydew: "#f0fff0", + hotpink: "#ff69b4", + indianred: "#cd5c5c", + indigo: "#4b0082", + ivory: "#fffff0", + khaki: "#f0e68c", + lavender: "#e6e6fa", + lavenderblush: "#fff0f5", + lawngreen: "#7cfc00", + lemonchiffon: "#fffacd", + lightblue: "#add8e6", + lightcoral: "#f08080", + lightcyan: "#e0ffff", + lightgoldenrodyellow: "#fafad2", + lightgray: "#d3d3d3", + lightgreen: "#90ee90", + lightgrey: "#d3d3d3", + lightpink: "#ffb6c1", + lightsalmon: "#ffa07a", + lightseagreen: "#20b2aa", + lightskyblue: "#87cefa", + lightslategray: "#778899", + lightslategrey: "#778899", + lightsteelblue: "#b0c4de", + lightyellow: "#ffffe0", + lime: "#00ff00", + limegreen: "#32cd32", + linen: "#faf0e6", + magenta: "#ff00ff", + maroon: "#800000", + mediumaquamarine: "#66cdaa", + mediumblue: "#0000cd", + mediumorchid: "#ba55d3", + mediumpurple: "#9370db", + mediumseagreen: "#3cb371", + mediumslateblue: "#7b68ee", + mediumspringgreen: "#00fa9a", + mediumturquoise: "#48d1cc", + mediumvioletred: "#c71585", + midnightblue: "#191970", + mintcream: "#f5fffa", + mistyrose: "#ffe4e1", + moccasin: "#ffe4b5", + navajowhite: "#ffdead", + navy: "#000080", + oldlace: "#fdf5e6", + olive: "#808000", + olivedrab: "#6b8e23", + orange: "#ffa500", + orangered: "#ff4500", + orchid: "#da70d6", + palegoldenrod: "#eee8aa", + palegreen: "#98fb98", + paleturquoise: "#afeeee", + palevioletred: "#db7093", + papayawhip: "#ffefd5", + peachpuff: "#ffdab9", + peru: "#cd853f", + pink: "#ffc0cb", + plum: "#dda0dd", + powderblue: "#b0e0e6", + purple: "#800080", + red: "#ff0000", + rosybrown: "#bc8f8f", + royalblue: "#4169e1", + saddlebrown: "#8b4513", + salmon: "#fa8072", + sandybrown: "#f4a460", + seagreen: "#2e8b57", + seashell: "#fff5ee", + sienna: "#a0522d", + silver: "#c0c0c0", + skyblue: "#87ceeb", + slateblue: "#6a5acd", + slategray: "#708090", + slategrey: "#708090", + snow: "#fffafa", + springgreen: "#00ff7f", + steelblue: "#4682b4", + tan: "#d2b48c", + teal: "#008080", + thistle: "#d8bfd8", + tomato: "#ff6347", + turquoise: "#40e0d0", + violet: "#ee82ee", + wheat: "#f5deb3", + white: "#ffffff", + whitesmoke: "#f5f5f5", + yellow: "#ffff00", + yellowgreen: "#9acd32" + }); + d3_rgb_names.forEach(function(key, value) { + d3_rgb_names.set(key, d3_rgb_parse(value, d3_rgb, d3_hsl_rgb)); + }); + d3.hsl = function(h, s, l) { + return arguments.length === 1 ? h instanceof d3_Hsl ? d3_hsl(h.h, h.s, h.l) : d3_rgb_parse("" + h, d3_rgb_hsl, d3_hsl) : d3_hsl(+h, +s, +l); + }; + var d3_hslPrototype = d3_Hsl.prototype = new d3_Color; + d3_hslPrototype.brighter = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + return d3_hsl(this.h, this.s, this.l / k); + }; + d3_hslPrototype.darker = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + return d3_hsl(this.h, this.s, k * this.l); + }; + d3_hslPrototype.rgb = function() { + return d3_hsl_rgb(this.h, this.s, this.l); + }; + d3.hcl = function(h, c, l) { + return arguments.length === 1 ? h instanceof d3_Hcl ? d3_hcl(h.h, h.c, h.l) : h instanceof d3_Lab ? d3_lab_hcl(h.l, h.a, h.b) : d3_lab_hcl((h = d3_rgb_lab((h = d3.rgb(h)).r, h.g, h.b)).l, h.a, h.b) : d3_hcl(+h, +c, +l); + }; + var d3_hclPrototype = d3_Hcl.prototype = new d3_Color; + d3_hclPrototype.brighter = function(k) { + return d3_hcl(this.h, this.c, Math.min(100, this.l + d3_lab_K * (arguments.length ? k : 1))); + }; + d3_hclPrototype.darker = function(k) { + return d3_hcl(this.h, this.c, Math.max(0, this.l - d3_lab_K * (arguments.length ? k : 1))); + }; + d3_hclPrototype.rgb = function() { + return d3_hcl_lab(this.h, this.c, this.l).rgb(); + }; + d3.lab = function(l, a, b) { + return arguments.length === 1 ? l instanceof d3_Lab ? d3_lab(l.l, l.a, l.b) : l instanceof d3_Hcl ? d3_hcl_lab(l.l, l.c, l.h) : d3_rgb_lab((l = d3.rgb(l)).r, l.g, l.b) : d3_lab(+l, +a, +b); + }; + var d3_lab_K = 18; + var d3_lab_X = .95047, d3_lab_Y = 1, d3_lab_Z = 1.08883; + var d3_labPrototype = d3_Lab.prototype = new d3_Color; + d3_labPrototype.brighter = function(k) { + return d3_lab(Math.min(100, this.l + d3_lab_K * (arguments.length ? k : 1)), this.a, this.b); + }; + d3_labPrototype.darker = function(k) { + return d3_lab(Math.max(0, this.l - d3_lab_K * (arguments.length ? k : 1)), this.a, this.b); + }; + d3_labPrototype.rgb = function() { + return d3_lab_rgb(this.l, this.a, this.b); + }; + var d3_select = function(s, n) { + return n.querySelector(s); + }, d3_selectAll = function(s, n) { + return n.querySelectorAll(s); + }, d3_selectRoot = document.documentElement, d3_selectMatcher = d3_selectRoot.matchesSelector || d3_selectRoot.webkitMatchesSelector || d3_selectRoot.mozMatchesSelector || d3_selectRoot.msMatchesSelector || d3_selectRoot.oMatchesSelector, d3_selectMatches = function(n, s) { + return d3_selectMatcher.call(n, s); + }; + if (typeof Sizzle === "function") { + d3_select = function(s, n) { + return Sizzle(s, n)[0] || null; + }; + d3_selectAll = function(s, n) { + return Sizzle.uniqueSort(Sizzle(s, n)); + }; + d3_selectMatches = Sizzle.matchesSelector; + } + var d3_selectionPrototype = []; + d3.selection = function() { + return d3_selectionRoot; + }; + d3.selection.prototype = d3_selectionPrototype; + d3_selectionPrototype.select = function(selector) { + var subgroups = [], subgroup, subnode, group, node; + if (typeof selector !== "function") selector = d3_selection_selector(selector); + for (var j = -1, m = this.length; ++j < m; ) { + subgroups.push(subgroup = []); + subgroup.parentNode = (group = this[j]).parentNode; + for (var i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + subgroup.push(subnode = selector.call(node, node.__data__, i)); + if (subnode && "__data__" in node) subnode.__data__ = node.__data__; + } else { + subgroup.push(null); + } + } + } + return d3_selection(subgroups); + }; + d3_selectionPrototype.selectAll = function(selector) { + var subgroups = [], subgroup, node; + if (typeof selector !== "function") selector = d3_selection_selectorAll(selector); + for (var j = -1, m = this.length; ++j < m; ) { + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + subgroups.push(subgroup = d3_array(selector.call(node, node.__data__, i))); + subgroup.parentNode = node; + } + } + } + return d3_selection(subgroups); + }; + d3_selectionPrototype.attr = function(name, value) { + if (arguments.length < 2) { + if (typeof name === "string") { + var node = this.node(); + name = d3.ns.qualify(name); + return name.local ? node.getAttributeNS(name.space, name.local) : node.getAttribute(name); + } + for (value in name) this.each(d3_selection_attr(value, name[value])); + return this; + } + return this.each(d3_selection_attr(name, value)); + }; + d3_selectionPrototype.classed = function(name, value) { + if (arguments.length < 2) { + if (typeof name === "string") { + var node = this.node(), n = (name = name.trim().split(/^|\s+/g)).length, i = -1; + if (value = node.classList) { + while (++i < n) if (!value.contains(name[i])) return false; + } else { + value = node.className; + if (value.baseVal != null) value = value.baseVal; + while (++i < n) if (!d3_selection_classedRe(name[i]).test(value)) return false; + } + return true; + } + for (value in name) this.each(d3_selection_classed(value, name[value])); + return this; + } + return this.each(d3_selection_classed(name, value)); + }; + d3_selectionPrototype.style = function(name, value, priority) { + var n = arguments.length; + if (n < 3) { + if (typeof name !== "string") { + if (n < 2) value = ""; + for (priority in name) this.each(d3_selection_style(priority, name[priority], value)); + return this; + } + if (n < 2) return window.getComputedStyle(this.node(), null).getPropertyValue(name); + priority = ""; + } + return this.each(d3_selection_style(name, value, priority)); + }; + d3_selectionPrototype.property = function(name, value) { + if (arguments.length < 2) { + if (typeof name === "string") return this.node()[name]; + for (value in name) this.each(d3_selection_property(value, name[value])); + return this; + } + return this.each(d3_selection_property(name, value)); + }; + d3_selectionPrototype.text = function(value) { + return arguments.length < 1 ? this.node().textContent : this.each(typeof value === "function" ? function() { + var v = value.apply(this, arguments); + this.textContent = v == null ? "" : v; + } : value == null ? function() { + this.textContent = ""; + } : function() { + this.textContent = value; + }); + }; + d3_selectionPrototype.html = function(value) { + return arguments.length < 1 ? this.node().innerHTML : this.each(typeof value === "function" ? function() { + var v = value.apply(this, arguments); + this.innerHTML = v == null ? "" : v; + } : value == null ? function() { + this.innerHTML = ""; + } : function() { + this.innerHTML = value; + }); + }; + d3_selectionPrototype.append = function(name) { + function append() { + return this.appendChild(document.createElementNS(this.namespaceURI, name)); + } + function appendNS() { + return this.appendChild(document.createElementNS(name.space, name.local)); + } + name = d3.ns.qualify(name); + return this.select(name.local ? appendNS : append); + }; + d3_selectionPrototype.insert = function(name, before) { + function insert() { + return this.insertBefore(document.createElementNS(this.namespaceURI, name), d3_select(before, this)); + } + function insertNS() { + return this.insertBefore(document.createElementNS(name.space, name.local), d3_select(before, this)); + } + name = d3.ns.qualify(name); + return this.select(name.local ? insertNS : insert); + }; + d3_selectionPrototype.remove = function() { + return this.each(function() { + var parent = this.parentNode; + if (parent) parent.removeChild(this); + }); + }; + d3_selectionPrototype.data = function(value, key) { + function bind(group, groupData) { + var i, n = group.length, m = groupData.length, n0 = Math.min(n, m), n1 = Math.max(n, m), updateNodes = [], enterNodes = [], exitNodes = [], node, nodeData; + if (key) { + var nodeByKeyValue = new d3_Map, keyValues = [], keyValue, j = groupData.length; + for (i = -1; ++i < n; ) { + keyValue = key.call(node = group[i], node.__data__, i); + if (nodeByKeyValue.has(keyValue)) { + exitNodes[j++] = node; + } else { + nodeByKeyValue.set(keyValue, node); + } + keyValues.push(keyValue); + } + for (i = -1; ++i < m; ) { + keyValue = key.call(groupData, nodeData = groupData[i], i); + if (nodeByKeyValue.has(keyValue)) { + updateNodes[i] = node = nodeByKeyValue.get(keyValue); + node.__data__ = nodeData; + enterNodes[i] = exitNodes[i] = null; + } else { + enterNodes[i] = d3_selection_dataNode(nodeData); + updateNodes[i] = exitNodes[i] = null; + } + nodeByKeyValue.remove(keyValue); + } + for (i = -1; ++i < n; ) { + if (nodeByKeyValue.has(keyValues[i])) { + exitNodes[i] = group[i]; + } + } + } else { + for (i = -1; ++i < n0; ) { + node = group[i]; + nodeData = groupData[i]; + if (node) { + node.__data__ = nodeData; + updateNodes[i] = node; + enterNodes[i] = exitNodes[i] = null; + } else { + enterNodes[i] = d3_selection_dataNode(nodeData); + updateNodes[i] = exitNodes[i] = null; + } + } + for (; i < m; ++i) { + enterNodes[i] = d3_selection_dataNode(groupData[i]); + updateNodes[i] = exitNodes[i] = null; + } + for (; i < n1; ++i) { + exitNodes[i] = group[i]; + enterNodes[i] = updateNodes[i] = null; + } + } + enterNodes.update = updateNodes; + enterNodes.parentNode = updateNodes.parentNode = exitNodes.parentNode = group.parentNode; + enter.push(enterNodes); + update.push(updateNodes); + exit.push(exitNodes); + } + var i = -1, n = this.length, group, node; + if (!arguments.length) { + value = new Array(n = (group = this[0]).length); + while (++i < n) { + if (node = group[i]) { + value[i] = node.__data__; + } + } + return value; + } + var enter = d3_selection_enter([]), update = d3_selection([]), exit = d3_selection([]); + if (typeof value === "function") { + while (++i < n) { + bind(group = this[i], value.call(group, group.parentNode.__data__, i)); + } + } else { + while (++i < n) { + bind(group = this[i], value); + } + } + update.enter = function() { + return enter; + }; + update.exit = function() { + return exit; + }; + return update; + }; + d3_selectionPrototype.datum = d3_selectionPrototype.map = function(value) { + return arguments.length < 1 ? this.property("__data__") : this.property("__data__", value); + }; + d3_selectionPrototype.filter = function(filter) { + var subgroups = [], subgroup, group, node; + if (typeof filter !== "function") filter = d3_selection_filter(filter); + for (var j = 0, m = this.length; j < m; j++) { + subgroups.push(subgroup = []); + subgroup.parentNode = (group = this[j]).parentNode; + for (var i = 0, n = group.length; i < n; i++) { + if ((node = group[i]) && filter.call(node, node.__data__, i)) { + subgroup.push(node); + } + } + } + return d3_selection(subgroups); + }; + d3_selectionPrototype.order = function() { + for (var j = -1, m = this.length; ++j < m; ) { + for (var group = this[j], i = group.length - 1, next = group[i], node; --i >= 0; ) { + if (node = group[i]) { + if (next && next !== node.nextSibling) next.parentNode.insertBefore(node, next); + next = node; + } + } + } + return this; + }; + d3_selectionPrototype.sort = function(comparator) { + comparator = d3_selection_sortComparator.apply(this, arguments); + for (var j = -1, m = this.length; ++j < m; ) this[j].sort(comparator); + return this.order(); + }; + d3_selectionPrototype.on = function(type, listener, capture) { + var n = arguments.length; + if (n < 3) { + if (typeof type !== "string") { + if (n < 2) listener = false; + for (capture in type) this.each(d3_selection_on(capture, type[capture], listener)); + return this; + } + if (n < 2) return (n = this.node()["__on" + type]) && n._; + capture = false; + } + return this.each(d3_selection_on(type, listener, capture)); + }; + d3_selectionPrototype.each = function(callback) { + return d3_selection_each(this, function(node, i, j) { + callback.call(node, node.__data__, i, j); + }); + }; + d3_selectionPrototype.call = function(callback) { + callback.apply(this, (arguments[0] = this, arguments)); + return this; + }; + d3_selectionPrototype.empty = function() { + return !this.node(); + }; + d3_selectionPrototype.node = function(callback) { + for (var j = 0, m = this.length; j < m; j++) { + for (var group = this[j], i = 0, n = group.length; i < n; i++) { + var node = group[i]; + if (node) return node; + } + } + return null; + }; + d3_selectionPrototype.transition = function() { + var subgroups = [], subgroup, node; + for (var j = -1, m = this.length; ++j < m; ) { + subgroups.push(subgroup = []); + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + subgroup.push((node = group[i]) ? { + node: node, + delay: d3_transitionDelay, + duration: d3_transitionDuration + } : null); + } + } + return d3_transition(subgroups, d3_transitionId || ++d3_transitionNextId, Date.now()); + }; + var d3_selectionRoot = d3_selection([ [ document ] ]); + d3_selectionRoot[0].parentNode = d3_selectRoot; + d3.select = function(selector) { + return typeof selector === "string" ? d3_selectionRoot.select(selector) : d3_selection([ [ selector ] ]); + }; + d3.selectAll = function(selector) { + return typeof selector === "string" ? d3_selectionRoot.selectAll(selector) : d3_selection([ d3_array(selector) ]); + }; + var d3_selection_enterPrototype = []; + d3.selection.enter = d3_selection_enter; + d3.selection.enter.prototype = d3_selection_enterPrototype; + d3_selection_enterPrototype.append = d3_selectionPrototype.append; + d3_selection_enterPrototype.insert = d3_selectionPrototype.insert; + d3_selection_enterPrototype.empty = d3_selectionPrototype.empty; + d3_selection_enterPrototype.node = d3_selectionPrototype.node; + d3_selection_enterPrototype.select = function(selector) { + var subgroups = [], subgroup, subnode, upgroup, group, node; + for (var j = -1, m = this.length; ++j < m; ) { + upgroup = (group = this[j]).update; + subgroups.push(subgroup = []); + subgroup.parentNode = group.parentNode; + for (var i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + subgroup.push(upgroup[i] = subnode = selector.call(group.parentNode, node.__data__, i)); + subnode.__data__ = node.__data__; + } else { + subgroup.push(null); + } + } + } + return d3_selection(subgroups); + }; + var d3_transitionPrototype = [], d3_transitionNextId = 0, d3_transitionId = 0, d3_transitionDefaultDelay = 0, d3_transitionDefaultDuration = 250, d3_transitionDefaultEase = d3.ease("cubic-in-out"), d3_transitionDelay = d3_transitionDefaultDelay, d3_transitionDuration = d3_transitionDefaultDuration, d3_transitionEase = d3_transitionDefaultEase; + d3_transitionPrototype.call = d3_selectionPrototype.call; + d3.transition = function(selection) { + return arguments.length ? d3_transitionId ? selection.transition() : selection : d3_selectionRoot.transition(); + }; + d3.transition.prototype = d3_transitionPrototype; + d3_transitionPrototype.select = function(selector) { + var subgroups = [], subgroup, subnode, node; + if (typeof selector !== "function") selector = d3_selection_selector(selector); + for (var j = -1, m = this.length; ++j < m; ) { + subgroups.push(subgroup = []); + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + if ((node = group[i]) && (subnode = selector.call(node.node, node.node.__data__, i))) { + if ("__data__" in node.node) subnode.__data__ = node.node.__data__; + subgroup.push({ + node: subnode, + delay: node.delay, + duration: node.duration + }); + } else { + subgroup.push(null); + } + } + } + return d3_transition(subgroups, this.id, this.time).ease(this.ease()); + }; + d3_transitionPrototype.selectAll = function(selector) { + var subgroups = [], subgroup, subnodes, node; + if (typeof selector !== "function") selector = d3_selection_selectorAll(selector); + for (var j = -1, m = this.length; ++j < m; ) { + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + subnodes = selector.call(node.node, node.node.__data__, i); + subgroups.push(subgroup = []); + for (var k = -1, o = subnodes.length; ++k < o; ) { + subgroup.push({ + node: subnodes[k], + delay: node.delay, + duration: node.duration + }); + } + } + } + } + return d3_transition(subgroups, this.id, this.time).ease(this.ease()); + }; + d3_transitionPrototype.filter = function(filter) { + var subgroups = [], subgroup, group, node; + if (typeof filter !== "function") filter = d3_selection_filter(filter); + for (var j = 0, m = this.length; j < m; j++) { + subgroups.push(subgroup = []); + for (var group = this[j], i = 0, n = group.length; i < n; i++) { + if ((node = group[i]) && filter.call(node.node, node.node.__data__, i)) { + subgroup.push(node); + } + } + } + return d3_transition(subgroups, this.id, this.time).ease(this.ease()); + }; + d3_transitionPrototype.attr = function(name, value) { + if (arguments.length < 2) { + for (value in name) this.attrTween(value, d3_tweenByName(name[value], value)); + return this; + } + return this.attrTween(name, d3_tweenByName(value, name)); + }; + d3_transitionPrototype.attrTween = function(nameNS, tween) { + function attrTween(d, i) { + var f = tween.call(this, d, i, this.getAttribute(name)); + return f === d3_tweenRemove ? (this.removeAttribute(name), null) : f && function(t) { + this.setAttribute(name, f(t)); + }; + } + function attrTweenNS(d, i) { + var f = tween.call(this, d, i, this.getAttributeNS(name.space, name.local)); + return f === d3_tweenRemove ? (this.removeAttributeNS(name.space, name.local), null) : f && function(t) { + this.setAttributeNS(name.space, name.local, f(t)); + }; + } + var name = d3.ns.qualify(nameNS); + return this.tween("attr." + nameNS, name.local ? attrTweenNS : attrTween); + }; + d3_transitionPrototype.style = function(name, value, priority) { + var n = arguments.length; + if (n < 3) { + if (typeof name !== "string") { + if (n < 2) value = ""; + for (priority in name) this.styleTween(priority, d3_tweenByName(name[priority], priority), value); + return this; + } + priority = ""; + } + return this.styleTween(name, d3_tweenByName(value, name), priority); + }; + d3_transitionPrototype.styleTween = function(name, tween, priority) { + if (arguments.length < 3) priority = ""; + return this.tween("style." + name, function(d, i) { + var f = tween.call(this, d, i, window.getComputedStyle(this, null).getPropertyValue(name)); + return f === d3_tweenRemove ? (this.style.removeProperty(name), null) : f && function(t) { + this.style.setProperty(name, f(t), priority); + }; + }); + }; + d3_transitionPrototype.text = function(value) { + return this.tween("text", function(d, i) { + this.textContent = typeof value === "function" ? value.call(this, d, i) : value; + }); + }; + d3_transitionPrototype.remove = function() { + return this.each("end.transition", function() { + var p; + if (!this.__transition__ && (p = this.parentNode)) p.removeChild(this); + }); + }; + d3_transitionPrototype.delay = function(value) { + return d3_selection_each(this, typeof value === "function" ? function(node, i, j) { + node.delay = value.call(node = node.node, node.__data__, i, j) | 0; + } : (value = value | 0, function(node) { + node.delay = value; + })); + }; + d3_transitionPrototype.duration = function(value) { + return d3_selection_each(this, typeof value === "function" ? function(node, i, j) { + node.duration = Math.max(1, value.call(node = node.node, node.__data__, i, j) | 0); + } : (value = Math.max(1, value | 0), function(node) { + node.duration = value; + })); + }; + d3_transitionPrototype.transition = function() { + return this.select(d3_this); + }; + d3.tween = function(b, interpolate) { + function tweenFunction(d, i, a) { + var v = b.call(this, d, i); + return v == null ? a != "" && d3_tweenRemove : a != v && interpolate(a, v + ""); + } + function tweenString(d, i, a) { + return a != b && interpolate(a, b); + } + return typeof b === "function" ? tweenFunction : b == null ? d3_tweenNull : (b += "", tweenString); + }; + var d3_tweenRemove = {}; + var d3_timer_id = 0, d3_timer_byId = {}, d3_timer_queue = null, d3_timer_interval, d3_timer_timeout; + d3.timer = function(callback, delay, then) { + if (arguments.length < 3) { + if (arguments.length < 2) delay = 0; else if (!isFinite(delay)) return; + then = Date.now(); + } + var timer = d3_timer_byId[callback.id]; + if (timer && timer.callback === callback) { + timer.then = then; + timer.delay = delay; + } else d3_timer_byId[callback.id = ++d3_timer_id] = d3_timer_queue = { + callback: callback, + then: then, + delay: delay, + next: d3_timer_queue + }; + if (!d3_timer_interval) { + d3_timer_timeout = clearTimeout(d3_timer_timeout); + d3_timer_interval = 1; + d3_timer_frame(d3_timer_step); + } + }; + d3.timer.flush = function() { + var elapsed, now = Date.now(), t1 = d3_timer_queue; + while (t1) { + elapsed = now - t1.then; + if (!t1.delay) t1.flush = t1.callback(elapsed); + t1 = t1.next; + } + d3_timer_flush(); + }; + var d3_timer_frame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || function(callback) { + setTimeout(callback, 17); + }; + d3.mouse = function(container) { + return d3_mousePoint(container, d3_eventSource()); + }; + var d3_mouse_bug44083 = /WebKit/.test(navigator.userAgent) ? -1 : 0; + d3.touches = function(container, touches) { + if (arguments.length < 2) touches = d3_eventSource().touches; + return touches ? d3_array(touches).map(function(touch) { + var point = d3_mousePoint(container, touch); + point.identifier = touch.identifier; + return point; + }) : []; + }; + d3.scale = {}; + d3.scale.linear = function() { + return d3_scale_linear([ 0, 1 ], [ 0, 1 ], d3.interpolate, false); + }; + d3.scale.log = function() { + return d3_scale_log(d3.scale.linear(), d3_scale_logp); + }; + var d3_scale_logFormat = d3.format(".0e"); + d3_scale_logp.pow = function(x) { + return Math.pow(10, x); + }; + d3_scale_logn.pow = function(x) { + return -Math.pow(10, -x); + }; + d3.scale.pow = function() { + return d3_scale_pow(d3.scale.linear(), 1); + }; + d3.scale.sqrt = function() { + return d3.scale.pow().exponent(.5); + }; + d3.scale.ordinal = function() { + return d3_scale_ordinal([], { + t: "range", + a: [ [] ] + }); + }; + d3.scale.category10 = function() { + return d3.scale.ordinal().range(d3_category10); + }; + d3.scale.category20 = function() { + return d3.scale.ordinal().range(d3_category20); + }; + d3.scale.category20b = function() { + return d3.scale.ordinal().range(d3_category20b); + }; + d3.scale.category20c = function() { + return d3.scale.ordinal().range(d3_category20c); + }; + var d3_category10 = [ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf" ]; + var d3_category20 = [ "#1f77b4", "#aec7e8", "#ff7f0e", "#ffbb78", "#2ca02c", "#98df8a", "#d62728", "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5" ]; + var d3_category20b = [ "#393b79", "#5254a3", "#6b6ecf", "#9c9ede", "#637939", "#8ca252", "#b5cf6b", "#cedb9c", "#8c6d31", "#bd9e39", "#e7ba52", "#e7cb94", "#843c39", "#ad494a", "#d6616b", "#e7969c", "#7b4173", "#a55194", "#ce6dbd", "#de9ed6" ]; + var d3_category20c = [ "#3182bd", "#6baed6", "#9ecae1", "#c6dbef", "#e6550d", "#fd8d3c", "#fdae6b", "#fdd0a2", "#31a354", "#74c476", "#a1d99b", "#c7e9c0", "#756bb1", "#9e9ac8", "#bcbddc", "#dadaeb", "#636363", "#969696", "#bdbdbd", "#d9d9d9" ]; + d3.scale.quantile = function() { + return d3_scale_quantile([], []); + }; + d3.scale.quantize = function() { + return d3_scale_quantize(0, 1, [ 0, 1 ]); + }; + d3.scale.threshold = function() { + return d3_scale_threshold([ .5 ], [ 0, 1 ]); + }; + d3.scale.identity = function() { + return d3_scale_identity([ 0, 1 ]); + }; + d3.svg = {}; + d3.svg.arc = function() { + function arc() { + var r0 = innerRadius.apply(this, arguments), r1 = outerRadius.apply(this, arguments), a0 = startAngle.apply(this, arguments) + d3_svg_arcOffset, a1 = endAngle.apply(this, arguments) + d3_svg_arcOffset, da = (a1 < a0 && (da = a0, a0 = a1, a1 = da), a1 - a0), df = da < Math.PI ? "0" : "1", c0 = Math.cos(a0), s0 = Math.sin(a0), c1 = Math.cos(a1), s1 = Math.sin(a1); + return da >= d3_svg_arcMax ? r0 ? "M0," + r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + -r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + r1 + "M0," + r0 + "A" + r0 + "," + r0 + " 0 1,0 0," + -r0 + "A" + r0 + "," + r0 + " 0 1,0 0," + r0 + "Z" : "M0," + r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + -r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + r1 + "Z" : r0 ? "M" + r1 * c0 + "," + r1 * s0 + "A" + r1 + "," + r1 + " 0 " + df + ",1 " + r1 * c1 + "," + r1 * s1 + "L" + r0 * c1 + "," + r0 * s1 + "A" + r0 + "," + r0 + " 0 " + df + ",0 " + r0 * c0 + "," + r0 * s0 + "Z" : "M" + r1 * c0 + "," + r1 * s0 + "A" + r1 + "," + r1 + " 0 " + df + ",1 " + r1 * c1 + "," + r1 * s1 + "L0,0" + "Z"; + } + var innerRadius = d3_svg_arcInnerRadius, outerRadius = d3_svg_arcOuterRadius, startAngle = d3_svg_arcStartAngle, endAngle = d3_svg_arcEndAngle; + arc.innerRadius = function(v) { + if (!arguments.length) return innerRadius; + innerRadius = d3_functor(v); + return arc; + }; + arc.outerRadius = function(v) { + if (!arguments.length) return outerRadius; + outerRadius = d3_functor(v); + return arc; + }; + arc.startAngle = function(v) { + if (!arguments.length) return startAngle; + startAngle = d3_functor(v); + return arc; + }; + arc.endAngle = function(v) { + if (!arguments.length) return endAngle; + endAngle = d3_functor(v); + return arc; + }; + arc.centroid = function() { + var r = (innerRadius.apply(this, arguments) + outerRadius.apply(this, arguments)) / 2, a = (startAngle.apply(this, arguments) + endAngle.apply(this, arguments)) / 2 + d3_svg_arcOffset; + return [ Math.cos(a) * r, Math.sin(a) * r ]; + }; + return arc; + }; + var d3_svg_arcOffset = -Math.PI / 2, d3_svg_arcMax = 2 * Math.PI - 1e-6; + d3.svg.line = function() { + return d3_svg_line(d3_identity); + }; + var d3_svg_lineInterpolators = d3.map({ + linear: d3_svg_lineLinear, + "linear-closed": d3_svg_lineLinearClosed, + "step-before": d3_svg_lineStepBefore, + "step-after": d3_svg_lineStepAfter, + basis: d3_svg_lineBasis, + "basis-open": d3_svg_lineBasisOpen, + "basis-closed": d3_svg_lineBasisClosed, + bundle: d3_svg_lineBundle, + cardinal: d3_svg_lineCardinal, + "cardinal-open": d3_svg_lineCardinalOpen, + "cardinal-closed": d3_svg_lineCardinalClosed, + monotone: d3_svg_lineMonotone + }); + d3_svg_lineInterpolators.forEach(function(key, value) { + value.key = key; + value.closed = /-closed$/.test(key); + }); + var d3_svg_lineBasisBezier1 = [ 0, 2 / 3, 1 / 3, 0 ], d3_svg_lineBasisBezier2 = [ 0, 1 / 3, 2 / 3, 0 ], d3_svg_lineBasisBezier3 = [ 0, 1 / 6, 2 / 3, 1 / 6 ]; + d3.svg.line.radial = function() { + var line = d3_svg_line(d3_svg_lineRadial); + line.radius = line.x, delete line.x; + line.angle = line.y, delete line.y; + return line; + }; + d3_svg_lineStepBefore.reverse = d3_svg_lineStepAfter; + d3_svg_lineStepAfter.reverse = d3_svg_lineStepBefore; + d3.svg.area = function() { + return d3_svg_area(d3_identity); + }; + d3.svg.area.radial = function() { + var area = d3_svg_area(d3_svg_lineRadial); + area.radius = area.x, delete area.x; + area.innerRadius = area.x0, delete area.x0; + area.outerRadius = area.x1, delete area.x1; + area.angle = area.y, delete area.y; + area.startAngle = area.y0, delete area.y0; + area.endAngle = area.y1, delete area.y1; + return area; + }; + d3.svg.chord = function() { + function chord(d, i) { + var s = subgroup(this, source, d, i), t = subgroup(this, target, d, i); + return "M" + s.p0 + arc(s.r, s.p1, s.a1 - s.a0) + (equals(s, t) ? curve(s.r, s.p1, s.r, s.p0) : curve(s.r, s.p1, t.r, t.p0) + arc(t.r, t.p1, t.a1 - t.a0) + curve(t.r, t.p1, s.r, s.p0)) + "Z"; + } + function subgroup(self, f, d, i) { + var subgroup = f.call(self, d, i), r = radius.call(self, subgroup, i), a0 = startAngle.call(self, subgroup, i) + d3_svg_arcOffset, a1 = endAngle.call(self, subgroup, i) + d3_svg_arcOffset; + return { + r: r, + a0: a0, + a1: a1, + p0: [ r * Math.cos(a0), r * Math.sin(a0) ], + p1: [ r * Math.cos(a1), r * Math.sin(a1) ] + }; + } + function equals(a, b) { + return a.a0 == b.a0 && a.a1 == b.a1; + } + function arc(r, p, a) { + return "A" + r + "," + r + " 0 " + +(a > Math.PI) + ",1 " + p; + } + function curve(r0, p0, r1, p1) { + return "Q 0,0 " + p1; + } + var source = d3_svg_chordSource, target = d3_svg_chordTarget, radius = d3_svg_chordRadius, startAngle = d3_svg_arcStartAngle, endAngle = d3_svg_arcEndAngle; + chord.radius = function(v) { + if (!arguments.length) return radius; + radius = d3_functor(v); + return chord; + }; + chord.source = function(v) { + if (!arguments.length) return source; + source = d3_functor(v); + return chord; + }; + chord.target = function(v) { + if (!arguments.length) return target; + target = d3_functor(v); + return chord; + }; + chord.startAngle = function(v) { + if (!arguments.length) return startAngle; + startAngle = d3_functor(v); + return chord; + }; + chord.endAngle = function(v) { + if (!arguments.length) return endAngle; + endAngle = d3_functor(v); + return chord; + }; + return chord; + }; + d3.svg.diagonal = function() { + function diagonal(d, i) { + var p0 = source.call(this, d, i), p3 = target.call(this, d, i), m = (p0.y + p3.y) / 2, p = [ p0, { + x: p0.x, + y: m + }, { + x: p3.x, + y: m + }, p3 ]; + p = p.map(projection); + return "M" + p[0] + "C" + p[1] + " " + p[2] + " " + p[3]; + } + var source = d3_svg_chordSource, target = d3_svg_chordTarget, projection = d3_svg_diagonalProjection; + diagonal.source = function(x) { + if (!arguments.length) return source; + source = d3_functor(x); + return diagonal; + }; + diagonal.target = function(x) { + if (!arguments.length) return target; + target = d3_functor(x); + return diagonal; + }; + diagonal.projection = function(x) { + if (!arguments.length) return projection; + projection = x; + return diagonal; + }; + return diagonal; + }; + d3.svg.diagonal.radial = function() { + var diagonal = d3.svg.diagonal(), projection = d3_svg_diagonalProjection, projection_ = diagonal.projection; + diagonal.projection = function(x) { + return arguments.length ? projection_(d3_svg_diagonalRadialProjection(projection = x)) : projection; + }; + return diagonal; + }; + d3.svg.mouse = d3.mouse; + d3.svg.touches = d3.touches; + d3.svg.symbol = function() { + function symbol(d, i) { + return (d3_svg_symbols.get(type.call(this, d, i)) || d3_svg_symbolCircle)(size.call(this, d, i)); + } + var type = d3_svg_symbolType, size = d3_svg_symbolSize; + symbol.type = function(x) { + if (!arguments.length) return type; + type = d3_functor(x); + return symbol; + }; + symbol.size = function(x) { + if (!arguments.length) return size; + size = d3_functor(x); + return symbol; + }; + return symbol; + }; + var d3_svg_symbols = d3.map({ + circle: d3_svg_symbolCircle, + cross: function(size) { + var r = Math.sqrt(size / 5) / 2; + return "M" + -3 * r + "," + -r + "H" + -r + "V" + -3 * r + "H" + r + "V" + -r + "H" + 3 * r + "V" + r + "H" + r + "V" + 3 * r + "H" + -r + "V" + r + "H" + -3 * r + "Z"; + }, + diamond: function(size) { + var ry = Math.sqrt(size / (2 * d3_svg_symbolTan30)), rx = ry * d3_svg_symbolTan30; + return "M0," + -ry + "L" + rx + ",0" + " 0," + ry + " " + -rx + ",0" + "Z"; + }, + square: function(size) { + var r = Math.sqrt(size) / 2; + return "M" + -r + "," + -r + "L" + r + "," + -r + " " + r + "," + r + " " + -r + "," + r + "Z"; + }, + "triangle-down": function(size) { + var rx = Math.sqrt(size / d3_svg_symbolSqrt3), ry = rx * d3_svg_symbolSqrt3 / 2; + return "M0," + ry + "L" + rx + "," + -ry + " " + -rx + "," + -ry + "Z"; + }, + "triangle-up": function(size) { + var rx = Math.sqrt(size / d3_svg_symbolSqrt3), ry = rx * d3_svg_symbolSqrt3 / 2; + return "M0," + -ry + "L" + rx + "," + ry + " " + -rx + "," + ry + "Z"; + } + }); + d3.svg.symbolTypes = d3_svg_symbols.keys(); + var d3_svg_symbolSqrt3 = Math.sqrt(3), d3_svg_symbolTan30 = Math.tan(30 * Math.PI / 180); + d3.svg.axis = function() { + function axis(g) { + g.each(function() { + var g = d3.select(this); + var ticks = tickValues == null ? scale.ticks ? scale.ticks.apply(scale, tickArguments_) : scale.domain() : tickValues, tickFormat = tickFormat_ == null ? scale.tickFormat ? scale.tickFormat.apply(scale, tickArguments_) : String : tickFormat_; + var subticks = d3_svg_axisSubdivide(scale, ticks, tickSubdivide), subtick = g.selectAll(".minor").data(subticks, String), subtickEnter = subtick.enter().insert("line", "g").attr("class", "tick minor").style("opacity", 1e-6), subtickExit = d3.transition(subtick.exit()).style("opacity", 1e-6).remove(), subtickUpdate = d3.transition(subtick).style("opacity", 1); + var tick = g.selectAll("g").data(ticks, String), tickEnter = tick.enter().insert("g", "path").style("opacity", 1e-6), tickExit = d3.transition(tick.exit()).style("opacity", 1e-6).remove(), tickUpdate = d3.transition(tick).style("opacity", 1), tickTransform; + var range = d3_scaleRange(scale), path = g.selectAll(".domain").data([ 0 ]), pathEnter = path.enter().append("path").attr("class", "domain"), pathUpdate = d3.transition(path); + var scale1 = scale.copy(), scale0 = this.__chart__ || scale1; + this.__chart__ = scale1; + tickEnter.append("line").attr("class", "tick"); + tickEnter.append("text"); + var lineEnter = tickEnter.select("line"), lineUpdate = tickUpdate.select("line"), text = tick.select("text").text(tickFormat), textEnter = tickEnter.select("text"), textUpdate = tickUpdate.select("text"); + switch (orient) { + case "bottom": + { + tickTransform = d3_svg_axisX; + subtickEnter.attr("y2", tickMinorSize); + subtickUpdate.attr("x2", 0).attr("y2", tickMinorSize); + lineEnter.attr("y2", tickMajorSize); + textEnter.attr("y", Math.max(tickMajorSize, 0) + tickPadding); + lineUpdate.attr("x2", 0).attr("y2", tickMajorSize); + textUpdate.attr("x", 0).attr("y", Math.max(tickMajorSize, 0) + tickPadding); + text.attr("dy", ".71em").attr("text-anchor", "middle"); + pathUpdate.attr("d", "M" + range[0] + "," + tickEndSize + "V0H" + range[1] + "V" + tickEndSize); + break; + } + case "top": + { + tickTransform = d3_svg_axisX; + subtickEnter.attr("y2", -tickMinorSize); + subtickUpdate.attr("x2", 0).attr("y2", -tickMinorSize); + lineEnter.attr("y2", -tickMajorSize); + textEnter.attr("y", -(Math.max(tickMajorSize, 0) + tickPadding)); + lineUpdate.attr("x2", 0).attr("y2", -tickMajorSize); + textUpdate.attr("x", 0).attr("y", -(Math.max(tickMajorSize, 0) + tickPadding)); + text.attr("dy", "0em").attr("text-anchor", "middle"); + pathUpdate.attr("d", "M" + range[0] + "," + -tickEndSize + "V0H" + range[1] + "V" + -tickEndSize); + break; + } + case "left": + { + tickTransform = d3_svg_axisY; + subtickEnter.attr("x2", -tickMinorSize); + subtickUpdate.attr("x2", -tickMinorSize).attr("y2", 0); + lineEnter.attr("x2", -tickMajorSize); + textEnter.attr("x", -(Math.max(tickMajorSize, 0) + tickPadding)); + lineUpdate.attr("x2", -tickMajorSize).attr("y2", 0); + textUpdate.attr("x", -(Math.max(tickMajorSize, 0) + tickPadding)).attr("y", 0); + text.attr("dy", ".32em").attr("text-anchor", "end"); + pathUpdate.attr("d", "M" + -tickEndSize + "," + range[0] + "H0V" + range[1] + "H" + -tickEndSize); + break; + } + case "right": + { + tickTransform = d3_svg_axisY; + subtickEnter.attr("x2", tickMinorSize); + subtickUpdate.attr("x2", tickMinorSize).attr("y2", 0); + lineEnter.attr("x2", tickMajorSize); + textEnter.attr("x", Math.max(tickMajorSize, 0) + tickPadding); + lineUpdate.attr("x2", tickMajorSize).attr("y2", 0); + textUpdate.attr("x", Math.max(tickMajorSize, 0) + tickPadding).attr("y", 0); + text.attr("dy", ".32em").attr("text-anchor", "start"); + pathUpdate.attr("d", "M" + tickEndSize + "," + range[0] + "H0V" + range[1] + "H" + tickEndSize); + break; + } + } + if (scale.ticks) { + tickEnter.call(tickTransform, scale0); + tickUpdate.call(tickTransform, scale1); + tickExit.call(tickTransform, scale1); + subtickEnter.call(tickTransform, scale0); + subtickUpdate.call(tickTransform, scale1); + subtickExit.call(tickTransform, scale1); + } else { + var dx = scale1.rangeBand() / 2, x = function(d) { + return scale1(d) + dx; + }; + tickEnter.call(tickTransform, x); + tickUpdate.call(tickTransform, x); + } + }); + } + var scale = d3.scale.linear(), orient = "bottom", tickMajorSize = 6, tickMinorSize = 6, tickEndSize = 6, tickPadding = 3, tickArguments_ = [ 10 ], tickValues = null, tickFormat_, tickSubdivide = 0; + axis.scale = function(x) { + if (!arguments.length) return scale; + scale = x; + return axis; + }; + axis.orient = function(x) { + if (!arguments.length) return orient; + orient = x; + return axis; + }; + axis.ticks = function() { + if (!arguments.length) return tickArguments_; + tickArguments_ = arguments; + return axis; + }; + axis.tickValues = function(x) { + if (!arguments.length) return tickValues; + tickValues = x; + return axis; + }; + axis.tickFormat = function(x) { + if (!arguments.length) return tickFormat_; + tickFormat_ = x; + return axis; + }; + axis.tickSize = function(x, y, z) { + if (!arguments.length) return tickMajorSize; + var n = arguments.length - 1; + tickMajorSize = +x; + tickMinorSize = n > 1 ? +y : tickMajorSize; + tickEndSize = n > 0 ? +arguments[n] : tickMajorSize; + return axis; + }; + axis.tickPadding = function(x) { + if (!arguments.length) return tickPadding; + tickPadding = +x; + return axis; + }; + axis.tickSubdivide = function(x) { + if (!arguments.length) return tickSubdivide; + tickSubdivide = +x; + return axis; + }; + return axis; + }; + d3.svg.brush = function() { + function brush(g) { + g.each(function() { + var g = d3.select(this), bg = g.selectAll(".background").data([ 0 ]), fg = g.selectAll(".extent").data([ 0 ]), tz = g.selectAll(".resize").data(resizes, String), e; + g.style("pointer-events", "all").on("mousedown.brush", brushstart).on("touchstart.brush", brushstart); + bg.enter().append("rect").attr("class", "background").style("visibility", "hidden").style("cursor", "crosshair"); + fg.enter().append("rect").attr("class", "extent").style("cursor", "move"); + tz.enter().append("g").attr("class", function(d) { + return "resize " + d; + }).style("cursor", function(d) { + return d3_svg_brushCursor[d]; + }).append("rect").attr("x", function(d) { + return /[ew]$/.test(d) ? -3 : null; + }).attr("y", function(d) { + return /^[ns]/.test(d) ? -3 : null; + }).attr("width", 6).attr("height", 6).style("visibility", "hidden"); + tz.style("display", brush.empty() ? "none" : null); + tz.exit().remove(); + if (x) { + e = d3_scaleRange(x); + bg.attr("x", e[0]).attr("width", e[1] - e[0]); + redrawX(g); + } + if (y) { + e = d3_scaleRange(y); + bg.attr("y", e[0]).attr("height", e[1] - e[0]); + redrawY(g); + } + redraw(g); + }); + } + function redraw(g) { + g.selectAll(".resize").attr("transform", function(d) { + return "translate(" + extent[+/e$/.test(d)][0] + "," + extent[+/^s/.test(d)][1] + ")"; + }); + } + function redrawX(g) { + g.select(".extent").attr("x", extent[0][0]); + g.selectAll(".extent,.n>rect,.s>rect").attr("width", extent[1][0] - extent[0][0]); + } + function redrawY(g) { + g.select(".extent").attr("y", extent[0][1]); + g.selectAll(".extent,.e>rect,.w>rect").attr("height", extent[1][1] - extent[0][1]); + } + function brushstart() { + function mouse() { + var touches = d3.event.changedTouches; + return touches ? d3.touches(target, touches)[0] : d3.mouse(target); + } + function keydown() { + if (d3.event.keyCode == 32) { + if (!dragging) { + center = null; + origin[0] -= extent[1][0]; + origin[1] -= extent[1][1]; + dragging = 2; + } + d3_eventCancel(); + } + } + function keyup() { + if (d3.event.keyCode == 32 && dragging == 2) { + origin[0] += extent[1][0]; + origin[1] += extent[1][1]; + dragging = 0; + d3_eventCancel(); + } + } + function brushmove() { + var point = mouse(), moved = false; + if (offset) { + point[0] += offset[0]; + point[1] += offset[1]; + } + if (!dragging) { + if (d3.event.altKey) { + if (!center) center = [ (extent[0][0] + extent[1][0]) / 2, (extent[0][1] + extent[1][1]) / 2 ]; + origin[0] = extent[+(point[0] < center[0])][0]; + origin[1] = extent[+(point[1] < center[1])][1]; + } else center = null; + } + if (resizingX && move1(point, x, 0)) { + redrawX(g); + moved = true; + } + if (resizingY && move1(point, y, 1)) { + redrawY(g); + moved = true; + } + if (moved) { + redraw(g); + event_({ + type: "brush", + mode: dragging ? "move" : "resize" + }); + } + } + function move1(point, scale, i) { + var range = d3_scaleRange(scale), r0 = range[0], r1 = range[1], position = origin[i], size = extent[1][i] - extent[0][i], min, max; + if (dragging) { + r0 -= position; + r1 -= size + position; + } + min = Math.max(r0, Math.min(r1, point[i])); + if (dragging) { + max = (min += position) + size; + } else { + if (center) position = Math.max(r0, Math.min(r1, 2 * center[i] - min)); + if (position < min) { + max = min; + min = position; + } else { + max = position; + } + } + if (extent[0][i] !== min || extent[1][i] !== max) { + extentDomain = null; + extent[0][i] = min; + extent[1][i] = max; + return true; + } + } + function brushend() { + brushmove(); + g.style("pointer-events", "all").selectAll(".resize").style("display", brush.empty() ? "none" : null); + d3.select("body").style("cursor", null); + w.on("mousemove.brush", null).on("mouseup.brush", null).on("touchmove.brush", null).on("touchend.brush", null).on("keydown.brush", null).on("keyup.brush", null); + event_({ + type: "brushend" + }); + d3_eventCancel(); + } + var target = this, eventTarget = d3.select(d3.event.target), event_ = event.of(target, arguments), g = d3.select(target), resizing = eventTarget.datum(), resizingX = !/^(n|s)$/.test(resizing) && x, resizingY = !/^(e|w)$/.test(resizing) && y, dragging = eventTarget.classed("extent"), center, origin = mouse(), offset; + var w = d3.select(window).on("mousemove.brush", brushmove).on("mouseup.brush", brushend).on("touchmove.brush", brushmove).on("touchend.brush", brushend).on("keydown.brush", keydown).on("keyup.brush", keyup); + if (dragging) { + origin[0] = extent[0][0] - origin[0]; + origin[1] = extent[0][1] - origin[1]; + } else if (resizing) { + var ex = +/w$/.test(resizing), ey = +/^n/.test(resizing); + offset = [ extent[1 - ex][0] - origin[0], extent[1 - ey][1] - origin[1] ]; + origin[0] = extent[ex][0]; + origin[1] = extent[ey][1]; + } else if (d3.event.altKey) center = origin.slice(); + g.style("pointer-events", "none").selectAll(".resize").style("display", null); + d3.select("body").style("cursor", eventTarget.style("cursor")); + event_({ + type: "brushstart" + }); + brushmove(); + d3_eventCancel(); + } + var event = d3_eventDispatch(brush, "brushstart", "brush", "brushend"), x = null, y = null, resizes = d3_svg_brushResizes[0], extent = [ [ 0, 0 ], [ 0, 0 ] ], extentDomain; + brush.x = function(z) { + if (!arguments.length) return x; + x = z; + resizes = d3_svg_brushResizes[!x << 1 | !y]; + return brush; + }; + brush.y = function(z) { + if (!arguments.length) return y; + y = z; + resizes = d3_svg_brushResizes[!x << 1 | !y]; + return brush; + }; + brush.extent = function(z) { + var x0, x1, y0, y1, t; + if (!arguments.length) { + z = extentDomain || extent; + if (x) { + x0 = z[0][0], x1 = z[1][0]; + if (!extentDomain) { + x0 = extent[0][0], x1 = extent[1][0]; + if (x.invert) x0 = x.invert(x0), x1 = x.invert(x1); + if (x1 < x0) t = x0, x0 = x1, x1 = t; + } + } + if (y) { + y0 = z[0][1], y1 = z[1][1]; + if (!extentDomain) { + y0 = extent[0][1], y1 = extent[1][1]; + if (y.invert) y0 = y.invert(y0), y1 = y.invert(y1); + if (y1 < y0) t = y0, y0 = y1, y1 = t; + } + } + return x && y ? [ [ x0, y0 ], [ x1, y1 ] ] : x ? [ x0, x1 ] : y && [ y0, y1 ]; + } + extentDomain = [ [ 0, 0 ], [ 0, 0 ] ]; + if (x) { + x0 = z[0], x1 = z[1]; + if (y) x0 = x0[0], x1 = x1[0]; + extentDomain[0][0] = x0, extentDomain[1][0] = x1; + if (x.invert) x0 = x(x0), x1 = x(x1); + if (x1 < x0) t = x0, x0 = x1, x1 = t; + extent[0][0] = x0 | 0, extent[1][0] = x1 | 0; + } + if (y) { + y0 = z[0], y1 = z[1]; + if (x) y0 = y0[1], y1 = y1[1]; + extentDomain[0][1] = y0, extentDomain[1][1] = y1; + if (y.invert) y0 = y(y0), y1 = y(y1); + if (y1 < y0) t = y0, y0 = y1, y1 = t; + extent[0][1] = y0 | 0, extent[1][1] = y1 | 0; + } + return brush; + }; + brush.clear = function() { + extentDomain = null; + extent[0][0] = extent[0][1] = extent[1][0] = extent[1][1] = 0; + return brush; + }; + brush.empty = function() { + return x && extent[0][0] === extent[1][0] || y && extent[0][1] === extent[1][1]; + }; + return d3.rebind(brush, event, "on"); + }; + var d3_svg_brushCursor = { + n: "ns-resize", + e: "ew-resize", + s: "ns-resize", + w: "ew-resize", + nw: "nwse-resize", + ne: "nesw-resize", + se: "nwse-resize", + sw: "nesw-resize" + }; + var d3_svg_brushResizes = [ [ "n", "e", "s", "w", "nw", "ne", "se", "sw" ], [ "e", "w" ], [ "n", "s" ], [] ]; + d3.behavior = {}; + d3.behavior.drag = function() { + function drag() { + this.on("mousedown.drag", mousedown).on("touchstart.drag", mousedown); + } + function mousedown() { + function point() { + var p = target.parentNode; + return touchId ? d3.touches(p).filter(function(p) { + return p.identifier === touchId; + })[0] : d3.mouse(p); + } + function dragmove() { + if (!target.parentNode) return dragend(); + var p = point(), dx = p[0] - origin_[0], dy = p[1] - origin_[1]; + moved |= dx | dy; + origin_ = p; + d3_eventCancel(); + event_({ + type: "drag", + x: p[0] + offset[0], + y: p[1] + offset[1], + dx: dx, + dy: dy + }); + } + function dragend() { + event_({ + type: "dragend" + }); + if (moved) { + d3_eventCancel(); + if (d3.event.target === eventTarget) w.on("click.drag", click, true); + } + w.on(touchId ? "touchmove.drag-" + touchId : "mousemove.drag", null).on(touchId ? "touchend.drag-" + touchId : "mouseup.drag", null); + } + function click() { + d3_eventCancel(); + w.on("click.drag", null); + } + var target = this, event_ = event.of(target, arguments), eventTarget = d3.event.target, touchId = d3.event.touches && d3.event.changedTouches[0].identifier, offset, origin_ = point(), moved = 0; + var w = d3.select(window).on(touchId ? "touchmove.drag-" + touchId : "mousemove.drag", dragmove).on(touchId ? "touchend.drag-" + touchId : "mouseup.drag", dragend, true); + if (origin) { + offset = origin.apply(target, arguments); + offset = [ offset.x - origin_[0], offset.y - origin_[1] ]; + } else { + offset = [ 0, 0 ]; + } + if (!touchId) d3_eventCancel(); + event_({ + type: "dragstart" + }); + } + var event = d3_eventDispatch(drag, "drag", "dragstart", "dragend"), origin = null; + drag.origin = function(x) { + if (!arguments.length) return origin; + origin = x; + return drag; + }; + return d3.rebind(drag, event, "on"); + }; + d3.behavior.zoom = function() { + function zoom() { + this.on("mousedown.zoom", mousedown).on("mousewheel.zoom", mousewheel).on("mousemove.zoom", mousemove).on("DOMMouseScroll.zoom", mousewheel).on("dblclick.zoom", dblclick).on("touchstart.zoom", touchstart).on("touchmove.zoom", touchmove).on("touchend.zoom", touchstart); + } + function location(p) { + return [ (p[0] - translate[0]) / scale, (p[1] - translate[1]) / scale ]; + } + function point(l) { + return [ l[0] * scale + translate[0], l[1] * scale + translate[1] ]; + } + function scaleTo(s) { + scale = Math.max(scaleExtent[0], Math.min(scaleExtent[1], s)); + } + function translateTo(p, l) { + l = point(l); + translate[0] += p[0] - l[0]; + translate[1] += p[1] - l[1]; + } + function dispatch(event) { + if (x1) x1.domain(x0.range().map(function(x) { + return (x - translate[0]) / scale; + }).map(x0.invert)); + if (y1) y1.domain(y0.range().map(function(y) { + return (y - translate[1]) / scale; + }).map(y0.invert)); + d3.event.preventDefault(); + event({ + type: "zoom", + scale: scale, + translate: translate + }); + } + function mousedown() { + function mousemove() { + moved = 1; + translateTo(d3.mouse(target), l); + dispatch(event_); + } + function mouseup() { + if (moved) d3_eventCancel(); + w.on("mousemove.zoom", null).on("mouseup.zoom", null); + if (moved && d3.event.target === eventTarget) w.on("click.zoom", click, true); + } + function click() { + d3_eventCancel(); + w.on("click.zoom", null); + } + var target = this, event_ = event.of(target, arguments), eventTarget = d3.event.target, moved = 0, w = d3.select(window).on("mousemove.zoom", mousemove).on("mouseup.zoom", mouseup), l = location(d3.mouse(target)); + window.focus(); + d3_eventCancel(); + } + function mousewheel() { + if (!translate0) translate0 = location(d3.mouse(this)); + scaleTo(Math.pow(2, d3_behavior_zoomDelta() * .002) * scale); + translateTo(d3.mouse(this), translate0); + dispatch(event.of(this, arguments)); + } + function mousemove() { + translate0 = null; + } + function dblclick() { + var p = d3.mouse(this), l = location(p); + scaleTo(d3.event.shiftKey ? scale / 2 : scale * 2); + translateTo(p, l); + dispatch(event.of(this, arguments)); + } + function touchstart() { + var touches = d3.touches(this), now = Date.now(); + scale0 = scale; + translate0 = {}; + touches.forEach(function(t) { + translate0[t.identifier] = location(t); + }); + d3_eventCancel(); + if (touches.length === 1) { + if (now - touchtime < 500) { + var p = touches[0], l = location(touches[0]); + scaleTo(scale * 2); + translateTo(p, l); + dispatch(event.of(this, arguments)); + } + touchtime = now; + } + } + function touchmove() { + var touches = d3.touches(this), p0 = touches[0], l0 = translate0[p0.identifier]; + if (p1 = touches[1]) { + var p1, l1 = translate0[p1.identifier]; + p0 = [ (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2 ]; + l0 = [ (l0[0] + l1[0]) / 2, (l0[1] + l1[1]) / 2 ]; + scaleTo(d3.event.scale * scale0); + } + translateTo(p0, l0); + touchtime = null; + dispatch(event.of(this, arguments)); + } + var translate = [ 0, 0 ], translate0, scale = 1, scale0, scaleExtent = d3_behavior_zoomInfinity, event = d3_eventDispatch(zoom, "zoom"), x0, x1, y0, y1, touchtime; + zoom.translate = function(x) { + if (!arguments.length) return translate; + translate = x.map(Number); + return zoom; + }; + zoom.scale = function(x) { + if (!arguments.length) return scale; + scale = +x; + return zoom; + }; + zoom.scaleExtent = function(x) { + if (!arguments.length) return scaleExtent; + scaleExtent = x == null ? d3_behavior_zoomInfinity : x.map(Number); + return zoom; + }; + zoom.x = function(z) { + if (!arguments.length) return x1; + x1 = z; + x0 = z.copy(); + return zoom; + }; + zoom.y = function(z) { + if (!arguments.length) return y1; + y1 = z; + y0 = z.copy(); + return zoom; + }; + return d3.rebind(zoom, event, "on"); + }; + var d3_behavior_zoomDiv, d3_behavior_zoomInfinity = [ 0, Infinity ]; + d3.layout = {}; + d3.layout.bundle = function() { + return function(links) { + var paths = [], i = -1, n = links.length; + while (++i < n) paths.push(d3_layout_bundlePath(links[i])); + return paths; + }; + }; + d3.layout.chord = function() { + function relayout() { + var subgroups = {}, groupSums = [], groupIndex = d3.range(n), subgroupIndex = [], k, x, x0, i, j; + chords = []; + groups = []; + k = 0, i = -1; + while (++i < n) { + x = 0, j = -1; + while (++j < n) { + x += matrix[i][j]; + } + groupSums.push(x); + subgroupIndex.push(d3.range(n)); + k += x; + } + if (sortGroups) { + groupIndex.sort(function(a, b) { + return sortGroups(groupSums[a], groupSums[b]); + }); + } + if (sortSubgroups) { + subgroupIndex.forEach(function(d, i) { + d.sort(function(a, b) { + return sortSubgroups(matrix[i][a], matrix[i][b]); + }); + }); + } + k = (2 * Math.PI - padding * n) / k; + x = 0, i = -1; + while (++i < n) { + x0 = x, j = -1; + while (++j < n) { + var di = groupIndex[i], dj = subgroupIndex[di][j], v = matrix[di][dj], a0 = x, a1 = x += v * k; + subgroups[di + "-" + dj] = { + index: di, + subindex: dj, + startAngle: a0, + endAngle: a1, + value: v + }; + } + groups[di] = { + index: di, + startAngle: x0, + endAngle: x, + value: (x - x0) / k + }; + x += padding; + } + i = -1; + while (++i < n) { + j = i - 1; + while (++j < n) { + var source = subgroups[i + "-" + j], target = subgroups[j + "-" + i]; + if (source.value || target.value) { + chords.push(source.value < target.value ? { + source: target, + target: source + } : { + source: source, + target: target + }); + } + } + } + if (sortChords) resort(); + } + function resort() { + chords.sort(function(a, b) { + return sortChords((a.source.value + a.target.value) / 2, (b.source.value + b.target.value) / 2); + }); + } + var chord = {}, chords, groups, matrix, n, padding = 0, sortGroups, sortSubgroups, sortChords; + chord.matrix = function(x) { + if (!arguments.length) return matrix; + n = (matrix = x) && matrix.length; + chords = groups = null; + return chord; + }; + chord.padding = function(x) { + if (!arguments.length) return padding; + padding = x; + chords = groups = null; + return chord; + }; + chord.sortGroups = function(x) { + if (!arguments.length) return sortGroups; + sortGroups = x; + chords = groups = null; + return chord; + }; + chord.sortSubgroups = function(x) { + if (!arguments.length) return sortSubgroups; + sortSubgroups = x; + chords = null; + return chord; + }; + chord.sortChords = function(x) { + if (!arguments.length) return sortChords; + sortChords = x; + if (chords) resort(); + return chord; + }; + chord.chords = function() { + if (!chords) relayout(); + return chords; + }; + chord.groups = function() { + if (!groups) relayout(); + return groups; + }; + return chord; + }; + d3.layout.force = function() { + function repulse(node) { + return function(quad, x1, y1, x2, y2) { + if (quad.point !== node) { + var dx = quad.cx - node.x, dy = quad.cy - node.y, dn = 1 / Math.sqrt(dx * dx + dy * dy); + if ((x2 - x1) * dn < theta) { + var k = quad.charge * dn * dn; + node.px -= dx * k; + node.py -= dy * k; + return true; + } + if (quad.point && isFinite(dn)) { + var k = quad.pointCharge * dn * dn; + node.px -= dx * k; + node.py -= dy * k; + } + } + return !quad.charge; + }; + } + function dragmove(d) { + d.px = d3.event.x; + d.py = d3.event.y; + force.resume(); + } + var force = {}, event = d3.dispatch("start", "tick", "end"), size = [ 1, 1 ], drag, alpha, friction = .9, linkDistance = d3_layout_forceLinkDistance, linkStrength = d3_layout_forceLinkStrength, charge = -30, gravity = .1, theta = .8, interval, nodes = [], links = [], distances, strengths, charges; + force.tick = function() { + if ((alpha *= .99) < .005) { + event.end({ + type: "end", + alpha: alpha = 0 + }); + return true; + } + var n = nodes.length, m = links.length, q, i, o, s, t, l, k, x, y; + for (i = 0; i < m; ++i) { + o = links[i]; + s = o.source; + t = o.target; + x = t.x - s.x; + y = t.y - s.y; + if (l = x * x + y * y) { + l = alpha * strengths[i] * ((l = Math.sqrt(l)) - distances[i]) / l; + x *= l; + y *= l; + t.x -= x * (k = s.weight / (t.weight + s.weight)); + t.y -= y * k; + s.x += x * (k = 1 - k); + s.y += y * k; + } + } + if (k = alpha * gravity) { + x = size[0] / 2; + y = size[1] / 2; + i = -1; + if (k) while (++i < n) { + o = nodes[i]; + o.x += (x - o.x) * k; + o.y += (y - o.y) * k; + } + } + if (charge) { + d3_layout_forceAccumulate(q = d3.geom.quadtree(nodes), alpha, charges); + i = -1; + while (++i < n) { + if (!(o = nodes[i]).fixed) { + q.visit(repulse(o)); + } + } + } + i = -1; + while (++i < n) { + o = nodes[i]; + if (o.fixed) { + o.x = o.px; + o.y = o.py; + } else { + o.x -= (o.px - (o.px = o.x)) * friction; + o.y -= (o.py - (o.py = o.y)) * friction; + } + } + event.tick({ + type: "tick", + alpha: alpha + }); + }; + force.nodes = function(x) { + if (!arguments.length) return nodes; + nodes = x; + return force; + }; + force.links = function(x) { + if (!arguments.length) return links; + links = x; + return force; + }; + force.size = function(x) { + if (!arguments.length) return size; + size = x; + return force; + }; + force.linkDistance = function(x) { + if (!arguments.length) return linkDistance; + linkDistance = d3_functor(x); + return force; + }; + force.distance = force.linkDistance; + force.linkStrength = function(x) { + if (!arguments.length) return linkStrength; + linkStrength = d3_functor(x); + return force; + }; + force.friction = function(x) { + if (!arguments.length) return friction; + friction = x; + return force; + }; + force.charge = function(x) { + if (!arguments.length) return charge; + charge = typeof x === "function" ? x : +x; + return force; + }; + force.gravity = function(x) { + if (!arguments.length) return gravity; + gravity = x; + return force; + }; + force.theta = function(x) { + if (!arguments.length) return theta; + theta = x; + return force; + }; + force.alpha = function(x) { + if (!arguments.length) return alpha; + if (alpha) { + if (x > 0) alpha = x; else alpha = 0; + } else if (x > 0) { + event.start({ + type: "start", + alpha: alpha = x + }); + d3.timer(force.tick); + } + return force; + }; + force.start = function() { + function position(dimension, size) { + var neighbors = neighbor(i), j = -1, m = neighbors.length, x; + while (++j < m) if (!isNaN(x = neighbors[j][dimension])) return x; + return Math.random() * size; + } + function neighbor() { + if (!neighbors) { + neighbors = []; + for (j = 0; j < n; ++j) { + neighbors[j] = []; + } + for (j = 0; j < m; ++j) { + var o = links[j]; + neighbors[o.source.index].push(o.target); + neighbors[o.target.index].push(o.source); + } + } + return neighbors[i]; + } + var i, j, n = nodes.length, m = links.length, w = size[0], h = size[1], neighbors, o; + for (i = 0; i < n; ++i) { + (o = nodes[i]).index = i; + o.weight = 0; + } + distances = []; + strengths = []; + for (i = 0; i < m; ++i) { + o = links[i]; + if (typeof o.source == "number") o.source = nodes[o.source]; + if (typeof o.target == "number") o.target = nodes[o.target]; + distances[i] = linkDistance.call(this, o, i); + strengths[i] = linkStrength.call(this, o, i); + ++o.source.weight; + ++o.target.weight; + } + for (i = 0; i < n; ++i) { + o = nodes[i]; + if (isNaN(o.x)) o.x = position("x", w); + if (isNaN(o.y)) o.y = position("y", h); + if (isNaN(o.px)) o.px = o.x; + if (isNaN(o.py)) o.py = o.y; + } + charges = []; + if (typeof charge === "function") { + for (i = 0; i < n; ++i) { + charges[i] = +charge.call(this, nodes[i], i); + } + } else { + for (i = 0; i < n; ++i) { + charges[i] = charge; + } + } + return force.resume(); + }; + force.resume = function() { + return force.alpha(.1); + }; + force.stop = function() { + return force.alpha(0); + }; + force.drag = function() { + if (!drag) drag = d3.behavior.drag().origin(d3_identity).on("dragstart", d3_layout_forceDragstart).on("drag", dragmove).on("dragend", d3_layout_forceDragend); + this.on("mouseover.force", d3_layout_forceMouseover).on("mouseout.force", d3_layout_forceMouseout).call(drag); + }; + return d3.rebind(force, event, "on"); + }; + d3.layout.partition = function() { + function position(node, x, dx, dy) { + var children = node.children; + node.x = x; + node.y = node.depth * dy; + node.dx = dx; + node.dy = dy; + if (children && (n = children.length)) { + var i = -1, n, c, d; + dx = node.value ? dx / node.value : 0; + while (++i < n) { + position(c = children[i], x, d = c.value * dx, dy); + x += d; + } + } + } + function depth(node) { + var children = node.children, d = 0; + if (children && (n = children.length)) { + var i = -1, n; + while (++i < n) d = Math.max(d, depth(children[i])); + } + return 1 + d; + } + function partition(d, i) { + var nodes = hierarchy.call(this, d, i); + position(nodes[0], 0, size[0], size[1] / depth(nodes[0])); + return nodes; + } + var hierarchy = d3.layout.hierarchy(), size = [ 1, 1 ]; + partition.size = function(x) { + if (!arguments.length) return size; + size = x; + return partition; + }; + return d3_layout_hierarchyRebind(partition, hierarchy); + }; + d3.layout.pie = function() { + function pie(data, i) { + var values = data.map(function(d, i) { + return +value.call(pie, d, i); + }); + var a = +(typeof startAngle === "function" ? startAngle.apply(this, arguments) : startAngle); + var k = ((typeof endAngle === "function" ? endAngle.apply(this, arguments) : endAngle) - startAngle) / d3.sum(values); + var index = d3.range(data.length); + if (sort != null) index.sort(sort === d3_layout_pieSortByValue ? function(i, j) { + return values[j] - values[i]; + } : function(i, j) { + return sort(data[i], data[j]); + }); + var arcs = []; + index.forEach(function(i) { + var d; + arcs[i] = { + data: data[i], + value: d = values[i], + startAngle: a, + endAngle: a += d * k + }; + }); + return arcs; + } + var value = Number, sort = d3_layout_pieSortByValue, startAngle = 0, endAngle = 2 * Math.PI; + pie.value = function(x) { + if (!arguments.length) return value; + value = x; + return pie; + }; + pie.sort = function(x) { + if (!arguments.length) return sort; + sort = x; + return pie; + }; + pie.startAngle = function(x) { + if (!arguments.length) return startAngle; + startAngle = x; + return pie; + }; + pie.endAngle = function(x) { + if (!arguments.length) return endAngle; + endAngle = x; + return pie; + }; + return pie; + }; + var d3_layout_pieSortByValue = {}; + d3.layout.stack = function() { + function stack(data, index) { + var series = data.map(function(d, i) { + return values.call(stack, d, i); + }); + var points = series.map(function(d, i) { + return d.map(function(v, i) { + return [ x.call(stack, v, i), y.call(stack, v, i) ]; + }); + }); + var orders = order.call(stack, points, index); + series = d3.permute(series, orders); + points = d3.permute(points, orders); + var offsets = offset.call(stack, points, index); + var n = series.length, m = series[0].length, i, j, o; + for (j = 0; j < m; ++j) { + out.call(stack, series[0][j], o = offsets[j], points[0][j][1]); + for (i = 1; i < n; ++i) { + out.call(stack, series[i][j], o += points[i - 1][j][1], points[i][j][1]); + } + } + return data; + } + var values = d3_identity, order = d3_layout_stackOrderDefault, offset = d3_layout_stackOffsetZero, out = d3_layout_stackOut, x = d3_layout_stackX, y = d3_layout_stackY; + stack.values = function(x) { + if (!arguments.length) return values; + values = x; + return stack; + }; + stack.order = function(x) { + if (!arguments.length) return order; + order = typeof x === "function" ? x : d3_layout_stackOrders.get(x) || d3_layout_stackOrderDefault; + return stack; + }; + stack.offset = function(x) { + if (!arguments.length) return offset; + offset = typeof x === "function" ? x : d3_layout_stackOffsets.get(x) || d3_layout_stackOffsetZero; + return stack; + }; + stack.x = function(z) { + if (!arguments.length) return x; + x = z; + return stack; + }; + stack.y = function(z) { + if (!arguments.length) return y; + y = z; + return stack; + }; + stack.out = function(z) { + if (!arguments.length) return out; + out = z; + return stack; + }; + return stack; + }; + var d3_layout_stackOrders = d3.map({ + "inside-out": function(data) { + var n = data.length, i, j, max = data.map(d3_layout_stackMaxIndex), sums = data.map(d3_layout_stackReduceSum), index = d3.range(n).sort(function(a, b) { + return max[a] - max[b]; + }), top = 0, bottom = 0, tops = [], bottoms = []; + for (i = 0; i < n; ++i) { + j = index[i]; + if (top < bottom) { + top += sums[j]; + tops.push(j); + } else { + bottom += sums[j]; + bottoms.push(j); + } + } + return bottoms.reverse().concat(tops); + }, + reverse: function(data) { + return d3.range(data.length).reverse(); + }, + "default": d3_layout_stackOrderDefault + }); + var d3_layout_stackOffsets = d3.map({ + silhouette: function(data) { + var n = data.length, m = data[0].length, sums = [], max = 0, i, j, o, y0 = []; + for (j = 0; j < m; ++j) { + for (i = 0, o = 0; i < n; i++) o += data[i][j][1]; + if (o > max) max = o; + sums.push(o); + } + for (j = 0; j < m; ++j) { + y0[j] = (max - sums[j]) / 2; + } + return y0; + }, + wiggle: function(data) { + var n = data.length, x = data[0], m = x.length, max = 0, i, j, k, s1, s2, s3, dx, o, o0, y0 = []; + y0[0] = o = o0 = 0; + for (j = 1; j < m; ++j) { + for (i = 0, s1 = 0; i < n; ++i) s1 += data[i][j][1]; + for (i = 0, s2 = 0, dx = x[j][0] - x[j - 1][0]; i < n; ++i) { + for (k = 0, s3 = (data[i][j][1] - data[i][j - 1][1]) / (2 * dx); k < i; ++k) { + s3 += (data[k][j][1] - data[k][j - 1][1]) / dx; + } + s2 += s3 * data[i][j][1]; + } + y0[j] = o -= s1 ? s2 / s1 * dx : 0; + if (o < o0) o0 = o; + } + for (j = 0; j < m; ++j) y0[j] -= o0; + return y0; + }, + expand: function(data) { + var n = data.length, m = data[0].length, k = 1 / n, i, j, o, y0 = []; + for (j = 0; j < m; ++j) { + for (i = 0, o = 0; i < n; i++) o += data[i][j][1]; + if (o) for (i = 0; i < n; i++) data[i][j][1] /= o; else for (i = 0; i < n; i++) data[i][j][1] = k; + } + for (j = 0; j < m; ++j) y0[j] = 0; + return y0; + }, + zero: d3_layout_stackOffsetZero + }); + d3.layout.histogram = function() { + function histogram(data, i) { + var bins = [], values = data.map(valuer, this), range = ranger.call(this, values, i), thresholds = binner.call(this, range, values, i), bin, i = -1, n = values.length, m = thresholds.length - 1, k = frequency ? 1 : 1 / n, x; + while (++i < m) { + bin = bins[i] = []; + bin.dx = thresholds[i + 1] - (bin.x = thresholds[i]); + bin.y = 0; + } + if (m > 0) { + i = -1; + while (++i < n) { + x = values[i]; + if (x >= range[0] && x <= range[1]) { + bin = bins[d3.bisect(thresholds, x, 1, m) - 1]; + bin.y += k; + bin.push(data[i]); + } + } + } + return bins; + } + var frequency = true, valuer = Number, ranger = d3_layout_histogramRange, binner = d3_layout_histogramBinSturges; + histogram.value = function(x) { + if (!arguments.length) return valuer; + valuer = x; + return histogram; + }; + histogram.range = function(x) { + if (!arguments.length) return ranger; + ranger = d3_functor(x); + return histogram; + }; + histogram.bins = function(x) { + if (!arguments.length) return binner; + binner = typeof x === "number" ? function(range) { + return d3_layout_histogramBinFixed(range, x); + } : d3_functor(x); + return histogram; + }; + histogram.frequency = function(x) { + if (!arguments.length) return frequency; + frequency = !!x; + return histogram; + }; + return histogram; + }; + d3.layout.hierarchy = function() { + function recurse(data, depth, nodes) { + var childs = children.call(hierarchy, data, depth), node = d3_layout_hierarchyInline ? data : { + data: data + }; + node.depth = depth; + nodes.push(node); + if (childs && (n = childs.length)) { + var i = -1, n, c = node.children = [], v = 0, j = depth + 1, d; + while (++i < n) { + d = recurse(childs[i], j, nodes); + d.parent = node; + c.push(d); + v += d.value; + } + if (sort) c.sort(sort); + if (value) node.value = v; + } else if (value) { + node.value = +value.call(hierarchy, data, depth) || 0; + } + return node; + } + function revalue(node, depth) { + var children = node.children, v = 0; + if (children && (n = children.length)) { + var i = -1, n, j = depth + 1; + while (++i < n) v += revalue(children[i], j); + } else if (value) { + v = +value.call(hierarchy, d3_layout_hierarchyInline ? node : node.data, depth) || 0; + } + if (value) node.value = v; + return v; + } + function hierarchy(d) { + var nodes = []; + recurse(d, 0, nodes); + return nodes; + } + var sort = d3_layout_hierarchySort, children = d3_layout_hierarchyChildren, value = d3_layout_hierarchyValue; + hierarchy.sort = function(x) { + if (!arguments.length) return sort; + sort = x; + return hierarchy; + }; + hierarchy.children = function(x) { + if (!arguments.length) return children; + children = x; + return hierarchy; + }; + hierarchy.value = function(x) { + if (!arguments.length) return value; + value = x; + return hierarchy; + }; + hierarchy.revalue = function(root) { + revalue(root, 0); + return root; + }; + return hierarchy; + }; + var d3_layout_hierarchyInline = false; + d3.layout.pack = function() { + function pack(d, i) { + var nodes = hierarchy.call(this, d, i), root = nodes[0]; + root.x = 0; + root.y = 0; + d3_layout_treeVisitAfter(root, function(d) { + d.r = Math.sqrt(d.value); + }); + d3_layout_treeVisitAfter(root, d3_layout_packSiblings); + var w = size[0], h = size[1], k = Math.max(2 * root.r / w, 2 * root.r / h); + if (padding > 0) { + var dr = padding * k / 2; + d3_layout_treeVisitAfter(root, function(d) { + d.r += dr; + }); + d3_layout_treeVisitAfter(root, d3_layout_packSiblings); + d3_layout_treeVisitAfter(root, function(d) { + d.r -= dr; + }); + k = Math.max(2 * root.r / w, 2 * root.r / h); + } + d3_layout_packTransform(root, w / 2, h / 2, 1 / k); + return nodes; + } + var hierarchy = d3.layout.hierarchy().sort(d3_layout_packSort), padding = 0, size = [ 1, 1 ]; + pack.size = function(x) { + if (!arguments.length) return size; + size = x; + return pack; + }; + pack.padding = function(_) { + if (!arguments.length) return padding; + padding = +_; + return pack; + }; + return d3_layout_hierarchyRebind(pack, hierarchy); + }; + d3.layout.cluster = function() { + function cluster(d, i) { + var nodes = hierarchy.call(this, d, i), root = nodes[0], previousNode, x = 0, kx, ky; + d3_layout_treeVisitAfter(root, function(node) { + var children = node.children; + if (children && children.length) { + node.x = d3_layout_clusterX(children); + node.y = d3_layout_clusterY(children); + } else { + node.x = previousNode ? x += separation(node, previousNode) : 0; + node.y = 0; + previousNode = node; + } + }); + var left = d3_layout_clusterLeft(root), right = d3_layout_clusterRight(root), x0 = left.x - separation(left, right) / 2, x1 = right.x + separation(right, left) / 2; + d3_layout_treeVisitAfter(root, function(node) { + node.x = (node.x - x0) / (x1 - x0) * size[0]; + node.y = (1 - (root.y ? node.y / root.y : 1)) * size[1]; + }); + return nodes; + } + var hierarchy = d3.layout.hierarchy().sort(null).value(null), separation = d3_layout_treeSeparation, size = [ 1, 1 ]; + cluster.separation = function(x) { + if (!arguments.length) return separation; + separation = x; + return cluster; + }; + cluster.size = function(x) { + if (!arguments.length) return size; + size = x; + return cluster; + }; + return d3_layout_hierarchyRebind(cluster, hierarchy); + }; + d3.layout.tree = function() { + function tree(d, i) { + function firstWalk(node, previousSibling) { + var children = node.children, layout = node._tree; + if (children && (n = children.length)) { + var n, firstChild = children[0], previousChild, ancestor = firstChild, child, i = -1; + while (++i < n) { + child = children[i]; + firstWalk(child, previousChild); + ancestor = apportion(child, previousChild, ancestor); + previousChild = child; + } + d3_layout_treeShift(node); + var midpoint = .5 * (firstChild._tree.prelim + child._tree.prelim); + if (previousSibling) { + layout.prelim = previousSibling._tree.prelim + separation(node, previousSibling); + layout.mod = layout.prelim - midpoint; + } else { + layout.prelim = midpoint; + } + } else { + if (previousSibling) { + layout.prelim = previousSibling._tree.prelim + separation(node, previousSibling); + } + } + } + function secondWalk(node, x) { + node.x = node._tree.prelim + x; + var children = node.children; + if (children && (n = children.length)) { + var i = -1, n; + x += node._tree.mod; + while (++i < n) { + secondWalk(children[i], x); + } + } + } + function apportion(node, previousSibling, ancestor) { + if (previousSibling) { + var vip = node, vop = node, vim = previousSibling, vom = node.parent.children[0], sip = vip._tree.mod, sop = vop._tree.mod, sim = vim._tree.mod, som = vom._tree.mod, shift; + while (vim = d3_layout_treeRight(vim), vip = d3_layout_treeLeft(vip), vim && vip) { + vom = d3_layout_treeLeft(vom); + vop = d3_layout_treeRight(vop); + vop._tree.ancestor = node; + shift = vim._tree.prelim + sim - vip._tree.prelim - sip + separation(vim, vip); + if (shift > 0) { + d3_layout_treeMove(d3_layout_treeAncestor(vim, node, ancestor), node, shift); + sip += shift; + sop += shift; + } + sim += vim._tree.mod; + sip += vip._tree.mod; + som += vom._tree.mod; + sop += vop._tree.mod; + } + if (vim && !d3_layout_treeRight(vop)) { + vop._tree.thread = vim; + vop._tree.mod += sim - sop; + } + if (vip && !d3_layout_treeLeft(vom)) { + vom._tree.thread = vip; + vom._tree.mod += sip - som; + ancestor = node; + } + } + return ancestor; + } + var nodes = hierarchy.call(this, d, i), root = nodes[0]; + d3_layout_treeVisitAfter(root, function(node, previousSibling) { + node._tree = { + ancestor: node, + prelim: 0, + mod: 0, + change: 0, + shift: 0, + number: previousSibling ? previousSibling._tree.number + 1 : 0 + }; + }); + firstWalk(root); + secondWalk(root, -root._tree.prelim); + var left = d3_layout_treeSearch(root, d3_layout_treeLeftmost), right = d3_layout_treeSearch(root, d3_layout_treeRightmost), deep = d3_layout_treeSearch(root, d3_layout_treeDeepest), x0 = left.x - separation(left, right) / 2, x1 = right.x + separation(right, left) / 2, y1 = deep.depth || 1; + d3_layout_treeVisitAfter(root, function(node) { + node.x = (node.x - x0) / (x1 - x0) * size[0]; + node.y = node.depth / y1 * size[1]; + delete node._tree; + }); + return nodes; + } + var hierarchy = d3.layout.hierarchy().sort(null).value(null), separation = d3_layout_treeSeparation, size = [ 1, 1 ]; + tree.separation = function(x) { + if (!arguments.length) return separation; + separation = x; + return tree; + }; + tree.size = function(x) { + if (!arguments.length) return size; + size = x; + return tree; + }; + return d3_layout_hierarchyRebind(tree, hierarchy); + }; + d3.layout.treemap = function() { + function scale(children, k) { + var i = -1, n = children.length, child, area; + while (++i < n) { + area = (child = children[i]).value * (k < 0 ? 0 : k); + child.area = isNaN(area) || area <= 0 ? 0 : area; + } + } + function squarify(node) { + var children = node.children; + if (children && children.length) { + var rect = pad(node), row = [], remaining = children.slice(), child, best = Infinity, score, u = Math.min(rect.dx, rect.dy), n; + scale(remaining, rect.dx * rect.dy / node.value); + row.area = 0; + while ((n = remaining.length) > 0) { + row.push(child = remaining[n - 1]); + row.area += child.area; + if ((score = worst(row, u)) <= best) { + remaining.pop(); + best = score; + } else { + row.area -= row.pop().area; + position(row, u, rect, false); + u = Math.min(rect.dx, rect.dy); + row.length = row.area = 0; + best = Infinity; + } + } + if (row.length) { + position(row, u, rect, true); + row.length = row.area = 0; + } + children.forEach(squarify); + } + } + function stickify(node) { + var children = node.children; + if (children && children.length) { + var rect = pad(node), remaining = children.slice(), child, row = []; + scale(remaining, rect.dx * rect.dy / node.value); + row.area = 0; + while (child = remaining.pop()) { + row.push(child); + row.area += child.area; + if (child.z != null) { + position(row, child.z ? rect.dx : rect.dy, rect, !remaining.length); + row.length = row.area = 0; + } + } + children.forEach(stickify); + } + } + function worst(row, u) { + var s = row.area, r, rmax = 0, rmin = Infinity, i = -1, n = row.length; + while (++i < n) { + if (!(r = row[i].area)) continue; + if (r < rmin) rmin = r; + if (r > rmax) rmax = r; + } + s *= s; + u *= u; + return s ? Math.max(u * rmax * ratio / s, s / (u * rmin * ratio)) : Infinity; + } + function position(row, u, rect, flush) { + var i = -1, n = row.length, x = rect.x, y = rect.y, v = u ? round(row.area / u) : 0, o; + if (u == rect.dx) { + if (flush || v > rect.dy) v = rect.dy; + while (++i < n) { + o = row[i]; + o.x = x; + o.y = y; + o.dy = v; + x += o.dx = Math.min(rect.x + rect.dx - x, v ? round(o.area / v) : 0); + } + o.z = true; + o.dx += rect.x + rect.dx - x; + rect.y += v; + rect.dy -= v; + } else { + if (flush || v > rect.dx) v = rect.dx; + while (++i < n) { + o = row[i]; + o.x = x; + o.y = y; + o.dx = v; + y += o.dy = Math.min(rect.y + rect.dy - y, v ? round(o.area / v) : 0); + } + o.z = false; + o.dy += rect.y + rect.dy - y; + rect.x += v; + rect.dx -= v; + } + } + function treemap(d) { + var nodes = stickies || hierarchy(d), root = nodes[0]; + root.x = 0; + root.y = 0; + root.dx = size[0]; + root.dy = size[1]; + if (stickies) hierarchy.revalue(root); + scale([ root ], root.dx * root.dy / root.value); + (stickies ? stickify : squarify)(root); + if (sticky) stickies = nodes; + return nodes; + } + var hierarchy = d3.layout.hierarchy(), round = Math.round, size = [ 1, 1 ], padding = null, pad = d3_layout_treemapPadNull, sticky = false, stickies, ratio = .5 * (1 + Math.sqrt(5)); + treemap.size = function(x) { + if (!arguments.length) return size; + size = x; + return treemap; + }; + treemap.padding = function(x) { + function padFunction(node) { + var p = x.call(treemap, node, node.depth); + return p == null ? d3_layout_treemapPadNull(node) : d3_layout_treemapPad(node, typeof p === "number" ? [ p, p, p, p ] : p); + } + function padConstant(node) { + return d3_layout_treemapPad(node, x); + } + if (!arguments.length) return padding; + var type; + pad = (padding = x) == null ? d3_layout_treemapPadNull : (type = typeof x) === "function" ? padFunction : type === "number" ? (x = [ x, x, x, x ], padConstant) : padConstant; + return treemap; + }; + treemap.round = function(x) { + if (!arguments.length) return round != Number; + round = x ? Math.round : Number; + return treemap; + }; + treemap.sticky = function(x) { + if (!arguments.length) return sticky; + sticky = x; + stickies = null; + return treemap; + }; + treemap.ratio = function(x) { + if (!arguments.length) return ratio; + ratio = x; + return treemap; + }; + return d3_layout_hierarchyRebind(treemap, hierarchy); + }; + d3.csv = d3_dsv(",", "text/csv"); + d3.tsv = d3_dsv(" ", "text/tab-separated-values"); + d3.geo = {}; + var d3_geo_radians = Math.PI / 180; + d3.geo.azimuthal = function() { + function azimuthal(coordinates) { + var x1 = coordinates[0] * d3_geo_radians - x0, y1 = coordinates[1] * d3_geo_radians, cx1 = Math.cos(x1), sx1 = Math.sin(x1), cy1 = Math.cos(y1), sy1 = Math.sin(y1), cc = mode !== "orthographic" ? sy0 * sy1 + cy0 * cy1 * cx1 : null, c, k = mode === "stereographic" ? 1 / (1 + cc) : mode === "gnomonic" ? 1 / cc : mode === "equidistant" ? (c = Math.acos(cc), c ? c / Math.sin(c) : 0) : mode === "equalarea" ? Math.sqrt(2 / (1 + cc)) : 1, x = k * cy1 * sx1, y = k * (sy0 * cy1 * cx1 - cy0 * sy1); + return [ scale * x + translate[0], scale * y + translate[1] ]; + } + var mode = "orthographic", origin, scale = 200, translate = [ 480, 250 ], x0, y0, cy0, sy0; + azimuthal.invert = function(coordinates) { + var x = (coordinates[0] - translate[0]) / scale, y = (coordinates[1] - translate[1]) / scale, p = Math.sqrt(x * x + y * y), c = mode === "stereographic" ? 2 * Math.atan(p) : mode === "gnomonic" ? Math.atan(p) : mode === "equidistant" ? p : mode === "equalarea" ? 2 * Math.asin(.5 * p) : Math.asin(p), sc = Math.sin(c), cc = Math.cos(c); + return [ (x0 + Math.atan2(x * sc, p * cy0 * cc + y * sy0 * sc)) / d3_geo_radians, Math.asin(cc * sy0 - (p ? y * sc * cy0 / p : 0)) / d3_geo_radians ]; + }; + azimuthal.mode = function(x) { + if (!arguments.length) return mode; + mode = x + ""; + return azimuthal; + }; + azimuthal.origin = function(x) { + if (!arguments.length) return origin; + origin = x; + x0 = origin[0] * d3_geo_radians; + y0 = origin[1] * d3_geo_radians; + cy0 = Math.cos(y0); + sy0 = Math.sin(y0); + return azimuthal; + }; + azimuthal.scale = function(x) { + if (!arguments.length) return scale; + scale = +x; + return azimuthal; + }; + azimuthal.translate = function(x) { + if (!arguments.length) return translate; + translate = [ +x[0], +x[1] ]; + return azimuthal; + }; + return azimuthal.origin([ 0, 0 ]); + }; + d3.geo.albers = function() { + function albers(coordinates) { + var t = n * (d3_geo_radians * coordinates[0] - lng0), p = Math.sqrt(C - 2 * n * Math.sin(d3_geo_radians * coordinates[1])) / n; + return [ scale * p * Math.sin(t) + translate[0], scale * (p * Math.cos(t) - p0) + translate[1] ]; + } + function reload() { + var phi1 = d3_geo_radians * parallels[0], phi2 = d3_geo_radians * parallels[1], lat0 = d3_geo_radians * origin[1], s = Math.sin(phi1), c = Math.cos(phi1); + lng0 = d3_geo_radians * origin[0]; + n = .5 * (s + Math.sin(phi2)); + C = c * c + 2 * n * s; + p0 = Math.sqrt(C - 2 * n * Math.sin(lat0)) / n; + return albers; + } + var origin = [ -98, 38 ], parallels = [ 29.5, 45.5 ], scale = 1e3, translate = [ 480, 250 ], lng0, n, C, p0; + albers.invert = function(coordinates) { + var x = (coordinates[0] - translate[0]) / scale, y = (coordinates[1] - translate[1]) / scale, p0y = p0 + y, t = Math.atan2(x, p0y), p = Math.sqrt(x * x + p0y * p0y); + return [ (lng0 + t / n) / d3_geo_radians, Math.asin((C - p * p * n * n) / (2 * n)) / d3_geo_radians ]; + }; + albers.origin = function(x) { + if (!arguments.length) return origin; + origin = [ +x[0], +x[1] ]; + return reload(); + }; + albers.parallels = function(x) { + if (!arguments.length) return parallels; + parallels = [ +x[0], +x[1] ]; + return reload(); + }; + albers.scale = function(x) { + if (!arguments.length) return scale; + scale = +x; + return albers; + }; + albers.translate = function(x) { + if (!arguments.length) return translate; + translate = [ +x[0], +x[1] ]; + return albers; + }; + return reload(); + }; + d3.geo.albersUsa = function() { + function albersUsa(coordinates) { + var lon = coordinates[0], lat = coordinates[1]; + return (lat > 50 ? alaska : lon < -140 ? hawaii : lat < 21 ? puertoRico : lower48)(coordinates); + } + var lower48 = d3.geo.albers(); + var alaska = d3.geo.albers().origin([ -160, 60 ]).parallels([ 55, 65 ]); + var hawaii = d3.geo.albers().origin([ -160, 20 ]).parallels([ 8, 18 ]); + var puertoRico = d3.geo.albers().origin([ -60, 10 ]).parallels([ 8, 18 ]); + albersUsa.scale = function(x) { + if (!arguments.length) return lower48.scale(); + lower48.scale(x); + alaska.scale(x * .6); + hawaii.scale(x); + puertoRico.scale(x * 1.5); + return albersUsa.translate(lower48.translate()); + }; + albersUsa.translate = function(x) { + if (!arguments.length) return lower48.translate(); + var dz = lower48.scale() / 1e3, dx = x[0], dy = x[1]; + lower48.translate(x); + alaska.translate([ dx - 400 * dz, dy + 170 * dz ]); + hawaii.translate([ dx - 190 * dz, dy + 200 * dz ]); + puertoRico.translate([ dx + 580 * dz, dy + 430 * dz ]); + return albersUsa; + }; + return albersUsa.scale(lower48.scale()); + }; + d3.geo.bonne = function() { + function bonne(coordinates) { + var x = coordinates[0] * d3_geo_radians - x0, y = coordinates[1] * d3_geo_radians - y0; + if (y1) { + var p = c1 + y1 - y, E = x * Math.cos(y) / p; + x = p * Math.sin(E); + y = p * Math.cos(E) - c1; + } else { + x *= Math.cos(y); + y *= -1; + } + return [ scale * x + translate[0], scale * y + translate[1] ]; + } + var scale = 200, translate = [ 480, 250 ], x0, y0, y1, c1; + bonne.invert = function(coordinates) { + var x = (coordinates[0] - translate[0]) / scale, y = (coordinates[1] - translate[1]) / scale; + if (y1) { + var c = c1 + y, p = Math.sqrt(x * x + c * c); + y = c1 + y1 - p; + x = x0 + p * Math.atan2(x, c) / Math.cos(y); + } else { + y *= -1; + x /= Math.cos(y); + } + return [ x / d3_geo_radians, y / d3_geo_radians ]; + }; + bonne.parallel = function(x) { + if (!arguments.length) return y1 / d3_geo_radians; + c1 = 1 / Math.tan(y1 = x * d3_geo_radians); + return bonne; + }; + bonne.origin = function(x) { + if (!arguments.length) return [ x0 / d3_geo_radians, y0 / d3_geo_radians ]; + x0 = x[0] * d3_geo_radians; + y0 = x[1] * d3_geo_radians; + return bonne; + }; + bonne.scale = function(x) { + if (!arguments.length) return scale; + scale = +x; + return bonne; + }; + bonne.translate = function(x) { + if (!arguments.length) return translate; + translate = [ +x[0], +x[1] ]; + return bonne; + }; + return bonne.origin([ 0, 0 ]).parallel(45); + }; + d3.geo.equirectangular = function() { + function equirectangular(coordinates) { + var x = coordinates[0] / 360, y = -coordinates[1] / 360; + return [ scale * x + translate[0], scale * y + translate[1] ]; + } + var scale = 500, translate = [ 480, 250 ]; + equirectangular.invert = function(coordinates) { + var x = (coordinates[0] - translate[0]) / scale, y = (coordinates[1] - translate[1]) / scale; + return [ 360 * x, -360 * y ]; + }; + equirectangular.scale = function(x) { + if (!arguments.length) return scale; + scale = +x; + return equirectangular; + }; + equirectangular.translate = function(x) { + if (!arguments.length) return translate; + translate = [ +x[0], +x[1] ]; + return equirectangular; + }; + return equirectangular; + }; + d3.geo.mercator = function() { + function mercator(coordinates) { + var x = coordinates[0] / 360, y = -(Math.log(Math.tan(Math.PI / 4 + coordinates[1] * d3_geo_radians / 2)) / d3_geo_radians) / 360; + return [ scale * x + translate[0], scale * Math.max(-.5, Math.min(.5, y)) + translate[1] ]; + } + var scale = 500, translate = [ 480, 250 ]; + mercator.invert = function(coordinates) { + var x = (coordinates[0] - translate[0]) / scale, y = (coordinates[1] - translate[1]) / scale; + return [ 360 * x, 2 * Math.atan(Math.exp(-360 * y * d3_geo_radians)) / d3_geo_radians - 90 ]; + }; + mercator.scale = function(x) { + if (!arguments.length) return scale; + scale = +x; + return mercator; + }; + mercator.translate = function(x) { + if (!arguments.length) return translate; + translate = [ +x[0], +x[1] ]; + return mercator; + }; + return mercator; + }; + d3.geo.path = function() { + function path(d, i) { + if (typeof pointRadius === "function") pointCircle = d3_path_circle(pointRadius.apply(this, arguments)); + pathType(d); + var result = buffer.length ? buffer.join("") : null; + buffer = []; + return result; + } + function project(coordinates) { + return projection(coordinates).join(","); + } + function polygonArea(coordinates) { + var sum = area(coordinates[0]), i = 0, n = coordinates.length; + while (++i < n) sum -= area(coordinates[i]); + return sum; + } + function polygonCentroid(coordinates) { + var polygon = d3.geom.polygon(coordinates[0].map(projection)), area = polygon.area(), centroid = polygon.centroid(area < 0 ? (area *= -1, 1) : -1), x = centroid[0], y = centroid[1], z = area, i = 0, n = coordinates.length; + while (++i < n) { + polygon = d3.geom.polygon(coordinates[i].map(projection)); + area = polygon.area(); + centroid = polygon.centroid(area < 0 ? (area *= -1, 1) : -1); + x -= centroid[0]; + y -= centroid[1]; + z -= area; + } + return [ x, y, 6 * z ]; + } + function area(coordinates) { + return Math.abs(d3.geom.polygon(coordinates.map(projection)).area()); + } + var pointRadius = 4.5, pointCircle = d3_path_circle(pointRadius), projection = d3.geo.albersUsa(), buffer = []; + var pathType = d3_geo_type({ + FeatureCollection: function(o) { + var features = o.features, i = -1, n = features.length; + while (++i < n) buffer.push(pathType(features[i].geometry)); + }, + Feature: function(o) { + pathType(o.geometry); + }, + Point: function(o) { + buffer.push("M", project(o.coordinates), pointCircle); + }, + MultiPoint: function(o) { + var coordinates = o.coordinates, i = -1, n = coordinates.length; + while (++i < n) buffer.push("M", project(coordinates[i]), pointCircle); + }, + LineString: function(o) { + var coordinates = o.coordinates, i = -1, n = coordinates.length; + buffer.push("M"); + while (++i < n) buffer.push(project(coordinates[i]), "L"); + buffer.pop(); + }, + MultiLineString: function(o) { + var coordinates = o.coordinates, i = -1, n = coordinates.length, subcoordinates, j, m; + while (++i < n) { + subcoordinates = coordinates[i]; + j = -1; + m = subcoordinates.length; + buffer.push("M"); + while (++j < m) buffer.push(project(subcoordinates[j]), "L"); + buffer.pop(); + } + }, + Polygon: function(o) { + var coordinates = o.coordinates, i = -1, n = coordinates.length, subcoordinates, j, m; + while (++i < n) { + subcoordinates = coordinates[i]; + j = -1; + if ((m = subcoordinates.length - 1) > 0) { + buffer.push("M"); + while (++j < m) buffer.push(project(subcoordinates[j]), "L"); + buffer[buffer.length - 1] = "Z"; + } + } + }, + MultiPolygon: function(o) { + var coordinates = o.coordinates, i = -1, n = coordinates.length, subcoordinates, j, m, subsubcoordinates, k, p; + while (++i < n) { + subcoordinates = coordinates[i]; + j = -1; + m = subcoordinates.length; + while (++j < m) { + subsubcoordinates = subcoordinates[j]; + k = -1; + if ((p = subsubcoordinates.length - 1) > 0) { + buffer.push("M"); + while (++k < p) buffer.push(project(subsubcoordinates[k]), "L"); + buffer[buffer.length - 1] = "Z"; + } + } + } + }, + GeometryCollection: function(o) { + var geometries = o.geometries, i = -1, n = geometries.length; + while (++i < n) buffer.push(pathType(geometries[i])); + } + }); + var areaType = path.area = d3_geo_type({ + FeatureCollection: function(o) { + var area = 0, features = o.features, i = -1, n = features.length; + while (++i < n) area += areaType(features[i]); + return area; + }, + Feature: function(o) { + return areaType(o.geometry); + }, + Polygon: function(o) { + return polygonArea(o.coordinates); + }, + MultiPolygon: function(o) { + var sum = 0, coordinates = o.coordinates, i = -1, n = coordinates.length; + while (++i < n) sum += polygonArea(coordinates[i]); + return sum; + }, + GeometryCollection: function(o) { + var sum = 0, geometries = o.geometries, i = -1, n = geometries.length; + while (++i < n) sum += areaType(geometries[i]); + return sum; + } + }, 0); + var centroidType = path.centroid = d3_geo_type({ + Feature: function(o) { + return centroidType(o.geometry); + }, + Polygon: function(o) { + var centroid = polygonCentroid(o.coordinates); + return [ centroid[0] / centroid[2], centroid[1] / centroid[2] ]; + }, + MultiPolygon: function(o) { + var area = 0, coordinates = o.coordinates, centroid, x = 0, y = 0, z = 0, i = -1, n = coordinates.length; + while (++i < n) { + centroid = polygonCentroid(coordinates[i]); + x += centroid[0]; + y += centroid[1]; + z += centroid[2]; + } + return [ x / z, y / z ]; + } + }); + path.projection = function(x) { + projection = x; + return path; + }; + path.pointRadius = function(x) { + if (typeof x === "function") pointRadius = x; else { + pointRadius = +x; + pointCircle = d3_path_circle(pointRadius); + } + return path; + }; + return path; + }; + d3.geo.bounds = function(feature) { + var left = Infinity, bottom = Infinity, right = -Infinity, top = -Infinity; + d3_geo_bounds(feature, function(x, y) { + if (x < left) left = x; + if (x > right) right = x; + if (y < bottom) bottom = y; + if (y > top) top = y; + }); + return [ [ left, bottom ], [ right, top ] ]; + }; + var d3_geo_boundsTypes = { + Feature: d3_geo_boundsFeature, + FeatureCollection: d3_geo_boundsFeatureCollection, + GeometryCollection: d3_geo_boundsGeometryCollection, + LineString: d3_geo_boundsLineString, + MultiLineString: d3_geo_boundsMultiLineString, + MultiPoint: d3_geo_boundsLineString, + MultiPolygon: d3_geo_boundsMultiPolygon, + Point: d3_geo_boundsPoint, + Polygon: d3_geo_boundsPolygon + }; + d3.geo.circle = function() { + function circle() {} + function visible(point) { + return arc.distance(point) < radians; + } + function clip(coordinates) { + var i = -1, n = coordinates.length, clipped = [], p0, p1, p2, d0, d1; + while (++i < n) { + d1 = arc.distance(p2 = coordinates[i]); + if (d1 < radians) { + if (p1) clipped.push(d3_geo_greatArcInterpolate(p1, p2)((d0 - radians) / (d0 - d1))); + clipped.push(p2); + p0 = p1 = null; + } else { + p1 = p2; + if (!p0 && clipped.length) { + clipped.push(d3_geo_greatArcInterpolate(clipped[clipped.length - 1], p1)((radians - d0) / (d1 - d0))); + p0 = p1; + } + } + d0 = d1; + } + p0 = coordinates[0]; + p1 = clipped[0]; + if (p1 && p2[0] === p0[0] && p2[1] === p0[1] && !(p2[0] === p1[0] && p2[1] === p1[1])) { + clipped.push(p1); + } + return resample(clipped); + } + function resample(coordinates) { + var i = 0, n = coordinates.length, j, m, resampled = n ? [ coordinates[0] ] : coordinates, resamples, origin = arc.source(); + while (++i < n) { + resamples = arc.source(coordinates[i - 1])(coordinates[i]).coordinates; + for (j = 0, m = resamples.length; ++j < m; ) resampled.push(resamples[j]); + } + arc.source(origin); + return resampled; + } + var origin = [ 0, 0 ], degrees = 90 - .01, radians = degrees * d3_geo_radians, arc = d3.geo.greatArc().source(origin).target(d3_identity); + circle.clip = function(d) { + if (typeof origin === "function") arc.source(origin.apply(this, arguments)); + return clipType(d) || null; + }; + var clipType = d3_geo_type({ + FeatureCollection: function(o) { + var features = o.features.map(clipType).filter(d3_identity); + return features && (o = Object.create(o), o.features = features, o); + }, + Feature: function(o) { + var geometry = clipType(o.geometry); + return geometry && (o = Object.create(o), o.geometry = geometry, o); + }, + Point: function(o) { + return visible(o.coordinates) && o; + }, + MultiPoint: function(o) { + var coordinates = o.coordinates.filter(visible); + return coordinates.length && { + type: o.type, + coordinates: coordinates + }; + }, + LineString: function(o) { + var coordinates = clip(o.coordinates); + return coordinates.length && (o = Object.create(o), o.coordinates = coordinates, o); + }, + MultiLineString: function(o) { + var coordinates = o.coordinates.map(clip).filter(function(d) { + return d.length; + }); + return coordinates.length && (o = Object.create(o), o.coordinates = coordinates, o); + }, + Polygon: function(o) { + var coordinates = o.coordinates.map(clip); + return coordinates[0].length && (o = Object.create(o), o.coordinates = coordinates, o); + }, + MultiPolygon: function(o) { + var coordinates = o.coordinates.map(function(d) { + return d.map(clip); + }).filter(function(d) { + return d[0].length; + }); + return coordinates.length && (o = Object.create(o), o.coordinates = coordinates, o); + }, + GeometryCollection: function(o) { + var geometries = o.geometries.map(clipType).filter(d3_identity); + return geometries.length && (o = Object.create(o), o.geometries = geometries, o); + } + }); + circle.origin = function(x) { + if (!arguments.length) return origin; + origin = x; + if (typeof origin !== "function") arc.source(origin); + return circle; + }; + circle.angle = function(x) { + if (!arguments.length) return degrees; + radians = (degrees = +x) * d3_geo_radians; + return circle; + }; + return d3.rebind(circle, arc, "precision"); + }; + d3.geo.greatArc = function() { + function greatArc() { + var d = greatArc.distance.apply(this, arguments), t = 0, dt = precision / d, coordinates = [ p0 ]; + while ((t += dt) < 1) coordinates.push(interpolate(t)); + coordinates.push(p1); + return { + type: "LineString", + coordinates: coordinates + }; + } + var source = d3_geo_greatArcSource, p0, target = d3_geo_greatArcTarget, p1, precision = 6 * d3_geo_radians, interpolate = d3_geo_greatArcInterpolator(); + greatArc.distance = function() { + if (typeof source === "function") interpolate.source(p0 = source.apply(this, arguments)); + if (typeof target === "function") interpolate.target(p1 = target.apply(this, arguments)); + return interpolate.distance(); + }; + greatArc.source = function(_) { + if (!arguments.length) return source; + source = _; + if (typeof source !== "function") interpolate.source(p0 = source); + return greatArc; + }; + greatArc.target = function(_) { + if (!arguments.length) return target; + target = _; + if (typeof target !== "function") interpolate.target(p1 = target); + return greatArc; + }; + greatArc.precision = function(_) { + if (!arguments.length) return precision / d3_geo_radians; + precision = _ * d3_geo_radians; + return greatArc; + }; + return greatArc; + }; + d3.geo.greatCircle = d3.geo.circle; + d3.geom = {}; + d3.geom.contour = function(grid, start) { + var s = start || d3_geom_contourStart(grid), c = [], x = s[0], y = s[1], dx = 0, dy = 0, pdx = NaN, pdy = NaN, i = 0; + do { + i = 0; + if (grid(x - 1, y - 1)) i += 1; + if (grid(x, y - 1)) i += 2; + if (grid(x - 1, y)) i += 4; + if (grid(x, y)) i += 8; + if (i === 6) { + dx = pdy === -1 ? -1 : 1; + dy = 0; + } else if (i === 9) { + dx = 0; + dy = pdx === 1 ? -1 : 1; + } else { + dx = d3_geom_contourDx[i]; + dy = d3_geom_contourDy[i]; + } + if (dx != pdx && dy != pdy) { + c.push([ x, y ]); + pdx = dx; + pdy = dy; + } + x += dx; + y += dy; + } while (s[0] != x || s[1] != y); + return c; + }; + var d3_geom_contourDx = [ 1, 0, 1, 1, -1, 0, -1, 1, 0, 0, 0, 0, -1, 0, -1, NaN ], d3_geom_contourDy = [ 0, -1, 0, 0, 0, -1, 0, 0, 1, -1, 1, 1, 0, -1, 0, NaN ]; + d3.geom.hull = function(vertices) { + if (vertices.length < 3) return []; + var len = vertices.length, plen = len - 1, points = [], stack = [], i, j, h = 0, x1, y1, x2, y2, u, v, a, sp; + for (i = 1; i < len; ++i) { + if (vertices[i][1] < vertices[h][1]) { + h = i; + } else if (vertices[i][1] == vertices[h][1]) { + h = vertices[i][0] < vertices[h][0] ? i : h; + } + } + for (i = 0; i < len; ++i) { + if (i === h) continue; + y1 = vertices[i][1] - vertices[h][1]; + x1 = vertices[i][0] - vertices[h][0]; + points.push({ + angle: Math.atan2(y1, x1), + index: i + }); + } + points.sort(function(a, b) { + return a.angle - b.angle; + }); + a = points[0].angle; + v = points[0].index; + u = 0; + for (i = 1; i < plen; ++i) { + j = points[i].index; + if (a == points[i].angle) { + x1 = vertices[v][0] - vertices[h][0]; + y1 = vertices[v][1] - vertices[h][1]; + x2 = vertices[j][0] - vertices[h][0]; + y2 = vertices[j][1] - vertices[h][1]; + if (x1 * x1 + y1 * y1 >= x2 * x2 + y2 * y2) { + points[i].index = -1; + } else { + points[u].index = -1; + a = points[i].angle; + u = i; + v = j; + } + } else { + a = points[i].angle; + u = i; + v = j; + } + } + stack.push(h); + for (i = 0, j = 0; i < 2; ++j) { + if (points[j].index !== -1) { + stack.push(points[j].index); + i++; + } + } + sp = stack.length; + for (; j < plen; ++j) { + if (points[j].index === -1) continue; + while (!d3_geom_hullCCW(stack[sp - 2], stack[sp - 1], points[j].index, vertices)) { + --sp; + } + stack[sp++] = points[j].index; + } + var poly = []; + for (i = 0; i < sp; ++i) { + poly.push(vertices[stack[i]]); + } + return poly; + }; + d3.geom.polygon = function(coordinates) { + coordinates.area = function() { + var i = 0, n = coordinates.length, a = coordinates[n - 1][0] * coordinates[0][1], b = coordinates[n - 1][1] * coordinates[0][0]; + while (++i < n) { + a += coordinates[i - 1][0] * coordinates[i][1]; + b += coordinates[i - 1][1] * coordinates[i][0]; + } + return (b - a) * .5; + }; + coordinates.centroid = function(k) { + var i = -1, n = coordinates.length, x = 0, y = 0, a, b = coordinates[n - 1], c; + if (!arguments.length) k = -1 / (6 * coordinates.area()); + while (++i < n) { + a = b; + b = coordinates[i]; + c = a[0] * b[1] - b[0] * a[1]; + x += (a[0] + b[0]) * c; + y += (a[1] + b[1]) * c; + } + return [ x * k, y * k ]; + }; + coordinates.clip = function(subject) { + var input, i = -1, n = coordinates.length, j, m, a = coordinates[n - 1], b, c, d; + while (++i < n) { + input = subject.slice(); + subject.length = 0; + b = coordinates[i]; + c = input[(m = input.length) - 1]; + j = -1; + while (++j < m) { + d = input[j]; + if (d3_geom_polygonInside(d, a, b)) { + if (!d3_geom_polygonInside(c, a, b)) { + subject.push(d3_geom_polygonIntersect(c, d, a, b)); + } + subject.push(d); + } else if (d3_geom_polygonInside(c, a, b)) { + subject.push(d3_geom_polygonIntersect(c, d, a, b)); + } + c = d; + } + a = b; + } + return subject; + }; + return coordinates; + }; + d3.geom.voronoi = function(vertices) { + var polygons = vertices.map(function() { + return []; + }); + d3_voronoi_tessellate(vertices, function(e) { + var s1, s2, x1, x2, y1, y2; + if (e.a === 1 && e.b >= 0) { + s1 = e.ep.r; + s2 = e.ep.l; + } else { + s1 = e.ep.l; + s2 = e.ep.r; + } + if (e.a === 1) { + y1 = s1 ? s1.y : -1e6; + x1 = e.c - e.b * y1; + y2 = s2 ? s2.y : 1e6; + x2 = e.c - e.b * y2; + } else { + x1 = s1 ? s1.x : -1e6; + y1 = e.c - e.a * x1; + x2 = s2 ? s2.x : 1e6; + y2 = e.c - e.a * x2; + } + var v1 = [ x1, y1 ], v2 = [ x2, y2 ]; + polygons[e.region.l.index].push(v1, v2); + polygons[e.region.r.index].push(v1, v2); + }); + return polygons.map(function(polygon, i) { + var cx = vertices[i][0], cy = vertices[i][1]; + polygon.forEach(function(v) { + v.angle = Math.atan2(v[0] - cx, v[1] - cy); + }); + return polygon.sort(function(a, b) { + return a.angle - b.angle; + }).filter(function(d, i) { + return !i || d.angle - polygon[i - 1].angle > 1e-10; + }); + }); + }; + var d3_voronoi_opposite = { + l: "r", + r: "l" + }; + d3.geom.delaunay = function(vertices) { + var edges = vertices.map(function() { + return []; + }), triangles = []; + d3_voronoi_tessellate(vertices, function(e) { + edges[e.region.l.index].push(vertices[e.region.r.index]); + }); + edges.forEach(function(edge, i) { + var v = vertices[i], cx = v[0], cy = v[1]; + edge.forEach(function(v) { + v.angle = Math.atan2(v[0] - cx, v[1] - cy); + }); + edge.sort(function(a, b) { + return a.angle - b.angle; + }); + for (var j = 0, m = edge.length - 1; j < m; j++) { + triangles.push([ v, edge[j], edge[j + 1] ]); + } + }); + return triangles; + }; + d3.geom.quadtree = function(points, x1, y1, x2, y2) { + function insert(n, p, x1, y1, x2, y2) { + if (isNaN(p.x) || isNaN(p.y)) return; + if (n.leaf) { + var v = n.point; + if (v) { + if (Math.abs(v.x - p.x) + Math.abs(v.y - p.y) < .01) { + insertChild(n, p, x1, y1, x2, y2); + } else { + n.point = null; + insertChild(n, v, x1, y1, x2, y2); + insertChild(n, p, x1, y1, x2, y2); + } + } else { + n.point = p; + } + } else { + insertChild(n, p, x1, y1, x2, y2); + } + } + function insertChild(n, p, x1, y1, x2, y2) { + var sx = (x1 + x2) * .5, sy = (y1 + y2) * .5, right = p.x >= sx, bottom = p.y >= sy, i = (bottom << 1) + right; + n.leaf = false; + n = n.nodes[i] || (n.nodes[i] = d3_geom_quadtreeNode()); + if (right) x1 = sx; else x2 = sx; + if (bottom) y1 = sy; else y2 = sy; + insert(n, p, x1, y1, x2, y2); + } + var p, i = -1, n = points.length; + if (n && isNaN(points[0].x)) points = points.map(d3_geom_quadtreePoint); + if (arguments.length < 5) { + if (arguments.length === 3) { + y2 = x2 = y1; + y1 = x1; + } else { + x1 = y1 = Infinity; + x2 = y2 = -Infinity; + while (++i < n) { + p = points[i]; + if (p.x < x1) x1 = p.x; + if (p.y < y1) y1 = p.y; + if (p.x > x2) x2 = p.x; + if (p.y > y2) y2 = p.y; + } + var dx = x2 - x1, dy = y2 - y1; + if (dx > dy) y2 = y1 + dx; else x2 = x1 + dy; + } + } + var root = d3_geom_quadtreeNode(); + root.add = function(p) { + insert(root, p, x1, y1, x2, y2); + }; + root.visit = function(f) { + d3_geom_quadtreeVisit(f, root, x1, y1, x2, y2); + }; + points.forEach(root.add); + return root; + }; + d3.time = {}; + var d3_time = Date, d3_time_daySymbols = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ]; + d3_time_utc.prototype = { + getDate: function() { + return this._.getUTCDate(); + }, + getDay: function() { + return this._.getUTCDay(); + }, + getFullYear: function() { + return this._.getUTCFullYear(); + }, + getHours: function() { + return this._.getUTCHours(); + }, + getMilliseconds: function() { + return this._.getUTCMilliseconds(); + }, + getMinutes: function() { + return this._.getUTCMinutes(); + }, + getMonth: function() { + return this._.getUTCMonth(); + }, + getSeconds: function() { + return this._.getUTCSeconds(); + }, + getTime: function() { + return this._.getTime(); + }, + getTimezoneOffset: function() { + return 0; + }, + valueOf: function() { + return this._.valueOf(); + }, + setDate: function() { + d3_time_prototype.setUTCDate.apply(this._, arguments); + }, + setDay: function() { + d3_time_prototype.setUTCDay.apply(this._, arguments); + }, + setFullYear: function() { + d3_time_prototype.setUTCFullYear.apply(this._, arguments); + }, + setHours: function() { + d3_time_prototype.setUTCHours.apply(this._, arguments); + }, + setMilliseconds: function() { + d3_time_prototype.setUTCMilliseconds.apply(this._, arguments); + }, + setMinutes: function() { + d3_time_prototype.setUTCMinutes.apply(this._, arguments); + }, + setMonth: function() { + d3_time_prototype.setUTCMonth.apply(this._, arguments); + }, + setSeconds: function() { + d3_time_prototype.setUTCSeconds.apply(this._, arguments); + }, + setTime: function() { + d3_time_prototype.setTime.apply(this._, arguments); + } + }; + var d3_time_prototype = Date.prototype; + var d3_time_formatDateTime = "%a %b %e %H:%M:%S %Y", d3_time_formatDate = "%m/%d/%y", d3_time_formatTime = "%H:%M:%S"; + var d3_time_days = d3_time_daySymbols, d3_time_dayAbbreviations = d3_time_days.map(d3_time_formatAbbreviate), d3_time_months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ], d3_time_monthAbbreviations = d3_time_months.map(d3_time_formatAbbreviate); + d3.time.format = function(template) { + function format(date) { + var string = [], i = -1, j = 0, c, f; + while (++i < n) { + if (template.charCodeAt(i) == 37) { + string.push(template.substring(j, i), (f = d3_time_formats[c = template.charAt(++i)]) ? f(date) : c); + j = i + 1; + } + } + string.push(template.substring(j, i)); + return string.join(""); + } + var n = template.length; + format.parse = function(string) { + var d = { + y: 1900, + m: 0, + d: 1, + H: 0, + M: 0, + S: 0, + L: 0 + }, i = d3_time_parse(d, template, string, 0); + if (i != string.length) return null; + if ("p" in d) d.H = d.H % 12 + d.p * 12; + var date = new d3_time; + date.setFullYear(d.y, d.m, d.d); + date.setHours(d.H, d.M, d.S, d.L); + return date; + }; + format.toString = function() { + return template; + }; + return format; + }; + var d3_time_zfill2 = d3.format("02d"), d3_time_zfill3 = d3.format("03d"), d3_time_zfill4 = d3.format("04d"), d3_time_sfill2 = d3.format("2d"); + var d3_time_dayRe = d3_time_formatRe(d3_time_days), d3_time_dayAbbrevRe = d3_time_formatRe(d3_time_dayAbbreviations), d3_time_monthRe = d3_time_formatRe(d3_time_months), d3_time_monthLookup = d3_time_formatLookup(d3_time_months), d3_time_monthAbbrevRe = d3_time_formatRe(d3_time_monthAbbreviations), d3_time_monthAbbrevLookup = d3_time_formatLookup(d3_time_monthAbbreviations); + var d3_time_formats = { + a: function(d) { + return d3_time_dayAbbreviations[d.getDay()]; + }, + A: function(d) { + return d3_time_days[d.getDay()]; + }, + b: function(d) { + return d3_time_monthAbbreviations[d.getMonth()]; + }, + B: function(d) { + return d3_time_months[d.getMonth()]; + }, + c: d3.time.format(d3_time_formatDateTime), + d: function(d) { + return d3_time_zfill2(d.getDate()); + }, + e: function(d) { + return d3_time_sfill2(d.getDate()); + }, + H: function(d) { + return d3_time_zfill2(d.getHours()); + }, + I: function(d) { + return d3_time_zfill2(d.getHours() % 12 || 12); + }, + j: function(d) { + return d3_time_zfill3(1 + d3.time.dayOfYear(d)); + }, + L: function(d) { + return d3_time_zfill3(d.getMilliseconds()); + }, + m: function(d) { + return d3_time_zfill2(d.getMonth() + 1); + }, + M: function(d) { + return d3_time_zfill2(d.getMinutes()); + }, + p: function(d) { + return d.getHours() >= 12 ? "PM" : "AM"; + }, + S: function(d) { + return d3_time_zfill2(d.getSeconds()); + }, + U: function(d) { + return d3_time_zfill2(d3.time.sundayOfYear(d)); + }, + w: function(d) { + return d.getDay(); + }, + W: function(d) { + return d3_time_zfill2(d3.time.mondayOfYear(d)); + }, + x: d3.time.format(d3_time_formatDate), + X: d3.time.format(d3_time_formatTime), + y: function(d) { + return d3_time_zfill2(d.getFullYear() % 100); + }, + Y: function(d) { + return d3_time_zfill4(d.getFullYear() % 1e4); + }, + Z: d3_time_zone, + "%": function(d) { + return "%"; + } + }; + var d3_time_parsers = { + a: d3_time_parseWeekdayAbbrev, + A: d3_time_parseWeekday, + b: d3_time_parseMonthAbbrev, + B: d3_time_parseMonth, + c: d3_time_parseLocaleFull, + d: d3_time_parseDay, + e: d3_time_parseDay, + H: d3_time_parseHour24, + I: d3_time_parseHour24, + L: d3_time_parseMilliseconds, + m: d3_time_parseMonthNumber, + M: d3_time_parseMinutes, + p: d3_time_parseAmPm, + S: d3_time_parseSeconds, + x: d3_time_parseLocaleDate, + X: d3_time_parseLocaleTime, + y: d3_time_parseYear, + Y: d3_time_parseFullYear + }; + var d3_time_numberRe = /^\s*\d+/; + var d3_time_amPmLookup = d3.map({ + am: 0, + pm: 1 + }); + d3.time.format.utc = function(template) { + function format(date) { + try { + d3_time = d3_time_utc; + var utc = new d3_time; + utc._ = date; + return local(utc); + } finally { + d3_time = Date; + } + } + var local = d3.time.format(template); + format.parse = function(string) { + try { + d3_time = d3_time_utc; + var date = local.parse(string); + return date && date._; + } finally { + d3_time = Date; + } + }; + format.toString = local.toString; + return format; + }; + var d3_time_formatIso = d3.time.format.utc("%Y-%m-%dT%H:%M:%S.%LZ"); + d3.time.format.iso = Date.prototype.toISOString ? d3_time_formatIsoNative : d3_time_formatIso; + d3_time_formatIsoNative.parse = function(string) { + var date = new Date(string); + return isNaN(date) ? null : date; + }; + d3_time_formatIsoNative.toString = d3_time_formatIso.toString; + d3.time.second = d3_time_interval(function(date) { + return new d3_time(Math.floor(date / 1e3) * 1e3); + }, function(date, offset) { + date.setTime(date.getTime() + Math.floor(offset) * 1e3); + }, function(date) { + return date.getSeconds(); + }); + d3.time.seconds = d3.time.second.range; + d3.time.seconds.utc = d3.time.second.utc.range; + d3.time.minute = d3_time_interval(function(date) { + return new d3_time(Math.floor(date / 6e4) * 6e4); + }, function(date, offset) { + date.setTime(date.getTime() + Math.floor(offset) * 6e4); + }, function(date) { + return date.getMinutes(); + }); + d3.time.minutes = d3.time.minute.range; + d3.time.minutes.utc = d3.time.minute.utc.range; + d3.time.hour = d3_time_interval(function(date) { + var timezone = date.getTimezoneOffset() / 60; + return new d3_time((Math.floor(date / 36e5 - timezone) + timezone) * 36e5); + }, function(date, offset) { + date.setTime(date.getTime() + Math.floor(offset) * 36e5); + }, function(date) { + return date.getHours(); + }); + d3.time.hours = d3.time.hour.range; + d3.time.hours.utc = d3.time.hour.utc.range; + d3.time.day = d3_time_interval(function(date) { + var day = new d3_time(1970, 0); + day.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + return day; + }, function(date, offset) { + date.setDate(date.getDate() + offset); + }, function(date) { + return date.getDate() - 1; + }); + d3.time.days = d3.time.day.range; + d3.time.days.utc = d3.time.day.utc.range; + d3.time.dayOfYear = function(date) { + var year = d3.time.year(date); + return Math.floor((date - year - (date.getTimezoneOffset() - year.getTimezoneOffset()) * 6e4) / 864e5); + }; + d3_time_daySymbols.forEach(function(day, i) { + day = day.toLowerCase(); + i = 7 - i; + var interval = d3.time[day] = d3_time_interval(function(date) { + (date = d3.time.day(date)).setDate(date.getDate() - (date.getDay() + i) % 7); + return date; + }, function(date, offset) { + date.setDate(date.getDate() + Math.floor(offset) * 7); + }, function(date) { + var day = d3.time.year(date).getDay(); + return Math.floor((d3.time.dayOfYear(date) + (day + i) % 7) / 7) - (day !== i); + }); + d3.time[day + "s"] = interval.range; + d3.time[day + "s"].utc = interval.utc.range; + d3.time[day + "OfYear"] = function(date) { + var day = d3.time.year(date).getDay(); + return Math.floor((d3.time.dayOfYear(date) + (day + i) % 7) / 7); + }; + }); + d3.time.week = d3.time.sunday; + d3.time.weeks = d3.time.sunday.range; + d3.time.weeks.utc = d3.time.sunday.utc.range; + d3.time.weekOfYear = d3.time.sundayOfYear; + d3.time.month = d3_time_interval(function(date) { + date = d3.time.day(date); + date.setDate(1); + return date; + }, function(date, offset) { + date.setMonth(date.getMonth() + offset); + }, function(date) { + return date.getMonth(); + }); + d3.time.months = d3.time.month.range; + d3.time.months.utc = d3.time.month.utc.range; + d3.time.year = d3_time_interval(function(date) { + date = d3.time.day(date); + date.setMonth(0, 1); + return date; + }, function(date, offset) { + date.setFullYear(date.getFullYear() + offset); + }, function(date) { + return date.getFullYear(); + }); + d3.time.years = d3.time.year.range; + d3.time.years.utc = d3.time.year.utc.range; + var d3_time_scaleSteps = [ 1e3, 5e3, 15e3, 3e4, 6e4, 3e5, 9e5, 18e5, 36e5, 108e5, 216e5, 432e5, 864e5, 1728e5, 6048e5, 2592e6, 7776e6, 31536e6 ]; + var d3_time_scaleLocalMethods = [ [ d3.time.second, 1 ], [ d3.time.second, 5 ], [ d3.time.second, 15 ], [ d3.time.second, 30 ], [ d3.time.minute, 1 ], [ d3.time.minute, 5 ], [ d3.time.minute, 15 ], [ d3.time.minute, 30 ], [ d3.time.hour, 1 ], [ d3.time.hour, 3 ], [ d3.time.hour, 6 ], [ d3.time.hour, 12 ], [ d3.time.day, 1 ], [ d3.time.day, 2 ], [ d3.time.week, 1 ], [ d3.time.month, 1 ], [ d3.time.month, 3 ], [ d3.time.year, 1 ] ]; + var d3_time_scaleLocalFormats = [ [ d3.time.format("%Y"), function(d) { + return true; + } ], [ d3.time.format("%B"), function(d) { + return d.getMonth(); + } ], [ d3.time.format("%b %d"), function(d) { + return d.getDate() != 1; + } ], [ d3.time.format("%a %d"), function(d) { + return d.getDay() && d.getDate() != 1; + } ], [ d3.time.format("%I %p"), function(d) { + return d.getHours(); + } ], [ d3.time.format("%I:%M"), function(d) { + return d.getMinutes(); + } ], [ d3.time.format(":%S"), function(d) { + return d.getSeconds(); + } ], [ d3.time.format(".%L"), function(d) { + return d.getMilliseconds(); + } ] ]; + var d3_time_scaleLinear = d3.scale.linear(), d3_time_scaleLocalFormat = d3_time_scaleFormat(d3_time_scaleLocalFormats); + d3_time_scaleLocalMethods.year = function(extent, m) { + return d3_time_scaleLinear.domain(extent.map(d3_time_scaleGetYear)).ticks(m).map(d3_time_scaleSetYear); + }; + d3.time.scale = function() { + return d3_time_scale(d3.scale.linear(), d3_time_scaleLocalMethods, d3_time_scaleLocalFormat); + }; + var d3_time_scaleUTCMethods = d3_time_scaleLocalMethods.map(function(m) { + return [ m[0].utc, m[1] ]; + }); + var d3_time_scaleUTCFormats = [ [ d3.time.format.utc("%Y"), function(d) { + return true; + } ], [ d3.time.format.utc("%B"), function(d) { + return d.getUTCMonth(); + } ], [ d3.time.format.utc("%b %d"), function(d) { + return d.getUTCDate() != 1; + } ], [ d3.time.format.utc("%a %d"), function(d) { + return d.getUTCDay() && d.getUTCDate() != 1; + } ], [ d3.time.format.utc("%I %p"), function(d) { + return d.getUTCHours(); + } ], [ d3.time.format.utc("%I:%M"), function(d) { + return d.getUTCMinutes(); + } ], [ d3.time.format.utc(":%S"), function(d) { + return d.getUTCSeconds(); + } ], [ d3.time.format.utc(".%L"), function(d) { + return d.getUTCMilliseconds(); + } ] ]; + var d3_time_scaleUTCFormat = d3_time_scaleFormat(d3_time_scaleUTCFormats); + d3_time_scaleUTCMethods.year = function(extent, m) { + return d3_time_scaleLinear.domain(extent.map(d3_time_scaleUTCGetYear)).ticks(m).map(d3_time_scaleUTCSetYear); + }; + d3.time.scale.utc = function() { + return d3_time_scale(d3.scale.linear(), d3_time_scaleUTCMethods, d3_time_scaleUTCFormat); + }; +})(); \ No newline at end of file diff --git a/packages/d3/package.js b/packages/d3/package.js new file mode 100644 index 0000000000..a4868a8ea2 --- /dev/null +++ b/packages/d3/package.js @@ -0,0 +1,7 @@ +Package.describe({ + summary: "Library for manipulating documents based on data." +}); + +Package.on_use(function (api) { + api.add_files('d3.v2.js', 'client'); +}); diff --git a/packages/deps/deps-utils.js b/packages/deps/deps-utils.js new file mode 100644 index 0000000000..2a8d748c7f --- /dev/null +++ b/packages/deps/deps-utils.js @@ -0,0 +1,118 @@ +(function () { + // XXX Document, test, and remove the leading underscore from everything. + + ////////// Meteor.deps._ContextSet + + // Constructor for an empty _ContextSet. + // + // A _ContextSet is used to hold a set of Meteor.deps.Contexts that + // are to be invalidated at some future time. If a Context in the + // set becomes invalidated for any reason, it's immediately removed + // from the set. + var _ContextSet = function () { + this._contextsById = {}; + }; + + // Adds the Context `ctx` to this set if it is not already + // present. Returns true if the context is new to this set. + _ContextSet.prototype.add = function (ctx) { + var self = this; + if (ctx && ! (ctx.id in self._contextsById)) { + self._contextsById[ctx.id] = ctx; + ctx.onInvalidate(function () { + delete self._contextsById[ctx.id]; + }); + return true; + } + return false; + }; + + // Adds the current Context to this set if there is one. Returns + // true if there is a current Context and it's new to the set. + _ContextSet.prototype.addCurrentContext = function () { + var self = this; + var context = Meteor.deps.Context.current; + if (! context) + return false; + return self.add(context); + }; + + // Invalidate all Contexts in this set. They will be removed + // from the set as a consequence. + _ContextSet.prototype.invalidateAll = function () { + var self = this; + for (var id in self._contextsById) + self._contextsById[id].invalidate(); + }; + + // Returns true if there are no Contexts in this set. + _ContextSet.prototype.isEmpty = function () { + var self = this; + for(var id in self._contextsById) + return false; + return true; + }; + + Meteor.deps._ContextSet = _ContextSet; + + ////////// Meteor.autorun + + // Run f(). Record its dependencies. Rerun it whenever the + // dependencies change. + // + // Returns an object with a stop() method. Call stop() to stop the + // rerunning. Also passes this object as an argument to f. + Meteor.autorun = function (f) { + var ctx; + var slain = false; + var handle = { + stop: function () { + slain = true; + ctx.invalidate(); + } + }; + var rerun = function () { + if (slain) + return; + ctx = new Meteor.deps.Context; + ctx.run(function () { f.call(this, handle); }); + ctx.onInvalidate(rerun); + }; + rerun(); + return handle; + }; + + ////////// Meteor._atFlush + + // Run 'f' at Meteor.flush()-time. If atFlush is called multiple times, + // we guarantee that the 'f's will run in the same order that + // atFlush was called on them. If we are inside a Meteor.flush() already, + // f will be scheduled as part of the current flush(). + + var atFlushQueue = []; + var atFlushContext = null; + Meteor._atFlush = function (f) { + atFlushQueue.push(f); + + if (! atFlushContext) { + atFlushContext = new Meteor.deps.Context; + atFlushContext.onInvalidate(function () { + var f; + while ((f = atFlushQueue.shift())) { + // Since atFlushContext is truthy, if f() calls atFlush + // reentrantly, it's guaranteed to append to atFlushQueue and + // not contruct a new atFlushContext. + try { + f(); + } catch (e) { + Meteor._debug("Exception from Meteor._atFlush:", e.stack); + } + } + atFlushContext = null; + }); + + atFlushContext.invalidate(); + } + }; + +})(); \ No newline at end of file diff --git a/packages/deps/deps.js b/packages/deps/deps.js index dc229ad960..2ef788dd43 100644 --- a/packages/deps/deps.js +++ b/packages/deps/deps.js @@ -59,7 +59,11 @@ _.each(pending, function (ctx) { _.each(ctx._callbacks, function (f) { - f(ctx); // XXX wrap in try? + try { + f(ctx); + } catch (e) { + Meteor._debug("Exception from Meteor.flush:", e.stack); + } }); delete ctx._callbacks; // maybe help the GC }); diff --git a/packages/deps/deps_tests.js b/packages/deps/deps_tests.js new file mode 100644 index 0000000000..63e4e718ef --- /dev/null +++ b/packages/deps/deps_tests.js @@ -0,0 +1,46 @@ +Tinytest.add('deps - autorun', function (test) { + var listeners = new Meteor.deps._ContextSet; + var x = 0; + var handle = Meteor.autorun(function (handle) { + listeners.addCurrentContext(); + ++x; + }); + test.equal(x, 1); + Meteor.flush(); + test.equal(x, 1); + listeners.invalidateAll(); + test.equal(x, 1); + Meteor.flush(); + test.equal(x, 2); + listeners.invalidateAll(); + test.equal(x, 2); + Meteor.flush(); + test.equal(x, 3); + listeners.invalidateAll(); + // Prevent the function from running further. + handle.stop(); + Meteor.flush(); + test.equal(x, 3); + listeners.invalidateAll(); + Meteor.flush(); + test.equal(x, 3); + + Meteor.autorun(function (internalHandle) { + listeners.addCurrentContext(); + ++x; + if (x == 6) + internalHandle.stop(); + }); + test.equal(x, 4); + listeners.invalidateAll(); + Meteor.flush(); + test.equal(x, 5); + listeners.invalidateAll(); + // Increment to 6 and stop. + Meteor.flush(); + test.equal(x, 6); + listeners.invalidateAll(); + Meteor.flush(); + // Still 6! + test.equal(x, 6); +}); diff --git a/packages/deps/package.js b/packages/deps/package.js index 9949cfdd73..a38a57f69e 100644 --- a/packages/deps/package.js +++ b/packages/deps/package.js @@ -9,5 +9,11 @@ Package.on_use(function (api, where) { where = where || ['client', 'server']; api.use('underscore', where); - api.add_files('deps.js', where); + api.add_files(['deps.js', 'deps-utils.js'], where); +}); + +Package.on_test(function (api) { + api.use('tinytest'); + api.use('deps'); + api.add_files('deps_tests.js', 'client'); }); diff --git a/packages/force-ssl/package.js b/packages/force-ssl/package.js index bc413ad820..e397ec0eaa 100644 --- a/packages/force-ssl/package.js +++ b/packages/force-ssl/package.js @@ -1,5 +1,5 @@ Package.describe({ - summary: "Require this application always use transport layer encryption" + summary: "Require this application to use secure transport (HTTPS)" }); Package.on_use(function (api) { diff --git a/packages/insecure/insecure.js b/packages/insecure/insecure.js new file mode 100644 index 0000000000..22a74ca954 --- /dev/null +++ b/packages/insecure/insecure.js @@ -0,0 +1 @@ +Meteor.Collection.insecure = true; diff --git a/packages/insecure/package.js b/packages/insecure/package.js new file mode 100644 index 0000000000..fe2e744a38 --- /dev/null +++ b/packages/insecure/package.js @@ -0,0 +1,8 @@ +Package.describe({ + summary: "Allow all database writes by default" +}); + +Package.on_use(function (api) { + api.use(['mongo-livedata']); + api.add_files(['insecure.js'], 'server'); +}); diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index c7394076b8..323dda5baf 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -1,25 +1,46 @@ // XXX namespacing -Meteor._MethodInvocation = function (isSimulation, unblock) { +Meteor._MethodInvocation = function (options) { var self = this; // true if we're running not the actual method, but a stub (that is, - // if we're on the client and presently running a simulation of a - // server-side method for latency compensation purposes). never true - // except in a client such as a browser, since there's no point in - // running stubs unless you have a zero-latency connection to the - // user. - this.isSimulation = isSimulation; + // if we're on a client (which may be a browser, or in the future a + // server connecting to another server) and presently running a + // simulation of a server-side method for latency compensation + // purposes). not currently true except in a client such as a browser, + // since there's usually no point in running stubs unless you have a + // zero-latency connection to the user. + this.isSimulation = options.isSimulation; // XXX Backwards compatibility only. Remove this before 1.0. - this.is_simulation = isSimulation; + this.is_simulation = this.isSimulation; // call this function to allow other method invocations (from the // same client) to continue running without waiting for this one to // complete. - this.unblock = unblock || function () {}; + this.unblock = options.unblock || function () {}; + + // current user id + this.userId = options.userId; + + // sets current user id in all appropriate server contexts and + // reruns subscriptions + this._setUserId = options.setUserId || 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 + // sort of thing someday. + this._sessionData = options.sessionData; }; +_.extend(Meteor._MethodInvocation.prototype, { + setUserId: function(userId) { + this.userId = userId; + this._setUserId(userId); + } +}); + Meteor._CurrentInvocation = new Meteor.EnvironmentVariable; Meteor.Error = function (error, reason, details) { @@ -43,4 +64,4 @@ Meteor.Error = function (error, reason, details) { self.details = details; }; -Meteor.Error.prototype = new Error; \ No newline at end of file +Meteor.Error.prototype = new Error; diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index 27350ab93c..083e8d0149 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -12,8 +12,16 @@ Meteor._capture_subs = null; // @param url {String|Object} URL to Meteor app or sockjs endpoint (deprecated), // or an object as a test hook (see code) -Meteor._LivedataConnection = function (url, restart_on_update) { +// Options: +// reloadOnUpdate: should we try to reload when the server says +// there's new code available? +// reloadWithOutstanding: is it OK to reload if there are outstanding methods? +Meteor._LivedataConnection = function (url, options) { var self = this; + options = _.extend({ + reloadOnUpdate: false, + reloadWithOutstanding: false + }, options); // as a test hook, allow passing a stream instead of a url. if (typeof url === "object") { @@ -29,8 +37,27 @@ Meteor._LivedataConnection = function (url, restart_on_update) { self.stores = {}; // name -> object with methods self.method_handlers = {}; // name -> func self.next_method_id = 1; - // waiting for results of method + + // --- Three classes of outstanding methods --- + + // 1. either already sent, or waiting to be sent with no special + // consideration once we reconnect self.outstanding_methods = []; // each item has keys: msg, callback + + // 2. the sole outstanding method that needs to be waited on, or null + // same keys as outstanding_methods (notably wait is implicitly true + // but not set) + self.outstanding_wait_method = null; // same keys as outstanding_methods + // stores response from `outstanding_wait_method` while we wait for + // previous method calls to complete, as received in _livedata_result + self.outstanding_wait_method_response = null; + + // 3. methods blocked on outstanding_wait_method being completed. + self.blocked_methods = []; // each item has keys: msg, callback, wait + + // if set, called when we reconnect, queuing method calls _before_ + // the existing outstanding ones + self.onReconnect = null; // waiting for data from method self.unsatisfied_methods = {}; // map from method_id -> true // sub was ready, is no longer (due to reconnect) @@ -48,21 +75,26 @@ Meteor._LivedataConnection = function (url, restart_on_update) { // yet ready. self.sub_ready_callbacks = {}; + // Per-connection scratch area. This is only used internally, but we + // should have real and documented API for this sort of thing someday. + self.sessionData = {}; + // just for testing self.quiesce_callbacks = []; - - // Setup auto-reload persistence. - Meteor._reload.onMigrate(function (retry) { - if (!self._readyToMigrate()) { - if (self._retryMigrate) - throw new Error("Two migrations in progress?"); - self._retryMigrate = retry; - return false; - } - - return [true]; - }); + // Block auto-reload while we're waiting for method responses. + if (!options.reloadWithOutstanding) { + Meteor._reload.onMigrate(function (retry) { + if (!self._readyToMigrate()) { + if (self._retryMigrate) + throw new Error("Two migrations in progress?"); + self._retryMigrate = retry; + return false; + } else { + return [true]; + } + }); + } // Setup stream (if not overriden above) self.stream = self.stream || new Meteor._Stream(self.url); @@ -94,7 +126,6 @@ Meteor._LivedataConnection = function (url, restart_on_update) { }); self.stream.on('reset', function () { - // Send a connect message at the beginning of the stream. // NOTE: reset is called even on the first connection, so this is // the only place we send this message. @@ -115,10 +146,15 @@ Meteor._LivedataConnection = function (url, restart_on_update) { // immediately before disconnection.. do we need to add app-level // acking of data messages? - // Send pending methods. - _.each(self.outstanding_methods, function (m) { - self.stream.send(JSON.stringify(m.msg)); - }); + // If an `onReconnect` handler is set, call it first. Go through + // some hoops to ensure that methods that are called from within + // `onReconnect` get executed _before_ ones that were originally + // outstanding (since `onReconnect` is used to re-establish auth + // certificates) + if (self.onReconnect) + self._callOnReconnectAndSendAppropriateOutstandingMethods(); + else + self._sendOutstandingMethods(); // add new subscriptions at the end. this way they take effect after // the handlers and we don't see flicker. @@ -128,13 +164,14 @@ Meteor._LivedataConnection = function (url, restart_on_update) { }); }); - if (restart_on_update) + if (options.reloadOnUpdate) { self.stream.on('update_available', function () { // Start trying to migrate to a new version. Until all packages // signal that they're ready for a migration, the app will // continue running normally. Meteor._reload.reload(); }); + } // we never terminate the observe(), since there is no way to // destroy a LivedataConnection.. but this shouldn't matter, since we're @@ -186,7 +223,11 @@ _.extend(Meteor._LivedataConnection.prototype, { if (args.length && typeof args[args.length - 1] === "function") var callback = args.pop(); - var existing = self.subs.find({name: name, args: args}, {reactive: false}).fetch(); + // Look for existing subs (ignore those with count=0, since they're going to + // get removed on the next time through the event loop). + var existing = self.subs.find( + {name: name, args: args, count: {$gt: 0}}, + {reactive: false}).fetch(); if (existing && existing[0]) { // already subbed, inc count. @@ -242,11 +283,23 @@ _.extend(Meteor._LivedataConnection.prototype, { return this.apply(name, args, callback); }, - apply: function (name, args, callback) { + // @param options {Optional Object} + // wait: Boolean - Should we block subsequent method calls on this + // method's result having been received? + // (does not affect methods called from within this method) + // @param callback {Optional Function} + apply: function (name, args, options, callback) { var self = this; - var enclosing = Meteor._CurrentInvocation.get(); - if (callback) + // We were passed 3 arguments. They may be either (name, args, options) + // or (name, args, callback) + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + + if (callback) { // XXX would it be better form to do the binding in stream.on, // or caller, instead of here? callback = Meteor.bindEnvironment(callback, function (e) { @@ -254,8 +307,8 @@ _.extend(Meteor._LivedataConnection.prototype, { Meteor._debug("Exception while delivering result of invoking '" + name + "'", e.stack); }); + } - var isSimulation = enclosing && enclosing.isSimulation; if (Meteor.isClient) { // If on a client, run the stub, if we have one. The stub is // supposed to make some temporary writes to the database to @@ -271,7 +324,14 @@ _.extend(Meteor._LivedataConnection.prototype, { // of the stub as our return value. var stub = self.method_handlers[name]; if (stub) { - var invocation = new Meteor._MethodInvocation(true /* isSimulation */); + var setUserId = function(userId) { + self.setUserId(userId); + }; + var invocation = new Meteor._MethodInvocation({ + isSimulation: true, + userId: self.userId(), setUserId: setUserId, + sessionData: self.sessionData + }); try { var ret = Meteor._CurrentInvocation.withValue(invocation,function () { return stub.apply(invocation, args); @@ -283,10 +343,10 @@ _.extend(Meteor._LivedataConnection.prototype, { } // If we're in a simulation, stop and return the result we have, - // rather than going on to do an RPC. This can only happen on - // the client (since we only bother with stubs and simulations - // on the client.) If there was not stub, we'll end up returning - // undefined. + // rather than going on to do an RPC. If there was no stub, + // we'll end up returning undefined. + var enclosing = Meteor._CurrentInvocation.get(); + var isSimulation = enclosing && enclosing.isSimulation; if (isSimulation) { if (callback) { callback(exception, ret); @@ -305,7 +365,7 @@ _.extend(Meteor._LivedataConnection.prototype, { // go to log. if (exception && !exception.expected) Meteor._debug("Exception while simulating the effect of invoking '" + - name + "'", exception.stack); + name + "'", exception, exception.stack); } // At this point we're definitely doing an RPC, and we're going to @@ -337,9 +397,31 @@ _.extend(Meteor._LivedataConnection.prototype, { params: args, id: '' + (self.next_method_id++) }; - self.outstanding_methods.push({msg: msg, callback: callback}); + + if (self.outstanding_wait_method) { + self.blocked_methods.push({ + msg: msg, + callback: callback, + wait: options.wait + }); + } else { + var method_object = { + msg: msg, + callback: callback + }; + + if (options.wait) + self.outstanding_wait_method = method_object; + else + self.outstanding_methods.push(method_object); + + self.stream.send(JSON.stringify(msg)); + } + + // Even if we are waiting on other method calls mark this method + // as unsatisfied so that the user never ends up seeing + // intermediate versions of the server's datastream self.unsatisfied_methods[msg.id] = true; - self.stream.send(JSON.stringify(msg)); // If we're using the default callback on the server, // synchronously return the result from the remote host. @@ -351,16 +433,37 @@ _.extend(Meteor._LivedataConnection.prototype, { } }, - status: function () { + status: function (/*passthrough args*/) { var self = this; - return self.stream.status(); + return self.stream.status.apply(self.stream, arguments); }, - reconnect: function () { + reconnect: function (/*passthrough args*/) { var self = this; - return self.stream.reconnect(); + return self.stream.reconnect.apply(self.stream, arguments); }, + /// + /// Reactive user system + /// XXX Can/should this be generalized pattern? + /// + userId: function () { + var self = this; + if (self._userIdListeners) + self._userIdListeners.addCurrentContext(); + return self._userId; + }, + + setUserId: function (userId) { + var self = this; + self._userId = userId; + if (self._userIdListeners) + self._userIdListeners.invalidateAll(); + }, + + _userId: null, + _userIdListeners: Meteor.deps && new Meteor.deps._ContextSet, + // PRIVATE: called when we are up-to-date with the server. intended // for use only in tests. currently, you are very limited in what // you may do inside your callback -- in particular, don't do @@ -507,36 +610,86 @@ _.extend(Meteor._LivedataConnection.prototype, { }, _livedata_result: function (msg) { - var self = this; // id, result or error. error has error (code), reason, details + var self = this; // find the outstanding request // should be O(1) in nearly all realistic use cases - for (var i = 0; i < self.outstanding_methods.length; i++) { - var m = self.outstanding_methods[i]; - if (m.msg.id === msg.id) - break; + var m; + if (self.outstanding_wait_method && + self.outstanding_wait_method.msg.id === msg.id) { + m = self.outstanding_wait_method; + self.outstanding_wait_method_response = msg; + } else { + for (var i = 0; i < self.outstanding_methods.length; i++) { + m = self.outstanding_methods[i]; + if (m.msg.id === msg.id) + break; + } + + // remove + self.outstanding_methods.splice(i, 1); } + if (!m) { - // XXX write a better error - Meteor._debug("Can't interpret method response message"); + Meteor._debug("Can't match method response to original method call", msg); return; } - // remove - self.outstanding_methods.splice(i, 1); + if (self.outstanding_wait_method) { + // Wait until we have completed all outstanding methods. + if (self.outstanding_methods.length === 0 && + self.outstanding_wait_method_response) { - // deliver result - if (m.callback) { - // callback will have already been bindEnvironment'd by apply(), - // so no need to catch exceptions - if ('error' in msg) - m.callback(new Meteor.Error(msg.error.error, msg.error.reason, - msg.error.details)); - else - // msg.result may be undefined if the method didn't return a - // value - m.callback(undefined, msg.result); + // Start by saving the outstanding wait method details, since + // we're going to reshift the blocked ones and try to send + // them *before* calling the method callback. It is necessary + // to call method callbacks last since they might themselves + // call other methods + var savedOutstandingWaitMethod = self.outstanding_wait_method; + var savedOutstandingWaitMethodResponse = self.outstanding_wait_method_response; + self.outstanding_wait_method_response = null; + self.outstanding_wait_method = null; + + // Find first blocked method with wait: true + var i; + for (i = 0; i < self.blocked_methods.length; i++) + if (self.blocked_methods[i].wait) + break; + + // Move as many blocked methods as we can into + // outstanding_methods and outstanding_wait_method if needed + self.outstanding_methods = _.first(self.blocked_methods, i); + if (i !== self.blocked_methods.length) { + self.outstanding_wait_method = self.blocked_methods[i]; + self.blocked_methods = _.rest(self.blocked_methods, i+1); + } else { + self.blocked_methods = []; + } + + // Send any new outstanding methods after we reshift the + // blocked methods. Intentionally do this before calling the + // method response because they might call additional methods + // that shouldn't be sent twice. + self._sendOutstandingMethods(); + + // Fire necessary outstanding method callbacks, making sure we + // only fire the outstanding wait method after all other outstanding + // methods' callbacks were fired + if (m === savedOutstandingWaitMethod) { + self._deliverMethodResponse(savedOutstandingWaitMethod, + savedOutstandingWaitMethodResponse /*(=== msg)*/); + } else { + self._deliverMethodResponse(m, msg); + self._deliverMethodResponse(savedOutstandingWaitMethod, + savedOutstandingWaitMethodResponse /*(!== msg)*/); + } + } else { + if (m !== self.outstanding_wait_method) + self._deliverMethodResponse(m, msg); + } + } else { + self._deliverMethodResponse(m, msg); } // if we were blocking a migration, see if it's now possible to @@ -547,16 +700,84 @@ _.extend(Meteor._LivedataConnection.prototype, { } }, + // @param method {Object} as in `outstanding_methods` + // @param response {Object{id, result | error}} + _deliverMethodResponse: function(method, response) { + // callback will have already been bindEnvironment'd by apply(), + // so no need to catch exceptions + if ('error' in response) { + method.callback(new Meteor.Error( + response.error.error, response.error.reason, + response.error.details)); + } else { + // msg.result may be undefined if the method didn't return a + // value + method.callback(undefined, response.result); + } + }, + + _sendOutstandingMethods: function() { + var self = this; + _.each(self.outstanding_methods, function (m) { + self.stream.send(JSON.stringify(m.msg)); + }); + if (self.outstanding_wait_method) + self.stream.send(JSON.stringify(self.outstanding_wait_method.msg)); + }, + _livedata_error: function (msg) { Meteor._debug("Received error from server: ", msg.reason); if (msg.offending_message) Meteor._debug("For: ", msg.offending_message); }, - // true if we're OK for a migration to happen - _readyToMigrate: function () { + _callOnReconnectAndSendAppropriateOutstandingMethods: function() { var self = this; - return self.outstanding_methods.length === 0; + var old_outstanding_methods = self.outstanding_methods; + var old_outstanding_wait_method = self.outstanding_wait_method; + var old_blocked_methods = self.blocked_methods; + self.outstanding_methods = []; + self.outstanding_wait_method = null; + self.blocked_methods = []; + + self.onReconnect(); + + if (self.outstanding_wait_method) { + // self.onReconnect() caused us to wait on a method. Add all old + // methods to blocked_methods, and we don't need to send any + // additional methods + self.blocked_methods = self.blocked_methods.concat( + old_outstanding_methods); + + if (old_outstanding_wait_method) { + self.blocked_methods.push(_.extend( + old_outstanding_wait_method, {wait: true})); + } + + self.blocked_methods = self.blocked_methods.concat( + old_blocked_methods); + } else { + // self.onReconnect() did not cause us to wait on a method. Add + // as many methods as we can to outstanding_methods and send + // them + _.each(old_outstanding_methods, function(method) { + self.outstanding_methods.push(method); + self.stream.send(JSON.stringify(method.msg)); + }); + + self.outstanding_wait_method = old_outstanding_wait_method; + if (self.outstanding_wait_method) + self.stream.send(JSON.stringify(self.outstanding_wait_method.msg)); + + self.blocked_methods = old_blocked_methods; + } + }, + + _readyToMigrate: function() { + var self = this; + return self.outstanding_methods.length === 0 && + !self.outstanding_wait_method && + self.blocked_methods.length === 0; } }); @@ -568,8 +789,9 @@ _.extend(Meteor, { // "/", // "http://subdomain.meteor.com/sockjs" (deprecated), // "/sockjs" (deprecated) - connect: function (url, _restartOnUpdate) { - var ret = new Meteor._LivedataConnection(url, _restartOnUpdate); + connect: function (url, _reloadOnUpdate) { + var ret = new Meteor._LivedataConnection( + url, {reloadOnUpdate: _reloadOnUpdate}); Meteor._LivedataConnection._allConnections.push(ret); // hack. see below. return ret; }, diff --git a/packages/livedata/livedata_connection_tests.js b/packages/livedata/livedata_connection_tests.js index 35ba174607..6e216e2d76 100644 --- a/packages/livedata/livedata_connection_tests.js +++ b/packages/livedata/livedata_connection_tests.js @@ -1,3 +1,10 @@ +var newConnection = function (stream) { + // Some of these tests leave outstanding methods with no result yet + // returned. This should not block us from re-running tests when sources + // change. + return new Meteor._LivedataConnection(stream, {reloadWithOutstanding: true}); +}; + var test_got_message = function (test, stream, expected) { if (stream.sent.length === 0) { test.fail({error: 'no message received', expected: expected}); @@ -12,12 +19,7 @@ var test_got_message = function (test, stream, expected) { test.equal(got, expected); }; -var SESSION_ID = '17'; - -Tinytest.add("livedata stub - receive data", function (test) { - var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); - +var startAndConnect = function(test, stream) { stream.reset(); // initial connection start. test_got_message(test, stream, {msg: 'connect'}); @@ -25,6 +27,15 @@ Tinytest.add("livedata stub - receive data", function (test) { stream.receive({msg: 'connected', session: SESSION_ID}); test.length(stream.sent, 0); +}; + +var SESSION_ID = '17'; + +Tinytest.add("livedata stub - receive data", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + + startAndConnect(test, stream); // data comes in for unknown collection. var coll_name = Meteor.uuid(); @@ -33,6 +44,8 @@ Tinytest.add("livedata stub - receive data", function (test) { // break throught the black box and test internal state test.length(conn.queued[coll_name], 1); + // XXX: Test that the old signature of passing manager directly instead of in + // options works. var coll = new Meteor.Collection(coll_name, conn); // queue has been emptied and doc is in db. @@ -46,19 +59,11 @@ Tinytest.add("livedata stub - receive data", function (test) { test.isUndefined(conn.queued[coll_name]); }); - - -Tinytest.add("livedata stub - subscribe", function (test) { +Tinytest.addAsync("livedata stub - subscribe", function (test, onComplete) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); // subscribe var callback_fired = false; @@ -67,6 +72,7 @@ Tinytest.add("livedata stub - subscribe", function (test) { }); test.isFalse(callback_fired); + test.length(stream.sent, 1); var message = JSON.parse(stream.sent.shift()); var id = message.id; delete message.id; @@ -75,18 +81,42 @@ Tinytest.add("livedata stub - subscribe", function (test) { // get the sub satisfied. callback fires. stream.receive({msg: 'data', 'subs': [id]}); test.isTrue(callback_fired); + + // This defers the actual unsub message, so we need to set a timeout + // to observe the message. We also test that we can resubscribe even + // before the unsub has been sent. + // + // Note: it would be perfectly fine for livedata_connection to send the unsub + // synchronously, so if this test fails just because we've made that change, + // that's OK! This is a regression test for a failure case where it *never* + // sent the unsub if there was a quick resub afterwards. + // + // XXX rewrite Meteor.defer to guarantee ordered execution so we don't have to + // use setTimeout + sub.stop(); + conn.subscribe('my_data'); + + test.length(stream.sent, 1); + message = JSON.parse(stream.sent.shift()); + var id2 = message.id; + test.notEqual(id, id2); + delete message.id; + test.equal(message, {msg: 'sub', name: 'my_data', params: []}); + + setTimeout(function() { + test.length(stream.sent, 1); + var message = JSON.parse(stream.sent.shift()); + test.equal(message, {msg: 'unsub', id: id}); + onComplete(); + }, 10); }); Tinytest.add("livedata stub - this", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - test_got_message(test, stream, {msg: 'connect'}); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); conn.methods({test_this: function() { test.isTrue(this.isSimulation); @@ -112,18 +142,12 @@ Tinytest.add("livedata stub - this", function (test) { Tinytest.add("livedata stub - methods", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); var coll_name = Meteor.uuid(); - var coll = new Meteor.Collection(coll_name, conn); + var coll = new Meteor.Collection(coll_name, {manager: conn}); // setup method conn.methods({do_something: function (x) { @@ -211,18 +235,12 @@ Tinytest.add("livedata stub - methods", function (test) { // method calls another method in simulation. see not sent. Tinytest.add("livedata stub - sub methods", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); var coll_name = Meteor.uuid(); - var coll = new Meteor.Collection(coll_name, conn); + var coll = new Meteor.Collection(coll_name, {manager: conn}); // setup methods conn.methods({ @@ -287,18 +305,12 @@ Tinytest.add("livedata stub - sub methods", function (test) { // data is shown Tinytest.add("livedata stub - reconnect", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); var coll_name = Meteor.uuid(); - var coll = new Meteor.Collection(coll_name, conn); + var coll = new Meteor.Collection(coll_name, {manager: conn}); // setup observers var counts = {added: 0, removed: 0, changed: 0, moved: 0}; @@ -344,9 +356,12 @@ Tinytest.add("livedata stub - reconnect", function (test) { conn.call('do_something', function () { method_callback_fired = true; }); + conn.apply('do_something', [], {wait: true}); + test.isFalse(method_callback_fired); var method_message = JSON.parse(stream.sent.shift()); + var wait_method_message = JSON.parse(stream.sent.shift()); test.equal(method_message, {msg: 'method', method: 'do_something', params: [], id:method_message.id}); @@ -356,13 +371,13 @@ Tinytest.add("livedata stub - reconnect", function (test) { test.equal(coll.find({c:3}).count(), 0); test.equal(counts, {added: 1, removed: 0, changed: 1, moved: 0}); - // stream reset. reconnect! // we send a connect, our pending messages, and our subs. stream.reset(); test_got_message(test, stream, {msg: 'connect', session: SESSION_ID}); test_got_message(test, stream, method_message); + test_got_message(test, stream, wait_method_message); test_got_message(test, stream, sub_message); // reconnect with different session id @@ -378,10 +393,12 @@ Tinytest.add("livedata stub - reconnect", function (test) { test.equal(counts, {added: 1, removed: 0, changed: 1, moved: 0}); // satisfy and return method callback - stream.receive({msg: 'data', methods: [method_message.id]}); + stream.receive({msg: 'data', + methods: [method_message.id, wait_method_message.id]}); test.isFalse(method_callback_fired); stream.receive({msg: 'result', id:method_message.id, result:"bupkis"}); + stream.receive({msg: 'result', id:wait_method_message.id, result:"bupkis"}); test.isTrue(method_callback_fired); // still no update. @@ -399,7 +416,195 @@ Tinytest.add("livedata stub - reconnect", function (test) { handle.stop(); }); +Tinytest.add("livedata connection - reactive userId", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + + test.equal(conn.userId(), null); + conn.setUserId(1337); + test.equal(conn.userId(), 1337); +}); + +Tinytest.add("livedata connection - two wait methods with reponse in order", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + var responses = []; + conn.apply('do_something', ['one!'], function() { responses.push('one'); }); + var one_message = JSON.parse(stream.sent.shift()); + test.equal(one_message.params, ['one!']); + + conn.apply('do_something', ['two!'], {wait: true}, function() { + responses.push('two'); + }); + var two_message = JSON.parse(stream.sent.shift()); + test.equal(two_message.params, ['two!']); + test.equal(responses, []); + + conn.apply('do_something', ['three!'], function() { + responses.push('three'); + }); + conn.apply('do_something', ['four!'], {wait: true}, function() { + responses.push('four'); + }); + + conn.apply('do_something', ['five!'], function() { responses.push('five'); }); + + // Verify that we did not send "three!" since we're waiting for + // "one!" and "two!" to send their response back + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: one_message.id}); + test.equal(responses, ['one']); + + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: two_message.id}); + test.equal(responses, ['one', 'two']); + + // Verify that we just sent "three!" and "four!" now that we got + // responses for "one!" and "two!" + test.equal(stream.sent.length, 2); + var three_message = JSON.parse(stream.sent.shift()); + test.equal(three_message.params, ['three!']); + var four_message = JSON.parse(stream.sent.shift()); + test.equal(four_message.params, ['four!']); + + stream.receive({msg: 'result', id: three_message.id}); + test.equal(responses, ['one', 'two', 'three']); + + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: four_message.id}); + test.equal(responses, ['one', 'two', 'three', 'four']); + + // Verify that we just sent "five!" + test.equal(stream.sent.length, 1); + var five_message = JSON.parse(stream.sent.shift()); + test.equal(five_message.params, ['five!']); +}); + +Tinytest.add("livedata connection - one wait method with response out of order", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + var responses = []; + conn.apply('do_something', ['one!'], function() { responses.push('one'); }); + var one_message = JSON.parse(stream.sent.shift()); + test.equal(one_message.params, ['one!']); + + conn.apply('do_something', ['two!'], {wait: true}, function() { + responses.push('two'); + }); + var two_message = JSON.parse(stream.sent.shift()); + test.equal(two_message.params, ['two!']); + test.equal(responses, []); + + conn.apply('do_something', ['three!']); + + // Verify that we did not send "three!" since we're waiting for + // "one!" and "two!" to send their response back + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: two_message.id}); + test.equal(responses, []); + + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: one_message.id}); + test.equal(responses, ['one', 'two']); // Namely not two, one + + // Verify that we just sent "three!" now that we got responses for + // "one!" and "two!" + test.equal(stream.sent.length, 1); + var three_message = JSON.parse(stream.sent.shift()); + test.equal(three_message.params, ['three!']); + // Since we sent it, it should no longer be in "blocked_methods". + test.equal(conn.blocked_methods, []); +}); + +Tinytest.add("livedata connection - onReconnect prepends messages correctly with a wait method", function(test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + conn.onReconnect = function() { + conn.apply('do_something', ['reconnect one']); + conn.apply('do_something', ['reconnect two'], {wait: true}); + conn.apply('do_something', ['reconnect three']); + }; + + conn.apply('do_something', ['one']); + conn.apply('do_something', ['two'], {wait: true}); + conn.apply('do_something', ['three']); + + // reconnect + stream.sent = []; + stream.reset(); + test_got_message( + test, stream, {msg: 'connect', session: conn.last_session_id}); + + // Test that we sent what we expect to send, and we're blocked on + // what we expect to be blocked. The subsequent logic to correctly + // read the wait flag is tested separately. + test.equal(_.map(stream.sent, function(msg) { + return JSON.parse(msg).params[0]; + }), ['reconnect one', 'reconnect two']); + test.equal(_.map(conn.blocked_methods, function(method) { + return [method.msg.params[0], method.wait]; + }), [ + ['reconnect three', undefined/*==false*/], + ['one', undefined/*==false*/], + ['two', true], + ['three', undefined/*==false*/] + ]); +}); + +Tinytest.add("livedata connection - onReconnect prepends messages correctly without a wait method", function(test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + conn.onReconnect = function() { + conn.apply('do_something', ['reconnect one']); + conn.apply('do_something', ['reconnect two']); + conn.apply('do_something', ['reconnect three']); + }; + + conn.apply('do_something', ['one']); + conn.apply('do_something', ['two'], {wait: true}); + conn.apply('do_something', ['three']); + + // reconnect + stream.sent = []; + stream.reset(); + test_got_message( + test, stream, {msg: 'connect', session: conn.last_session_id}); + + // Test that we sent what we expect to send, and we're blocked on + // what we expect to be blocked. The subsequent logic to correctly + // read the wait flag is tested separately. + test.equal(_.map(stream.sent, function(msg) { + return JSON.parse(msg).params[0]; + }), ['reconnect one', 'reconnect two', 'reconnect three', 'one', 'two']); + test.equal(_.map(conn.blocked_methods, function(method) { + return [method.msg.params[0], method.wait]; + }), [ + ['three', undefined/*==false*/] + ]); +}); + // XXX also test: // - reconnect, with session resume. // - restart on update flag // - on_update event +// - reloading when the app changes, including session migration diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index e10c9d73de..d70e8631b4 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -30,6 +30,18 @@ Meteor._LivedataSession = function (server) { // map from collection name -> id -> key -> subscription id -> true self.provides_key = {}; + + // if set, ignore flush requests on any subsubcription on this + // session. when set this back to false, don't forget to call flush + // manually. this is sometimes needed because subscriptions + // frequently call flush + self.dontFlush = false; + + self.userId = null; + + // Per-connection scratch area. This is only used internally, but we + // should have real and documented API for this sort of thing someday. + self.sessionData = {}; }; _.extend(Meteor._LivedataSession.prototype, { @@ -269,8 +281,16 @@ _.extend(Meteor._LivedataSession.prototype, { return; } - var invocation = new Meteor._MethodInvocation(false /* isSimulation */, - unblock); + var setUserId = function(userId) { + self._setUserId(userId); + }; + + var invocation = new Meteor._MethodInvocation({ + isSimulation: false, + userId: self.userId, setUserId: setUserId, + unblock: unblock, + sessionData: self.sessionData + }); try { var ret = Meteor._CurrentWriteFence.withValue(fence, function () { @@ -305,6 +325,20 @@ _.extend(Meteor._LivedataSession.prototype, { } }, + // Sets the current user id in all appropriate contexts and reruns + // all subscriptions + _setUserId: function(userId) { + var self = this; + self.userId = userId; + this._rerunAllSubscriptions(); + + // 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, priority, sub_id, params) { var self = this; @@ -314,23 +348,29 @@ _.extend(Meteor._LivedataSession.prototype, { else self.universal_subs.push(sub); - try { - var res = handler.apply(sub, params || []); - } catch (e) { - Meteor._debug("Internal exception while starting subscription", sub_id, - e.stack); - return; - } + // Store a function to re-run the handler in case we want to rerun + // subscriptions, for example when the current user id changes + sub._runHandler = function() { + try { + var res = handler.apply(sub, params || []); + } catch (e) { + Meteor._debug("Internal exception while starting subscription", sub_id, + e.stack); + return; + } - // if Meteor._RemoteCollectionDriver is available (defined in - // mongo-livedata), automatically wire up handlers that return a - // Cursor. otherwise, the handler is completely responsible for - // delivering its own data messages and registering stop - // functions. - // - // XXX generalize - if (Meteor._RemoteCollectionDriver && (res instanceof Meteor._Mongo.Cursor)) - sub._publishCursor(res); + // if Meteor._RemoteCollectionDriver is available (defined in + // mongo-livedata), automatically wire up handlers that return a + // Cursor. otherwise, the handler is completely responsible for + // delivering its own data messages and registering stop + // functions. + // + // XXX generalize + if (Meteor._RemoteCollectionDriver && (res instanceof Meteor._Mongo.Cursor)) + sub._publishCursor(res); + }; + + sub._runHandler(); }, // tear down specified subscription @@ -358,7 +398,30 @@ _.extend(Meteor._LivedataSession.prototype, { self.universal_subs = []; }, - // return the current value for a particular key, as given by the + // Rerun all subscriptions without sending intermediate state down + // the wire + _rerunAllSubscriptions: function () { + var self = this; + + var rerunSub = function(sub) { + sub._teardown(); + sub.userId = self.userId; + sub._runHandler(); + }; + var flushSub = function(sub) { + sub.flush(); + }; + + self.dontFlush = true; + _.each(self.named_subs, rerunSub); + _.each(self.universal_subs, rerunSub); + + self.dontFlush = false; + _.each(self.named_subs, flushSub); + _.each(self.universal_subs, flushSub); + }, + + // RETURN the current value for a particular key, as given by the // current contents of each subscription's snapshot. _effectiveValueForKey: function (collection_name, id, key) { var self = this; @@ -388,6 +451,12 @@ Meteor._LivedataSubscription = function (session, sub_id, priority) { // LivedataSession this.session = session; + // Give access to sessionData in subscriptions as well as + // methods. This is not currently used, but is included for + // consistency. We should have real and documented API for this sort + // of thing someday. + this._sessionData = session.sessionData; + // my subscription ID (generated by client, null for universal subs). this.sub_id = sub_id; @@ -413,6 +482,8 @@ Meteor._LivedataSubscription = function (session, sub_id, priority) { // stop callbacks to g/c this sub. called w/ zero arguments. this.stop_callbacks = []; + + this.userId = session.userId; }; _.extend(Meteor._LivedataSubscription.prototype, { @@ -422,22 +493,7 @@ _.extend(Meteor._LivedataSubscription.prototype, { if (self.stopped) return; - // tell listeners, so they can clean up - for (var i = 0; i < this.stop_callbacks.length; i++) - (this.stop_callbacks[i])(); - - // remove our data from the client (possibly unshadowing data from - // lower priority subscriptions) - self.pending_data = {}; - self.pending_complete = false; - for (var name in self.snapshot) { - self.pending_data[name] = {}; - for (var id in self.snapshot[name]) { - self.pending_data[name][id] = {}; - for (var key in self.snapshot[name][id]) - self.pending_data[name][id][key] = undefined; - } - } + self._teardown(); self.flush(); self.stopped = true; }, @@ -478,6 +534,9 @@ _.extend(Meteor._LivedataSubscription.prototype, { flush: function () { var self = this; + if (self.session.dontFlush) + return; + if (self.stopped) return; @@ -546,6 +605,26 @@ _.extend(Meteor._LivedataSubscription.prototype, { self.pending_complete = false; }, + _teardown: function() { + var self = this; + // tell listeners, so they can clean up + for (var i = 0; i < self.stop_callbacks.length; i++) + (self.stop_callbacks[i])(); + + // remove our data from the client (possibly unshadowing data from + // lower priority subscriptions) + self.pending_data = {}; + self.pending_complete = false; + for (var name in self.snapshot) { + self.pending_data[name] = {}; + for (var id in self.snapshot[name]) { + self.pending_data[name][id] = {}; + for (var key in self.snapshot[name][id]) + self.pending_data[name][id][key] = undefined; + } + } + }, + _publishCursor: function (cursor, name) { var self = this; var collection = name || cursor.collection_name; @@ -671,16 +750,24 @@ Meteor._LivedataServer = function () { }); }); - // Every minute, clean up sessions that have been abandoned for 15 - // minutes. Also run result cache cleanup. + // 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 + // 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); - _.each(self.sessions, function (s) { + var destroyedIds = []; + _.each(self.sessions, function (s, id) { s.cleanup(); - if (!s.socket && (now - s.last_detach_time) > 15 * 60 * 1000) + if (!s.socket && (now - s.last_detach_time) > 60 * 1000) { s.destroy(); + destroyedIds.push(id); + } + }); + _.each(destroyedIds, function (id) { + delete self.sessions[id]; }); }, 1 * 60 * 1000); }; @@ -767,9 +854,19 @@ _.extend(Meteor._LivedataServer.prototype, { return this.apply(name, args, callback); }, - apply: function (name, args, callback) { + // @param options {Optional Object} + // @param callback {Optional Function} + apply: function (name, args, options, callback) { var self = this; + // We were passed 3 arguments. They may be either (name, args, options) + // or (name, args, callback) + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + if (callback) // It's not really necessary to do this, since we immediately // run the callback in this fiber before returning, but we do it @@ -785,7 +882,26 @@ _.extend(Meteor._LivedataServer.prototype, { if (!handler) var exception = new Meteor.Error(404, "Method not found"); else { - var invocation = new Meteor._MethodInvocation(false /* isSimulation */); + // If this is a method call from within another method, get the + // user state from the outer method, otherwise don't allow + // setUserId to be called + var userId = null; + var setUserId = function() { + throw new Error("Can't call setUserId on a server initiated method call"); + }; + var currentInvocation = Meteor._CurrentInvocation.get(); + if (currentInvocation) { + userId = currentInvocation.userId; + setUserId = function(userId) { + currentInvocation.setUserId(userId); + }; + } + + var invocation = new Meteor._MethodInvocation({ + isSimulation: false, + userId: userId, setUserId: setUserId, + sessionData: self.sessionData + }); try { var ret = Meteor._CurrentInvocation.withValue(invocation, function () { return handler.apply(invocation, args); diff --git a/packages/livedata/livedata_test_service.js b/packages/livedata/livedata_test_service.js index b98597c238..5d6281858c 100644 --- a/packages/livedata/livedata_test_service.js +++ b/packages/livedata/livedata_test_service.js @@ -22,9 +22,56 @@ Meteor.methods({ } }); +// Methods to help test applying methods with `wait: true`: delayedTrue returns +// true 1s after being run unless makeDelayedTrueImmediatelyReturnFalse was run +// in the meanwhile. Increasing the timeout makes the "wait: true" test slower; +// decreasing the timeout makes the "wait: false" test flakier (ie, the timeout +// could fire before processing the second method). +if (Meteor.isServer) { + // Keys are random tokens, used to isolate multiple test invocations from each + // other. + var waiters = {}; + + var returnThroughFuture = function (token, returnValue) { + // Make sure that when we call return, the fields are already cleared. + var record = waiters[token]; + if (!record) + return; + delete waiters[token]; + record.future['return'](returnValue); + }; + + Meteor.methods({ + delayedTrue: function(token) { + var record = waiters[token] = { + future: new Future(), + timer: Meteor.setTimeout(function() { + returnThroughFuture(token, true); + }, 1000) + }; + + this.unblock(); + return record.future.wait(); + }, + makeDelayedTrueImmediatelyReturnFalse: function(token) { + var record = waiters[token]; + if (!record) + return; // since delayedTrue's timeout had already run + clearTimeout(record.timer); + returnThroughFuture(token, false); + } + }); +} + /*****/ Ledger = new Meteor.Collection("ledger"); +Ledger.allow({ + insert: function() { return true; }, + update: function() { return true; }, + remove: function() { return true; }, + fetch: [] +}); Meteor.startup(function () { if (Meteor.isServer) @@ -60,4 +107,57 @@ Meteor.methods({ Ledger.update({_id: to._id}, {$inc: {balance: amount}}); Meteor.refresh({collection: 'ledger', world: world}); } -}); \ No newline at end of file +}); + +/*****/ + +/// Helpers for "livedata - changing userid reruns subscriptions..." + +objectsWithUsers = new Meteor.Collection("objectsWithUsers"); + +if (Meteor.isServer) { + objectsWithUsers.remove({}); + objectsWithUsers.insert({name: "owned by none", ownerUserIds: [null]}); + objectsWithUsers.insert({name: "owned by one - a", ownerUserIds: [1]}); + objectsWithUsers.insert({name: "owned by one/two - a", ownerUserIds: [1, 2]}); + objectsWithUsers.insert({name: "owned by one/two - b", ownerUserIds: [1, 2]}); + objectsWithUsers.insert({name: "owned by two - a", ownerUserIds: [2]}); + objectsWithUsers.insert({name: "owned by two - b", ownerUserIds: [2]}); + + Meteor.publish("objectsWithUsers", function() { + return objectsWithUsers.find({ownerUserIds: this.userId}, + {fields: {ownerUserIds: 0}}); + }); + + userIdWhenStopped = null; + Meteor.publish("recordUserIdOnStop", function() { + var self = this; + self.onStop(function() { + userIdWhenStopped = self.userId; + }); + }); + + Meteor.methods({ + setUserId: function(userId) { + this.setUserId(userId); + }, + userIdWhenStopped: function() { + return userIdWhenStopped; + } + }); +} + +/*****/ + +/// Helper for "livedata - setUserId fails when called on server" + +if (Meteor.isServer) { + Meteor.startup(function() { + errorThrownWhenCallingSetUserIdDirectlyOnServer = null; + try { + Meteor.call("setUserId", 1000); + } catch (e) { + errorThrownWhenCallingSetUserIdDirectlyOnServer = e; + } + }); +} diff --git a/packages/livedata/livedata_tests.js b/packages/livedata/livedata_tests.js index 53cb7704f8..52eb6bcdca 100644 --- a/packages/livedata/livedata_tests.js +++ b/packages/livedata/livedata_tests.js @@ -17,7 +17,7 @@ var failure = function (test, code, reason) { } } }; -} +}; Tinytest.add("livedata - Meteor.Error", function (test) { var error = new Meteor.Error(123, "kittens", "puppies"); @@ -105,6 +105,31 @@ testAsyncMulti("livedata - basic method invocation", [ expect(undefined, [12, {x: 13}])), undefined); }, + // test that `wait: false` is respected + function (test, expect) { + if (Meteor.isClient) { + // For test isolation + var token = Meteor.uuid(); + Meteor.apply( + "delayedTrue", [token], {wait: false}, expect(function(err, res) { + test.equal(res, false); + })); + Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", [token]); + } + }, + + // test that `wait: true` is respected + function(test, expect) { + if (Meteor.isClient) { + var token = Meteor.uuid(); + Meteor.apply( + "delayedTrue", [token], {wait: true}, expect(function(err, res) { + test.equal(res, true); + })); + Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", [token]); + } + }, + function (test, expect) { // No callback @@ -184,12 +209,14 @@ testAsyncMulti("livedata - basic method invocation", [ ]); + + var checkBalances = function (test, a, b) { var alice = Ledger.findOne({name: "alice", world: test.runId()}); var bob = Ledger.findOne({name: "bob", world: test.runId()}); test.equal(alice.balance, a); test.equal(bob.balance, b); -} +}; var onQuiesce = function (f) { if (Meteor.isServer) @@ -204,6 +231,7 @@ testAsyncMulti("livedata - compound methods", [ function (test) { if (Meteor.isClient) Meteor.subscribe("ledger", test.runId()); + Ledger.insert({name: "alice", balance: 100, world: test.runId()}); Ledger.insert({name: "bob", balance: 50, world: test.runId()}); }, @@ -238,6 +266,127 @@ testAsyncMulti("livedata - compound methods", [ } ]); +// Replaces the LivedataConnection's `_livedata_data` method to push +// incoming messages on a given collection to an array. This can be +// used to verify that the right data is sent on the wire +// +// @param messages {Array} The array to which to append the messages +// @return {Function} A function to call to undo the eavesdropping +var eavesdropOnCollection = function(livedata_connection, + collection_name, messages) { + old_livedata_data = _.bind( + livedata_connection._livedata_data, livedata_connection); + + // Kind of gross since all tests past this one will run with this + // hook set up. That's probably fine since we only check a specific + // collection but still... + // + // Should we consider having a separate connection per Tinytest or + // some similar scheme? + livedata_connection._livedata_data = function(msg) { + if (msg.collection && msg.collection === collection_name) { + messages.push(msg); + } + old_livedata_data(msg); + }; + + return function() { + livedata_connection._livedata_data = old_livedata_data; + }; +}; + +testAsyncMulti("livedata - changing userid reruns subscriptions without flapping data on the wire", [ + function(test, expect) { + if (Meteor.isClient) { + var messages = []; + var undoEavesdrop = eavesdropOnCollection( + Meteor.default_connection, "objectsWithUsers", messages); + + // A helper for testing incoming set and unset messages + // XXX should this be extracted as a general helper together with + // eavesdropOnCollection? + var testSetAndUnset = function(expectation) { + test.equal(_.map(messages, function(msg) { + var result = {}; + if (msg.set) + result.set = msg.set.name; + if (msg.unset) + result.unset = true; + return result; + }), expectation); + messages.length = 0; // clear messages without creating a new object + }; + + Meteor.subscribe("objectsWithUsers", expect(function() { + testSetAndUnset([{set: "owned by none"}]); + test.equal(objectsWithUsers.find().count(), 1); + Meteor.defer(sendFirstSetUserId); + })); + + // Contorted since we need to call expect at the top level of a test + // (see comment at top of async_multi.js) + + var sendFirstSetUserId = expect(function() { + Meteor.apply("setUserId", [1], {wait: true}); + Meteor.default_connection.onQuiesce(afterFirstSetUserId); + }); + + var afterFirstSetUserId = expect(function() { + testSetAndUnset([ + {unset: true}, + {set: "owned by one - a"}, + {set: "owned by one/two - a"}, + {set: "owned by one/two - b"}]); + test.equal(objectsWithUsers.find().count(), 3); + Meteor.defer(sendSecondSetUserId); + }); + + var sendSecondSetUserId = expect(function() { + Meteor.apply("setUserId", [2], {wait: true}); + Meteor.default_connection.onQuiesce(afterSecondSetUserId); + }); + + var afterSecondSetUserId = expect(function() { + testSetAndUnset([ + {unset: true}, + {set: "owned by two - a"}, + {set: "owned by two - b"}]); + test.equal(objectsWithUsers.find().count(), 4); + Meteor.defer(sendThirdSetUserId); + }); + + var sendThirdSetUserId = expect(function() { + Meteor.apply("setUserId", [2], {wait: true}); + Meteor.default_connection.onQuiesce(afterThirdSetUserId); + }); + + var afterThirdSetUserId = expect(function() { + // Nothing should have been sent since the results of the + // query are the same ("don't flap data on the wire") + testSetAndUnset([]); + test.equal(objectsWithUsers.find().count(), 4); + undoEavesdrop(); + }); + } + }, function(test, expect) { + if (Meteor.isClient) { + Meteor.subscribe("recordUserIdOnStop"); + Meteor.apply("setUserId", [100], {wait: true}, expect(function() {})); + Meteor.apply("setUserId", [101], {wait: true}, expect(function() {})); + Meteor.call("userIdWhenStopped", expect(function(err, result) { + test.equal(result, 100); + })); + } + } +]); + +Tinytest.add("livedata - setUserId error when called from server", function(test) { + if (Meteor.isServer) { + test.equal(errorThrownWhenCallingSetUserIdDirectlyOnServer.message, + "Can't call setUserId on a server initiated method call"); + } +}); + // XXX some things to test in greater detail: // staying in simulation mode // time warp diff --git a/packages/localstorage-polyfill/localstorage_polyfill.js b/packages/localstorage-polyfill/localstorage_polyfill.js new file mode 100644 index 0000000000..98995137db --- /dev/null +++ b/packages/localstorage-polyfill/localstorage_polyfill.js @@ -0,0 +1,51 @@ +if (!window.localStorage) { + window.localStorage = (function () { + // XXX eliminate dependency on jQuery, detect browsers ourselves + if ($.browser.msie) { // If we are on IE, which support userData + var userdata = document.createElement('span'); // could be anything + userdata.style.behavior = 'url("#default#userData")'; + userdata.id = 'localstorage-polyfill-helper'; + userdata.style.display = 'none'; + document.getElementsByTagName("head")[0].appendChild(userdata); + + var userdataKey = 'localStorage'; + userdata.load(userdataKey); + + return { + setItem: function (key, val) { + userdata.setAttribute(key, val); + userdata.save(userdataKey); + }, + + removeItem: function (key) { + userdata.removeAttribute(key); + userdata.save(userdataKey); + }, + + getItem: function (key) { + userdata.load(userdataKey); + return userdata.getAttribute(key); + } + }; + } else { + Meteor._debug( + "You are running a browser with no localStorage or userData " + + "support. Logging in from one tab will not cause another " + + "tab to be logged in."); + + return { + _data: {}, + + setItem: function (key, val) { + this._data[key] = val; + }, + removeItem: function (key) { + delete this._data[key]; + }, + getItem: function (key) { + return this._data[key]; + } + }; + }; + })(); +} diff --git a/packages/localstorage-polyfill/localstorage_polyfill_tests.js b/packages/localstorage-polyfill/localstorage_polyfill_tests.js new file mode 100644 index 0000000000..af603d54b9 --- /dev/null +++ b/packages/localstorage-polyfill/localstorage_polyfill_tests.js @@ -0,0 +1,9 @@ +Tinytest.add("localStorage polyfill", function (test) { + // Doesn't actually test preservation across reloads since that is hard. + // userData should do that for us so it's unlikely this wouldn't work. + localStorage.setItem("key", "value"); + test.equal(localStorage.getItem("key"), "value"); + localStorage.removeItem("key"); + test.equal(localStorage.getItem("key"), null); +}); + diff --git a/packages/localstorage-polyfill/package.js b/packages/localstorage-polyfill/package.js new file mode 100644 index 0000000000..f6e269b2e2 --- /dev/null +++ b/packages/localstorage-polyfill/package.js @@ -0,0 +1,16 @@ +Package.describe({ + summary: "Simulates the localStorage API on IE 6,7 using userData", +}); + +Package.on_use(function (api) { + api.use('jquery', 'client'); // XXX only used for browser detection. remove. + + api.add_files('localstorage_polyfill.js', 'client'); +}); + +Package.on_test(function (api) { + api.use('localstorage-polyfill', 'client'); + api.use('tinytest'); + + api.add_files('localstorage_polyfill_tests.js', 'client'); +}); diff --git a/packages/madewith/madewith.js b/packages/madewith/madewith.js index 92fc13be5f..4b2c48844e 100644 --- a/packages/madewith/madewith.js +++ b/packages/madewith/madewith.js @@ -9,7 +9,7 @@ var sub = server.subscribe("myApp", hostname); // minimongo collection to hold my singleton app record. - var apps = new Meteor.Collection('madewith_apps', server); + var apps = new Meteor.Collection('madewith_apps', {manager: server}); server.methods({ vote: function (hostname) { diff --git a/packages/meteor/dynamics_browser.js b/packages/meteor/dynamics_browser.js index 9c61ff4e0e..ee85a4d336 100644 --- a/packages/meteor/dynamics_browser.js +++ b/packages/meteor/dynamics_browser.js @@ -27,6 +27,9 @@ }); Meteor.bindEnvironment = function (func, onException, _this) { + // needed in order to be able to create closures inside func and + // have the closed variables not change back to their original + // values var boundValues = _.clone(currentValues); if (!onException) diff --git a/packages/meteor/timers.js b/packages/meteor/timers.js index a7353b1a77..bdaa636ef8 100644 --- a/packages/meteor/timers.js +++ b/packages/meteor/timers.js @@ -42,10 +42,15 @@ _.extend(Meteor, { }, // won't be necessary once we clobber the global setTimeout + // + // XXX consider making this guarantee ordering of defer'd callbacks, like + // Meteor._atFlush or Node's nextTick (in practice). Then tests can do: + // callSomethingThatDefersSomeWork(); + // Meteor.defer(expect(somethingThatValidatesThatTheWorkHappened)); defer: function (f) { // Older Firefox will pass an argument to the setTimeout callback // function, indicating the "actual lateness." It's non-standard, // so for defer, standardize on not having it. Meteor.setTimeout(function () {f();}, 0); } -}); \ No newline at end of file +}); diff --git a/packages/minimongo/diff.js b/packages/minimongo/diff.js index e1665bdb0c..afa5e47068 100644 --- a/packages/minimongo/diff.js +++ b/packages/minimongo/diff.js @@ -192,9 +192,8 @@ LocalCollection._diffQuery = function (old_results, new_results, observer, deepc Meteor._debug("Assertion failed while diffing: nonmonotonic lcs data"); // no move scan_to(old_doc_idx); - if (! _.isEqual(old_doc, new_doc)) { - observer.changed && observer.changed( - mdc(new_doc), new_idx + bump_list.length, old_doc); + if (observer.changed && ! _.isEqual(old_doc, new_doc)) { + observer.changed(mdc(new_doc), new_idx + bump_list.length, old_doc); } old_idx++; } else { @@ -222,8 +221,8 @@ LocalCollection._diffQuery = function (old_results, new_results, observer, deepc } if (from_idx != to_idx) observer.moved && observer.moved(mdc(old_doc), from_idx, to_idx); - if (! _.isEqual(old_doc, new_doc)) { - observer.changed && observer.changed(mdc(new_doc), to_idx, old_doc); + if (observer.changed && ! _.isEqual(old_doc, new_doc)) { + observer.changed(mdc(new_doc), to_idx, old_doc); } } } diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index bfa420f354..2faf241416 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -151,7 +151,7 @@ LocalCollection.LiveResultsSet = function () {}; // - added (object, before_index) // - changed (new_object, at_index, old_object) // - moved (object, old_index, new_index) - can only fire with changed() -// - removed (id, at_index, object) +// - removed (object, at_index) // // attributes available on returned query handle: // * stop(): end updates @@ -164,8 +164,6 @@ LocalCollection.LiveResultsSet = function () {}; // XXX maybe callbacks should take a list of objects, to expose transactions? // XXX maybe support field limiting (to limit what you're notified on) // XXX maybe support limit/skip -// XXX it'd be helpful if removed got the object that just left the -// query, not just its id LocalCollection.Cursor.prototype.observe = function (options) { var self = this; diff --git a/packages/mongo-livedata/allow_tests.js b/packages/mongo-livedata/allow_tests.js new file mode 100644 index 0000000000..7b55870eec --- /dev/null +++ b/packages/mongo-livedata/allow_tests.js @@ -0,0 +1,632 @@ +(function () { + + // + // Set up a bunch of test collections + // + + // helper for defining a collection, subscribing to it, and defining + // a method to clear it + var defineCollection = function(name, insecure) { + var collection = new Meteor.Collection(name); + collection._insecure = insecure; + + if (Meteor.isServer) { + Meteor.publish("collection-" + name, function() { + return collection.find(); + }); + + var m = {}; + m["clear-collection-" + name] = function(runId) { + collection.remove({world: runId}); + }; + Meteor.methods(m); + } else { + Meteor.subscribe("collection-" + name); + } + + collection.callClearMethod = function (runId, callback) { + Meteor.call("clear-collection-" + name, runId, callback); + }; + return collection; + }; + + // totally insecure collection + var insecureCollection = defineCollection( + "collection-insecure", true /*insecure*/); + + // totally locked down collection + var lockedDownCollection = defineCollection( + "collection-locked-down", false /*insecure*/); + + // resticted collection with same allowed modifications, both with and + // without the `insecure` package + var restrictedCollectionDefaultSecure = defineCollection( + "collection-restrictedDefaultSecure", false /*insecure*/); + var restrictedCollectionDefaultInsecure = defineCollection( + "collection-restrictedDefaultInsecure", true /*insecure*/); + var restrictedCollectionForUpdateOptionsTest = defineCollection( + "collection-restrictedForUpdateOptionsTest", true /*insecure*/); + var restrictedCollectionForPartialAllowTest = defineCollection( + "collection-restrictedForPartialAllowTest", true /*insecure*/); + var restrictedCollectionForPartialDenyTest = defineCollection( + "collection-restrictedForPartialDenyTest", true /*insecure*/); + var restrictedCollectionForFetchTest = defineCollection( + "collection-restrictedForFetchTest", true /*insecure*/); + var restrictedCollectionForFetchAllTest = defineCollection( + "collection-restrictedForFetchAllTest", true /*insecure*/); + + + // + // Set up allow/deny rules for test collections + // + + + // two calls to allow to verify that either validator is sufficient. + var allows = [{ + insert: function(userId, doc) { + return doc.canInsert; + }, + update: function(userId, docs) { + return _.all(docs, function (doc) { + return doc.canUpdate; + }); + }, + remove: function (userId, docs) { + return _.all(docs, function (doc) { + return doc.canRemove; + }); + } + }, { + insert: function(userId, doc) { + return doc.canInsert2; + }, + update: function(userId, docs, fields, modifier) { + return -1 !== _.indexOf(fields, 'canUpdate2'); + }, + remove: function(userId, docs) { + return _.all(docs, function (doc) { + return doc.canRemove2; + }); + } + }]; + + // two calls to deny to verify that either one blocks the change. + var denies = [{ + insert: function(userId, doc) { + return doc.cantInsert; + }, + remove: function (userId, docs) { + return _.any(docs, function (doc) { + return doc.cantRemove; + }); + } + }, { + insert: function(userId, doc) { + return doc.cantInsert2; + }, + update: function(userId, docs, fields, modifier) { + return -1 !== _.indexOf(fields, 'verySecret'); + } + }]; + + + + + if (Meteor.isServer) { + _.each([ + restrictedCollectionDefaultSecure, + restrictedCollectionDefaultInsecure, + restrictedCollectionForUpdateOptionsTest + ], function (collection) { + _.each(allows, function (allow) { + collection.allow(allow); + }); + _.each(denies, function (deny) { + collection.deny(deny); + }); + }); + + // just restrict one operation so that we can verify that others + // fail + restrictedCollectionForPartialAllowTest.allow({ + insert: function() {} + }); + restrictedCollectionForPartialDenyTest.deny({ + insert: function() {} + }); + + + // verify that we only fetch the fields specified - we should + // be fetching just field1, field2, and field3. + restrictedCollectionForFetchTest.allow({ + insert: function() { return true; }, + update: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + remove: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + fetch: ['field1'] + }); + restrictedCollectionForFetchTest.allow({ + fetch: ['field2'] + }); + restrictedCollectionForFetchTest.deny({ + fetch: ['field3'] + }); + + // verify that not passing fetch to one of the calls to allow + // causes all fields to be fetched + restrictedCollectionForFetchAllTest.allow({ + insert: function() { return true; }, + update: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + remove: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + fetch: ['field1'] + }); + restrictedCollectionForFetchAllTest.allow({ + update: function() { return true; } + }); + } + + + // + // Begin actual tests + // + + if (Meteor.isServer) { + Tinytest.add("collection - allow and deny validate options", function (test) { + var collection = new Meteor.Collection(null); + + test.throws(function () { + collection.allow({invalidOption: true}); + }); + test.throws(function () { + collection.deny({invalidOption: true}); + }); + + _.each(['insert', 'update', 'remove', 'fetch'], function (key) { + var options = {}; + options[key] = true; + test.throws(function () { + collection.allow(options); + }); + test.throws(function () { + collection.deny(options); + }); + }); + + _.each(['insert', 'update', 'remove'], function (key) { + var options = {}; + options[key] = ['an array']; // this should be a function, not an array + test.throws(function () { + collection.allow(options); + }); + test.throws(function () { + collection.deny(options); + }); + }); + + test.throws(function () { + collection.allow({fetch: function () {}}); // this should be an array + }); + }); + + Tinytest.add("collection - calling allow restricts", function (test) { + var collection = new Meteor.Collection(null); + test.equal(collection._restricted, false); + collection.allow({ + insert: function() {} + }); + test.equal(collection._restricted, true); + }); + + Tinytest.add("collection - global insecure", function (test) { + // note: This test alters the global insecure status! This may + // collide with itself if run multiple times (but is better than + // the old test which had the same problem) + var oldGlobalInsecure = Meteor.Collection.insecure; + + Meteor.Collection.insecure = true; + var collection = new Meteor.Collection(null); + test.equal(collection._isInsecure(), true); + + Meteor.Collection.insecure = false; + test.equal(collection._isInsecure(), false); + + collection._insecure = true; + test.equal(collection._isInsecure(), true); + + Meteor.Collection.insecure = oldGlobalInsecure; + }); + + } + + if (Meteor.isClient) { + // test that if allow is called once then the collection is + // restricted, and that other mutations aren't allowed + testAsyncMulti("collection - partial allow", [ + function (test, expect) { + restrictedCollectionForPartialAllowTest.update( + {world: test.runId()}, {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.error, 403); + })); + } + ]); + + // test that if deny is called once then the collection is + // restricted, and that other mutations aren't allowed + testAsyncMulti("collection - partial deny", [ + function (test, expect) { + restrictedCollectionForPartialDenyTest.update( + {world: test.runId()}, {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.error, 403); + })); + } + ]); + + + // test that we only fetch the fields specified + testAsyncMulti("collection - fetch", [ + function (test, expect) { + restrictedCollectionForFetchTest.insert( + {field1: 1, field2: 1, field3: 1, field4: 1, + world: test.runId()}); + restrictedCollectionForFetchAllTest.insert( + {field1: 1, field2: 1, field3: 1, field4: 1, + world: test.runId()}); + + }, function (test, expect) { + restrictedCollectionForFetchTest.update( + {world: test.runId()}, + {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,_id"); + })); + restrictedCollectionForFetchTest.remove( + {world: test.runId()}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,_id"); + })); + + restrictedCollectionForFetchAllTest.update( + {world: test.runId()}, + {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,field4,world,_id"); + })); + restrictedCollectionForFetchAllTest.remove( + {world: test.runId()}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,field4,world,_id"); + })); + + } + ]); + } + + var onQuiesce = function (expect, callback) { + var expectedCallback = expect(callback); + return expect(function (/*arguments*/) { + var args = _.toArray(arguments); + Meteor.default_connection.onQuiesce(function () { + expectedCallback.apply(null, args); + }); + }); + }; + + if (Meteor.isClient) { + testAsyncMulti("collection - insecure", [ + function (test, expect) { + insecureCollection.callClearMethod(test.runId(), onQuiesce(expect, function () { + test.equal(insecureCollection.find({world: test.runId()}).count(), 0); + })); + }, + function (test, expect) { + insecureCollection.insert({world: test.runId(), foo: 'bar'}, onQuiesce(expect, function(err, res) { + test.equal(insecureCollection.find({world: test.runId()}).count(), 1); + test.equal(insecureCollection.findOne({world: test.runId()}).foo, 'bar'); + })); + test.equal(insecureCollection.find({world: test.runId()}).count(), 1); + test.equal(insecureCollection.findOne({world: test.runId()}).foo, 'bar'); + } + ]); + + testAsyncMulti("collection - locked down", [ + function (test, expect) { + lockedDownCollection.callClearMethod(test.runId(), onQuiesce(expect, function() { + test.equal(lockedDownCollection.find({world: test.runId()}).count(), 0); + })); + }, + function (test, expect) { + lockedDownCollection.insert({world: test.runId(), foo: 'bar'}, onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + test.equal(lockedDownCollection.find({world: test.runId()}).count(), 0); + })); + } + ]); + + (function () { + var collection = restrictedCollectionForUpdateOptionsTest; + var id1; + testAsyncMulti("collection - update options", [ + // init + function (test, expect) { + collection.callClearMethod(test.runId(), onQuiesce(expect, function () { + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // put a few objects + function (test, expect) { + var doc = {canInsert: true, canUpdate: true, world: test.runId()}; + id1 = collection.insert(doc); + collection.insert(doc); + collection.insert(doc); + collection.insert(doc, onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 4); + })); + }, + // update by id + function (test, expect) { + collection.update( + id1, + {$set: {updated: true}}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 1); + })); + }, + // update without the `multi` option + function (test, expect) { + collection.update( + {updated: {$exists: false}, world: test.runId()}, + {$set: {updated: true}}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 2); + })); + }, + // update with the `multi` option + function (test, expect) { + collection.update( + {world: test.runId()}, + {$set: {updated: true}}, + {multi: true}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 4); + })); + } + ]); + }) (); + + _.each( + [restrictedCollectionDefaultInsecure, restrictedCollectionDefaultSecure], + function(collection) { + testAsyncMulti("collection - " + collection._name, [ + // init + function (test, expect) { + collection.callClearMethod(test.runId(), onQuiesce(expect, function () { + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + + // insert with no allows passing. request is denied. + function (test, expect) { + collection.insert( + {world: test.runId()}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // insert with one allow and one deny. denied. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert: true, cantInsert: true}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // insert with one allow and other deny. denied. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert: true, cantInsert2: true}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // insert one allow passes. allowed. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert: true}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 1); + })); + }, + // insert other allow passes. allowed. + // includes canUpdate for later. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert2: true, canUpdate: true}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 2); + })); + }, + // yet a third insert executes. this one has canRemove and + // cantRemove set for later. + function (test, expect) { + collection.insert( + {canInsert: true, canRemove: true, cantRemove: true, + world: test.runId()}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 3); + })); + }, + + // can't update to a new object + function (test, expect) { + collection.update( + {canUpdate:true, world: test.runId()}, + {newObject: 1}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({world:test.runId()}).count(), 3); + })); + }, + + // updating dotted fields works as if we are changing their + // top part + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {"dotted.field": 1}}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), canUpdate: true}).count(), 1); + test.equal(collection.findOne({world: test.runId(), canUpdate: true}).dotted.field, 1); + })); + }, + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {"verySecret.field": 1}}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({verySecret: {$exists: true}}).count(), 0); + })); + }, + + // update doesn't do anything if no docs match + function (test, expect) { + collection.update( + {world: test.runId(), doesntExist: true}, + {$set: {updated: true}}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update fails when access is denied trying to set `verySecret` + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {verySecret: true}}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update fails when trying to set two fields, one of which is + // `verySecret` + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {updated: true, verySecret: true}}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update fails when trying to modify docs that don't + // have `canUpdate` set + function (test, expect) { + collection.update( + {world: test.runId(), canRemove: true}, + {$set: {updated: true}}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update executes when it should + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {updated: true}}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 1); + })); + }, + + // remove fails when trying to modify an doc with no + // `canRemove` set + function (test, expect) { + collection.remove({world: test.runId(), canUpdate: true}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + })); + }, + // remove fails when trying to modify an doc with `cantRemove` + // set + function (test, expect) { + collection.remove({world: test.runId(), canRemove: true}, + onQuiesce(expect, function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + })); + }, + + // update the doc to remove cantRemove. + function (test, expect) { + collection.update( + {world: test.runId(), canRemove: true}, + {$set: {cantRemove: false, canUpdate2: true}}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), cantRemove: true}).count(), 0); + })); + }, + + // now remove can remove it. + function (test, expect) { + collection.remove({world: test.runId(), canRemove: true}, + onQuiesce(expect, function (err, res) { + test.isFalse(err); + // successfully removed + test.equal(collection.find({world: test.runId()}).count(), 2); + })); + }, + + // methods can still bypass restrictions + function (test, expect) { + collection.callClearMethod( + test.runId(), onQuiesce(expect, function (err, res) { + test.isFalse(err); + // successfully removed + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + } + ]); + }); + } +}) (); diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index b8bb29837f..4bb3ec4bc7 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -1,7 +1,19 @@ // manager, if given, is a LivedataClient or LivedataServer // XXX presently there is no way to destroy/clean up a Collection -Meteor.Collection = function (name, manager, driver) { +Meteor.Collection = function (name, options) { var self = this; + if (options && options.methods) { + // Backwards compatibility hack with original signature (which passed + // "manager" directly instead of in options. (Managers must have a "methods" + // method.) + // XXX remove before 1.0 + options = {manager: options}; + } + options = _.extend({ + manager: undefined, + _driver: undefined, + _preventAutopublish: false + }, options); if (!name && (name !== null)) { Meteor._debug("Warning: creating anonymous collection. It will not be " + @@ -10,28 +22,27 @@ Meteor.Collection = function (name, manager, driver) { } // note: nameless collections never have a manager - manager = name && (manager || - (Meteor.isClient ? - Meteor.default_connection : Meteor.default_server)); + self._manager = name && (options.manager || + (Meteor.isClient ? + Meteor.default_connection : Meteor.default_server)); - if (!driver) { - if (name && manager === Meteor.default_server && + if (!options._driver) { + if (name && self._manager === Meteor.default_server && Meteor._RemoteCollectionDriver) - driver = Meteor._RemoteCollectionDriver; + options._driver = Meteor._RemoteCollectionDriver; else - driver = Meteor._LocalCollectionDriver; + options._driver = Meteor._LocalCollectionDriver; } - self._manager = manager; - self._driver = driver; - self._collection = driver.open(name); + self._collection = options._driver.open(name); self._was_snapshot = false; + self._name = name; - if (name && manager.registerStore) { + if (name && self._manager.registerStore) { // OK, we're going to be a slave, replicating some remote // database, except possibly with some temporary divergence while // we have unacknowledged RPC's. - var ok = manager.registerStore(name, { + var ok = self._manager.registerStore(name, { // Called at the beginning of a batch of updates. We're supposed to start // by backing out any local writes and returning to the last state // delivered by the server. batchSize is the number of update calls to @@ -90,40 +101,22 @@ Meteor.Collection = function (name, manager, driver) { throw new Error("There is already a collection named '" + name + "'"); } - // mutation methods - if (manager) { - var m = {}; - // XXX what if name has illegal characters in it? - self._prefix = '/' + name + '/'; - m[self._prefix + 'insert'] = function (/* selector, options */) { - self._maybe_snapshot(); - // insert returns nothing. allow exceptions to propagate. - self._collection.insert.apply(self._collection, _.toArray(arguments)); - }; - - m[self._prefix + 'update'] = function (/* selector, mutator, options */) { - self._maybe_snapshot(); - // update returns nothing. allow exceptions to propagate. - self._collection.update.apply(self._collection, _.toArray(arguments)); - }; - - m[self._prefix + 'remove'] = function (/* selector */) { - self._maybe_snapshot(); - // remove returns nothing. allow exceptions to propagate. - self._collection.remove.apply(self._collection, _.toArray(arguments)); - }; - - manager.methods(m); - } + self._defineMutationMethods(); // autopublish - if (manager && manager.onAutopublish) - manager.onAutopublish(function () { + if (!options._preventAutopublish && + self._manager && self._manager.onAutopublish) + self._manager.onAutopublish(function () { var handler = function () { return self.find(); }; - manager.publish(null, handler, {is_auto: true}); + self._manager.publish(null, handler, {is_auto: true}); }); }; +/// +/// Main collection API +/// + + _.extend(Meteor.Collection.prototype, { find: function (/* selector, options */) { // Collection.find() (return all docs) behaves differently @@ -148,6 +141,7 @@ _.extend(Meteor.Collection.prototype, { }); + // 'insert' immediately returns the inserted document's new _id. The // others return nothing. // @@ -182,7 +176,7 @@ _.each(["insert", "update", "remove"], function (name) { if (args.length && args[args.length - 1] instanceof Function) callback = args.pop(); - if (Meteor.isClient && !callback) + if (Meteor.isClient && !callback) { // Client can't block, so it can't report errors by exception, // only by callback. If they forget the callback, give them a // default one that logs the error, so they aren't totally @@ -190,8 +184,9 @@ _.each(["insert", "update", "remove"], function (name) { // down. callback = function (err) { if (err) - Meteor._debug(name + " failed: " + err.error + " -- " + err.reason); + Meteor._debug(name + " failed: " + (err.reason || err.stack)); }; + } if (name === "insert") { if (!args.length) @@ -206,16 +201,17 @@ _.each(["insert", "update", "remove"], function (name) { if (self._manager && self._manager !== Meteor.default_server) { // just remote to another endpoint, propagate return value or // exception. - if (callback) + if (callback) { // asynchronous: on success, callback should return ret // (document ID for insert, undefined for update and // remove), not the method's result. self._manager.apply(self._prefix + name, args, function (error, result) { callback(error, !error && ret); }); - else + } else { // synchronous: propagate exception self._manager.apply(self._prefix + name, args); + } } else { // it's my collection. descend into the collection object @@ -239,3 +235,318 @@ _.each(["insert", "update", "remove"], function (name) { return ret; }; }); + +// We'll actually design an index API later. For now, we just pass through to +// Mongo's, but make it synchronous. +Meteor.Collection.prototype._ensureIndex = function (index, options) { + var self = this; + if (!self._collection._ensureIndex) + throw new Error("Can only call _ensureIndex on server collections"); + self._collection._ensureIndex(index, options); +}; + +/// +/// Remote methods and access control. +/// + +// Restrict default mutators on collection. allow() and deny() take the +// same options: +// +// options.insert {Function(userId, doc)} +// return true to allow/deny adding this document +// +// options.update {Function(userId, docs, fields, modifier)} +// return true to allow/deny updating these documents. +// `fields` is passed as an array of fields that are to be modified +// +// options.remove {Function(userId, docs)} +// return true to allow/deny removing these documents +// +// options.fetch {Array} +// Fields to fetch for these validators. If any call to allow or deny +// does not have this option then all fields are loaded. +// +// allow and deny can be called multiple times. The validators are +// evaluated as follows: +// - If neither deny() nor allow() has been called on the collection, +// then the request is allowed if and only if the "insecure" smart +// package is in use. +// - Otherwise, if any deny() function returns true, the request is denied. +// - Otherwise, if any allow() function returns true, the request is allowed. +// - Otherwise, the request is denied. +// +// Meteor may call your deny() and allow() functions in any order, and may not +// call all of them if it is able to make a decision without calling them all +// (so don't include side effects). + +(function () { + var addValidator = function(allowOrDeny, options) { + // validate keys + var VALID_KEYS = ['insert', 'update', 'remove', 'fetch']; + _.each(_.keys(options), function (key) { + if (!_.contains(VALID_KEYS, key)) + throw new Error(allowOrDeny + ": Invalid key: " + key); + }); + + var self = this; + self._restricted = true; + + _.each(['insert', 'update', 'remove'], function (name) { + if (options[name]) { + if (!(options[name] instanceof Function)) { + throw new Error(allowOrDeny + ": Value for `" + name + "` must be a function"); + } + self._validators[name][allowOrDeny].push(options[name]); + } + }); + + // Only update the fetch fields if we're passed things that affect + // fetching. This way allow({}) and allow({insert: f}) don't result in + // setting fetchAllFields + if (options.update || options.remove || options.fetch) { + if (options.fetch && !(options.fetch instanceof Array)) { + throw new Error(allowOrDeny + ": Value for `fetch` must be an array"); + } + self._updateFetch(options.fetch); + } + }; + + Meteor.Collection.prototype.allow = function(options) { + addValidator.call(this, 'allow', options); + }; + Meteor.Collection.prototype.deny = function(options) { + addValidator.call(this, 'deny', options); + }; +})(); + +Meteor.Collection.prototype._defineMutationMethods = function() { + var self = this; + + // set to true once we call any allow or deny methods. If true, use + // allow/deny semantics. If false, use insecure mode semantics. + self._restricted = false; + + // Insecure mode (default to allowing writes). Defaults to 'undefined' + // which means use the global Meteor.Collection.insecure. This + // property can be overriden by tests or packages wishing to change + // insecure mode behavior of their collections. + self._insecure = undefined; + + self._validators = { + insert: {allow: [], deny: []}, + update: {allow: [], deny: []}, + remove: {allow: [], deny: []}, + fetch: [], + fetchAllFields: false + }; + + if (!self._name) + return; // anonymous collection + + // XXX Think about method namespacing. Maybe methods should be + // "Meteor:Mongo:insert/NAME"? + self._prefix = '/' + self._name + '/'; + + // mutation methods + if (self._manager) { + var m = {}; + + _.each(['insert', 'update', 'remove'], function (method) { + m[self._prefix + method] = function (/* ... */) { + self._maybe_snapshot(); + + if (this.isSimulation || (!self._restricted && self._isInsecure())) { + self._collection[method].apply( + self._collection, _.toArray(arguments)); + } else if (self._restricted) { + // short circuit if there is no way it will pass. + if (self._validators[method].allow.length === 0) { + throw new Meteor.Error( + 403, "Access denied. No allow validators set on restricted " + + "collection."); + } + + var validatedMethodName = + '_validated' + method.charAt(0).toUpperCase() + method.slice(1); + var argsWithUserId = [this.userId].concat(_.toArray(arguments)); + self[validatedMethodName].apply(self, argsWithUserId); + } else { + throw new Meteor.Error(403, "Access denied"); + } + }; + }); + + self._manager.methods(m); + } +}; + + +Meteor.Collection.prototype._updateFetch = function (fields) { + var self = this; + + if (!self._validators.fetchAllFields) { + if (fields) { + self._validators.fetch = _.union(self._validators.fetch, fields); + } else { + self._validators.fetchAllFields = true; + // clear fetch just to make sure we don't accidentally read it + self._validators.fetch = null; + } + } +}; + +Meteor.Collection.prototype._isInsecure = function () { + var self = this; + if (self._insecure === undefined) + return Meteor.Collection.insecure; + return self._insecure; +}; + +Meteor.Collection.prototype._validatedInsert = function(userId, doc) { + var self = this; + + // call user validators. + // Any deny returns true means denied. + if (_.any(self._validators.insert.deny, function(validator) { + return validator(userId, doc); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (_.all(self._validators.insert.allow, function(validator) { + return !validator(userId, doc); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + self._collection.insert.call(self._collection, doc); +}; + +// Simulate a mongo `update` operation while validating that the access +// control rules set by calls to `allow/deny` are satisfied. If all +// pass, rewrite the mongo operation to use $in to set the list of +// document ids to change ##ValidatedChange +Meteor.Collection.prototype._validatedUpdate = function( + userId, selector, mutator, options) { + var self = this; + + // compute modified fields + var fields = []; + _.each(mutator, function (params, op) { + if (op[0] !== '$') { + throw new Meteor.Error( + 403, "Access denied. Can't replace document in restricted collection."); + } else { + _.each(_.keys(params), function (field) { + // treat dotted fields as if they are replacing their + // top-level part + if (field.indexOf('.') !== -1) + field = field.substring(0, field.indexOf('.')); + + // record the field we are trying to change + if (!_.contains(fields, field)) + fields.push(field); + }); + } + }); + + var findOptions = {}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + _.each(self._validators.fetch, function(fieldName) { + findOptions.fields[fieldName] = 1; + }); + } + + var docs; + if (options && options.multi) { + docs = self._collection.find(selector, findOptions).fetch(); + if (docs.length === 0) // none satisfied! + return; + } else { + var doc = self._collection.findOne(selector, findOptions); + if (!doc) // none satisfied! + return; + docs = [doc]; + } + + // call user validators. + // Any deny returns true means denied. + if (_.any(self._validators.update.deny, function(validator) { + return validator(userId, docs, fields, mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (_.all(self._validators.update.allow, function(validator) { + return !validator(userId, docs, fields, mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // Construct new $in selector to augment the original one. This means we'll + // never update any doc we didn't validate. We keep around the original + // selector so that we don't mutate any docs that have been updated to no + // longer match the original selector. + var idInClause = {}; + idInClause.$in = _.map(docs, function(doc) { + return doc._id; + }); + var idSelector = {_id: idInClause}; + + var fullSelector; + if (LocalCollection._selectorIsId(selector)) { + // If the original selector was just a lookup by _id, no need to "and" it + // with the idSelector (and it won't work anyway without explicitly + // comparing with _id). + if (docs.length !== 1 || docs[0]._id !== selector) + throw new Error("Lookup by ID " + selector + " found something else"); + fullSelector = selector; + } else { + fullSelector = {$and: [selector, idSelector]}; + } + + self._collection.update.call( + self._collection, fullSelector, mutator, options); +}; + +// Simulate a mongo `remove` operation while validating access control +// rules. See #ValidatedChange +Meteor.Collection.prototype._validatedRemove = function(userId, selector) { + var self = this; + + var findOptions = {}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + _.each(self._validators.fetch, function(fieldName) { + findOptions.fields[fieldName] = 1; + }); + } + + var docs = self._collection.find(selector, findOptions).fetch(); + if (docs.length === 0) // none satisfied! + return; + + // call user validators. + // Any deny returns true means denied. + if (_.any(self._validators.remove.deny, function(validator) { + return validator(userId, docs); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (_.all(self._validators.remove.allow, function(validator) { + return !validator(userId, docs); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // construct new $in selector to replace the original one + var idInClause = {}; + idInClause.$in = _.map(docs, function(doc) { + return doc._id; + }); + var idSelector = {_id: idInClause}; + + self._collection.remove.call(self._collection, idSelector); +}; diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 9f0da038f6..8a82cb8fc5 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -105,13 +105,6 @@ _Mongo.prototype.insert = function (collection_name, document) { var write = self._maybeBeginWrite(); - var finish = Meteor.bindEnvironment(function () { - Meteor.refresh({collection: collection_name}); - write.committed(); - }, function (e) { - Meteor._debug("Exception while completing insert: " + e.stack); - }); - var future = new Future; self._withCollection(collection_name, function (err, collection) { if (err) { @@ -120,17 +113,13 @@ _Mongo.prototype.insert = function (collection_name, document) { } collection.insert(document, {safe: true}, function (err) { - if (err) { - future.ret(err); - return; - } - - finish(); - future.ret(); + future.ret(err); }); }); var err = future.wait(); + Meteor.refresh({collection: collection_name}); + write.committed(); if (err) throw err; }; @@ -147,13 +136,6 @@ _Mongo.prototype.remove = function (collection_name, selector) { var write = self._maybeBeginWrite(); - var finish = Meteor.bindEnvironment(function () { - Meteor.refresh({collection: collection_name}); - write.committed(); - }, function (e) { - Meteor._debug("Exception while completing remove: " + e.stack); - }); - // XXX does not allow options. matches the client. selector = _Mongo._rewriteSelector(selector); @@ -165,17 +147,13 @@ _Mongo.prototype.remove = function (collection_name, selector) { } collection.remove(selector, {safe: true}, function (err) { - if (err) { - future.ret(err); - return; - } - - finish(); - future.ret(); + future.ret(err); }); }); var err = future.wait(); + Meteor.refresh({collection: collection_name}); + write.committed(); if (err) throw err; }; @@ -192,13 +170,6 @@ _Mongo.prototype.update = function (collection_name, selector, mod, options) { var write = self._maybeBeginWrite(); - var finish = Meteor.bindEnvironment(function () { - Meteor.refresh({collection: collection_name}); - write.committed(); - }, function (e) { - Meteor._debug("Exception while completing update: " + e.stack); - }); - selector = _Mongo._rewriteSelector(selector); if (!options) options = {}; @@ -215,17 +186,13 @@ _Mongo.prototype.update = function (collection_name, selector, mod, options) { if (options.multi) opts.multi = true; collection.update(selector, mod, opts, function (err) { - if (err) { - future.ret(err); - return; - } - - finish(); - future.ret(); + future.ret(err); }); }); var err = future.wait(); + Meteor.refresh({collection: collection_name}); + write.committed(); if (err) throw err; }; @@ -248,6 +215,32 @@ _Mongo.prototype.findOne = function (collection_name, selector, options) { return self.find(collection_name, selector, options).fetch()[0]; }; +// We'll actually design an index API later. For now, we just pass through to +// Mongo's, but make it synchronous. +_Mongo.prototype._ensureIndex = function (collectionName, index, options) { + var self = this; + options = _.extend({safe: true}, options); + + // We expect this function to be called at startup, not from within a method, + // so we don't interact with the write fence. + var future = new Future; + self._withCollection(collectionName, function (err, collection) { + if (err) { + future.throw(err); + return; + } + // XXX do we have to bindEnv or Fiber.run this callback? + collection.ensureIndex(index, options, function (err, indexName) { + if (err) { + future.throw(err); + return; + } + future.ret(); + }); + }); + future.wait(); +}; + // Cursors // Returns a _Mongo.Cursor, or throws an exception on @@ -290,28 +283,34 @@ _Mongo.Cursor = function (mongo, collection_name, selector, options, cursor) { self.selector = selector; self.options = options; self.cursor = cursor; + self._synchronousNextObject = Future.wrap(cursor.nextObject.bind(cursor)); + self._synchronousCount = Future.wrap(cursor.count.bind(cursor)); self.visited_ids = {}; }; +_Mongo.Cursor.prototype._nextObject = function () { + var self = this; + while (true) { + var doc = self._synchronousNextObject().wait(); + if (!doc || !doc._id) return null; + if (self.visited_ids[doc._id]) continue; + self.visited_ids[doc._id] = true; + return doc; + } +}; + // XXX Make more like ECMA forEach: // https://github.com/meteor/meteor/pull/63#issuecomment-5320050 _Mongo.Cursor.prototype.forEach = function (callback) { var self = this; - var wrappedNextObject = Future.wrap(self.cursor.nextObject.bind(self.cursor)); - // We implement the loop ourself instead of using self.cursor.each, because // "each" will call its callback outside of a fiber which makes it much more // complex to make this function synchronous. while (true) { - var doc = wrappedNextObject().wait(); - if (!doc || !doc._id) - return; - // Have we already seen this doc (Mongo cursors can return duplicates)? - if (self.visited_ids[doc._id]) - continue; - self.visited_ids[doc._id] = true; + var doc = self._nextObject(); + if (!doc) return; callback(doc); } }; @@ -339,32 +338,12 @@ _Mongo.Cursor.prototype.rewind = function () { _Mongo.Cursor.prototype.fetch = function () { var self = this; - var future = new Future; - - self.cursor.toArray(function (err, res) { - future.ret([err, res]); - }); - - var result = future.wait(); - if (result[0]) - throw result[0]; - // dedup - return _.uniq(result[1], false, function(doc) { - return doc._id; }); + return self.map(_.identity); }; _Mongo.Cursor.prototype.count = function () { var self = this; - var future = new Future; - - self.cursor.count(function (err, res) { - future.ret([err, res]); - }); - - var result = future.wait(); - if (result[0]) - throw result[0]; - return result[1]; + return self._synchronousCount().wait(); }; // options to contain: diff --git a/packages/mongo-livedata/package.js b/packages/mongo-livedata/package.js index 89a3813aad..98ff164b35 100644 --- a/packages/mongo-livedata/package.js +++ b/packages/mongo-livedata/package.js @@ -27,4 +27,5 @@ Package.on_test(function (api) { api.use('tinytest'); api.use('test-helpers'); api.add_files('mongo_livedata_tests.js', ['client', 'server']); + api.add_files('allow_tests.js', ['client', 'server']); }); \ No newline at end of file diff --git a/packages/mongo-livedata/remote_collection_driver.js b/packages/mongo-livedata/remote_collection_driver.js index a827fe38ad..50e372309f 100644 --- a/packages/mongo-livedata/remote_collection_driver.js +++ b/packages/mongo-livedata/remote_collection_driver.js @@ -8,9 +8,11 @@ _.extend(Meteor._RemoteCollectionDriver.prototype, { open: function (name) { var self = this; var ret = {}; - _.each(['find', 'findOne', 'insert', 'update', 'remove'], function (m) { - ret[m] = _.bind(self.mongo[m], self.mongo, name); - }); + _.each( + ['find', 'findOne', 'insert', 'update', 'remove', '_ensureIndex'], + function (m) { + ret[m] = _.bind(self.mongo[m], self.mongo, name); + }); return ret; } }); diff --git a/packages/preserve-inputs/package.js b/packages/preserve-inputs/package.js index 3a9d4f0a9d..f8eca26f9b 100644 --- a/packages/preserve-inputs/package.js +++ b/packages/preserve-inputs/package.js @@ -1,5 +1,5 @@ Package.describe({ - summary: "Automatically preserve all form fields that have a unique id" + summary: "Automatically preserve all form fields with a unique id" }); Package.on_use(function (api, where) { diff --git a/packages/session/package.js b/packages/session/package.js index f0312ee2e6..5d5a937a13 100644 --- a/packages/session/package.js +++ b/packages/session/package.js @@ -20,3 +20,9 @@ Package.on_use(function (api, where) { api.add_files('session.js', where); }); + +Package.on_test(function (api) { + api.use('tinytest'); + api.use('session', 'client'); + api.add_files('session_tests.js', 'client'); +}); diff --git a/packages/session/session.js b/packages/session/session.js index df76d163eb..e61c06170f 100644 --- a/packages/session/session.js +++ b/packages/session/session.js @@ -1,125 +1,117 @@ -// XXX could use some tests +(function () { -Session = _.extend({}, { - keys: {}, - key_deps: {}, // key -> context id -> context - key_value_deps: {}, // key -> value -> context id -> context + // XXX come up with a serialization method which canonicalizes object key + // order, which would allow us to use objects as values for equals. + var stringify = function (value) { + if (value === undefined) + return 'undefined'; + return JSON.stringify(value); + }; + var parse = function (serialized) { + if (serialized === undefined || serialized === 'undefined') + return undefined; + return JSON.parse(serialized); + }; - // XXX remove debugging method (or improve it, but anyway, don't - // ship it in production) - dump_state: function () { - var self = this; - console.log("=== Session state ==="); - for (var key in self.key_deps) { - var ids = _.keys(self.key_deps[key]); - if (!ids.length) - continue; - console.log(key + ": " + _.reject(ids, function (x) {return x === "_once"}).join(' ')); - } + Session = _.extend({}, { + keys: {}, // key -> value + keyDeps: {}, // key -> _ContextSet + keyValueDeps: {}, // key -> value -> _ContextSet - for (var key in self.key_value_deps) { - for (var value in self.key_value_deps[key]) { - var ids = _.keys(self.key_value_deps[key][value]); - if (!ids.length) - continue; - console.log(key + "(" + value + "): " + _.reject(ids, function (x) {return x === "_once";}).join(' ')); + set: function (key, value) { + var self = this; + + value = stringify(value); + + var oldSerializedValue = 'undefined'; + if (_.has(self.keys, key)) oldSerializedValue = self.keys[key]; + if (value === oldSerializedValue) + return; + self.keys[key] = value; + + var invalidateAll = function (cset) { + cset && cset.invalidateAll(); + }; + + invalidateAll(self.keyDeps[key]); + if (self.keyValueDeps[key]) { + invalidateAll(self.keyValueDeps[key][oldSerializedValue]); + invalidateAll(self.keyValueDeps[key][value]); } - } - }, + }, - set: function (key, value) { - var self = this; - - if (typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' && - value !== null && value !== undefined) - throw new Error("Session.set: value can't be an object"); - - var old_value = self.keys[key]; - if (value === old_value) - return; - self.keys[key] = value; - - var invalidate = function (map) { - if (map) - for (var id in map) - map[id].invalidate(); - }; - - self._ensureKey(key); - invalidate(self.key_deps[key]); - invalidate(self.key_value_deps[key][old_value]); - invalidate(self.key_value_deps[key][value]); - }, - - get: function (key) { - var self = this; - var context = Meteor.deps.Context.current; - self._ensureKey(key); - - if (context && !(context.id in self.key_deps[key])) { - self.key_deps[key][context.id] = context; - context.onInvalidate(function () { - delete self.key_deps[key][context.id]; - }); - } - - return self.keys[key]; - }, - - equals: function (key, value) { - var self = this; - var context = Meteor.deps.Context.current; - - if (typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' && - value !== null && value !== undefined) - throw new Error("Session.equals: value can't be an object"); - - if (context) { + get: function (key) { + var self = this; self._ensureKey(key); - if (!(value in self.key_value_deps[key])) - self.key_value_deps[key][value] = {}; + self.keyDeps[key].addCurrentContext(); + return parse(self.keys[key]); + }, - if (!(context.id in self.key_value_deps[key][value])) { - self.key_value_deps[key][value][context.id] = context; - context.onInvalidate(function () { - delete self.key_value_deps[key][value][context.id]; + equals: function (key, value) { + var self = this; + var context = Meteor.deps.Context.current; - // clean up [key][value] if it's now empty, so we don't use - // O(n) memory for n = values seen ever - for (var x in self.key_value_deps[key][value]) - return; - delete self.key_value_deps[key][value]; - }); + // We don't allow objects (or arrays that might include objects) for + // .equals, because JSON.stringify doesn't canonicalize object key + // order. (We can make equals have the right return value by parsing the + // current value and using _.isEqual, but we won't have a canonical + // element of keyValueDeps[key] to store the context.) You can still use + // "_.isEqual(Session.get(key), value)". + // + // XXX we could allow arrays as long as we recursively check that there + // are no objects + if (typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + typeof value !== 'undefined' && + value !== null) + throw new Error("Session.equals: value must be scalar"); + var serializedValue = stringify(value); + + if (context) { + self._ensureKey(key); + + if (! _.has(self.keyValueDeps[key], serializedValue)) + self.keyValueDeps[key][serializedValue] = new Meteor.deps._ContextSet; + + var isNew = self.keyValueDeps[key][serializedValue].add(context); + if (isNew) { + context.onInvalidate(function () { + // clean up [key][serializedValue] if it's now empty, so we don't + // use O(n) memory for n = values seen ever + if (self.keyValueDeps[key][serializedValue].isEmpty()) + delete self.keyValueDeps[key][serializedValue]; + }); + } + } + + var oldValue = undefined; + if (_.has(self.keys, key)) oldValue = parse(self.keys[key]); + return oldValue === value; + }, + + _ensureKey: function (key) { + var self = this; + if (!(key in self.keyDeps)) { + self.keyDeps[key] = new Meteor.deps._ContextSet; + self.keyValueDeps[key] = {}; } } - - return self.keys[key] === value; - }, - - _ensureKey: function (key) { - var self = this; - if (!(key in self.key_deps)) { - self.key_deps[key] = {}; - self.key_value_deps[key] = {}; - } - } -}); - - -if (Meteor._reload) { - Meteor._reload.onMigrate('session', function () { - // XXX sanitize and make sure it's JSONible? - return [true, {keys: Session.keys}]; }); - (function () { - var migrationData = Meteor._reload.migrationData('session'); - if (migrationData && migrationData.keys) { - Session.keys = migrationData.keys; - } - })(); -} + + if (Meteor._reload) { + Meteor._reload.onMigrate('session', function () { + // XXX sanitize and make sure it's JSONible? + return [true, {keys: Session.keys}]; + }); + + (function () { + var migrationData = Meteor._reload.migrationData('session'); + if (migrationData && migrationData.keys) { + Session.keys = migrationData.keys; + } + })(); + } + +}()); diff --git a/packages/session/session_tests.js b/packages/session/session_tests.js new file mode 100644 index 0000000000..d148914a1f --- /dev/null +++ b/packages/session/session_tests.js @@ -0,0 +1,158 @@ +(function () { + + Tinytest.add('session - get/set/equals types', function (test) { + test.equal(Session.get('u'), undefined); + test.isTrue(Session.equals('u', undefined)); + test.isFalse(Session.equals('u', null)); + test.isFalse(Session.equals('u', 0)); + test.isFalse(Session.equals('u', '')); + + Session.set('u', undefined); + test.equal(Session.get('u'), undefined); + test.isTrue(Session.equals('u', undefined)); + test.isFalse(Session.equals('u', null)); + test.isFalse(Session.equals('u', 0)); + test.isFalse(Session.equals('u', '')); + test.isFalse(Session.equals('u', 'undefined')); + test.isFalse(Session.equals('u', 'null')); + + Session.set('n', null); + test.equal(Session.get('n'), null); + test.isFalse(Session.equals('n', undefined)); + test.isTrue(Session.equals('n', null)); + test.isFalse(Session.equals('n', 0)); + test.isFalse(Session.equals('n', '')); + test.isFalse(Session.equals('n', 'undefined')); + test.isFalse(Session.equals('n', 'null')); + + Session.set('t', true); + test.equal(Session.get('t'), true); + test.isTrue(Session.equals('t', true)); + test.isFalse(Session.equals('t', false)); + test.isFalse(Session.equals('t', 1)); + test.isFalse(Session.equals('t', 'true')); + + Session.set('f', false); + test.equal(Session.get('f'), false); + test.isFalse(Session.equals('f', true)); + test.isTrue(Session.equals('f', false)); + test.isFalse(Session.equals('f', 1)); + test.isFalse(Session.equals('f', 'false')); + + Session.set('num', 0); + test.equal(Session.get('num'), 0); + test.isTrue(Session.equals('num', 0)); + test.isFalse(Session.equals('num', false)); + test.isFalse(Session.equals('num', '0')); + test.isFalse(Session.equals('num', 1)); + + Session.set('str', 'true'); + test.equal(Session.get('str'), 'true'); + test.isTrue(Session.equals('str', 'true')); + test.isFalse(Session.equals('str', true)); + + Session.set('arr', [1, 2, {a: 1, b: [5, 6]}]); + test.equal(Session.get('arr'), [1, 2, {b: [5, 6], a: 1}]); + test.isFalse(Session.equals('arr', 1)); + test.isFalse(Session.equals('arr', '[1,2,{"a":1,"b":[5,6]}]')); + test.throws(function () { + Session.equals('arr', [1, 2, {a: 1, b: [5, 6]}]); + }); + + Session.set('obj', {a: 1, b: [5, 6]}); + test.equal(Session.get('obj'), {b: [5, 6], a: 1}); + test.isFalse(Session.equals('obj', 1)); + test.isFalse(Session.equals('obj', '{"a":1,"b":[5,6]}')); + test.throws(function() { Session.equals('obj', {a: 1, b: [5, 6]}); }); + }); + + Tinytest.add('session - objects are cloned', function (test) { + Session.set('frozen-array', [1, 2, 3]); + Session.get('frozen-array')[1] = 42; + test.equal(Session.get('frozen-array'), [1, 2, 3]); + + Session.set('frozen-object', {a: 1, b: 2}); + Session.get('frozen-object').a = 43; + test.equal(Session.get('frozen-object'), {a: 1, b: 2}); + }); + + Tinytest.add('session - context invalidation for get', function (test) { + var xGetExecutions = 0; + Meteor.autorun(function () { + ++xGetExecutions; + Session.get('x'); + }); + test.equal(xGetExecutions, 1); + Session.set('x', 1); + // Invalidation shouldn't happen until flush time. + test.equal(xGetExecutions, 1); + Meteor.flush(); + test.equal(xGetExecutions, 2); + // Setting to the same value doesn't re-run. + Session.set('x', 1); + Meteor.flush(); + test.equal(xGetExecutions, 2); + Session.set('x', '1'); + Meteor.flush(); + test.equal(xGetExecutions, 3); + }); + + Tinytest.add('session - context invalidation for equals', function (test) { + var xEqualsExecutions = 0; + Meteor.autorun(function () { + ++xEqualsExecutions; + Session.equals('x', 5); + }); + test.equal(xEqualsExecutions, 1); + Session.set('x', 1); + Meteor.flush(); + // Changing undefined -> 1 shouldn't affect equals(5). + test.equal(xEqualsExecutions, 1); + Session.set('x', 5); + // Invalidation shouldn't happen until flush time. + test.equal(xEqualsExecutions, 1); + Meteor.flush(); + test.equal(xEqualsExecutions, 2); + Session.set('x', 5); + Meteor.flush(); + // Setting to the same value doesn't re-run. + test.equal(xEqualsExecutions, 2); + Session.set('x', '5'); + test.equal(xEqualsExecutions, 2); + Meteor.flush(); + test.equal(xEqualsExecutions, 3); + Session.set('x', 5); + test.equal(xEqualsExecutions, 3); + Meteor.flush(); + test.equal(xEqualsExecutions, 4); + }); + + Tinytest.add( + 'session - context invalidation for equals with undefined', + function (test) { + // Make sure the special casing for equals undefined works. + var yEqualsExecutions = 0; + Meteor.autorun(function () { + ++yEqualsExecutions; + Session.equals('y', undefined); + }); + test.equal(yEqualsExecutions, 1); + Session.set('y', undefined); + Meteor.flush(); + test.equal(yEqualsExecutions, 1); + Session.set('y', 5); + test.equal(yEqualsExecutions, 1); + Meteor.flush(); + test.equal(yEqualsExecutions, 2); + Session.set('y', 3); + Meteor.flush(); + test.equal(yEqualsExecutions, 2); + Session.set('y', 'undefined'); + Meteor.flush(); + test.equal(yEqualsExecutions, 2); + Session.set('y', undefined); + test.equal(yEqualsExecutions, 2); + Meteor.flush(); + test.equal(yEqualsExecutions, 3); + }); +}()); diff --git a/packages/spark/spark.js b/packages/spark/spark.js index 6706254e86..861bf417c1 100644 --- a/packages/spark/spark.js +++ b/packages/spark/spark.js @@ -22,7 +22,7 @@ // XXX in landmark-demo, if Template.timer.created throws an exception, // then it is never called again, even if you push the 'create a -// timer' button again. the problem is almost certainly in atFlushTime +// timer' button again. the problem is almost certainly in atFlush // (not hard to see what it is.) (function() { @@ -144,9 +144,19 @@ Spark._Renderer = function () { _.extend(Spark._Renderer.prototype, { // `what` can be a function that takes a LiveRange, or just a set of - // attributes to add to the liverange. tag and what are optional. - // if no tag is passed, no liverange will be created. - annotate: function (html, type, what, unusedFunc) { + // attributes to add to the liverange. type and what are optional. + // if no type is passed, no liverange will be created. + // If what is a function, it will be called no matter what, even + // if the annotated HTML was not used and no LiveRange was created, + // in which case it gets null as an argument. + annotate: function (html, type, what) { + if (typeof what !== 'function') { + var attribs = what; + what = function (range) { + if (range) + _.extend(range, attribs); + }; + } // The annotation tags that we insert into HTML strings must be // unguessable in order to not create potential cross-site scripting // attack vectors, so we use random strings. Even a well-written app @@ -156,20 +166,15 @@ _.extend(Spark._Renderer.prototype, { // and not arbitrary user-entered data. var id = (type || '') + ":" + Spark._createId(); this.annotations[id] = function (start, end) { - if (! start) { - // materialize called us with no args because this annotation - // wasn't used - unusedFunc && unusedFunc(); + if ((! start) || (! type)) { + // ! start: materialize called us with no args because this + // annotation wasn't used + // ! type: no type given, don't generate a LiveRange + what(null); return; } - if (! type) - // no type given; don't generate a LiveRange - return; var range = makeRange(type, start, end); - if (what instanceof Function) - what(range); - else - _.extend(range, what); + what(range); }; return "<$" + id + ">" + html + ""; @@ -340,8 +345,7 @@ var scheduleOnscreenSetup = function (frag, landmarkRanges) { finalized = true; }; - var ctx = new Meteor.deps.Context; - ctx.onInvalidate(function () { + Meteor._atFlush(function () { if (finalized) return; @@ -395,8 +399,6 @@ var scheduleOnscreenSetup = function (frag, landmarkRanges) { notifyWatchers(renderedRange.firstNode(), renderedRange.lastNode()); renderedRange.destroy(); }); - - ctx.invalidate(); }; Spark.render = function (htmlFunc) { @@ -721,6 +723,9 @@ Spark.attachEvents = withRenderer(function (eventMap, html, _renderer) { html = _renderer.annotate( html, Spark._ANNOTATION_EVENTS, function (range) { + if (! range) + return; + _.each(eventTypes, function (t) { listener.addType(t); }); @@ -787,66 +792,40 @@ Spark.isolate = function (htmlFunc) { if (!renderer) return htmlFunc(); - var ctx = new Meteor.deps.Context; - - return renderer.annotate( - ctx.run(htmlFunc), Spark._ANNOTATION_ISOLATE, function (range) { - range.finalize = function () { - // Spark.finalize() was called on us (presumably because we were - // removed from the document.) Tear down our structures without - // doing any more updates. note that range is about to be - // destroyed by finalize. - range = null; - ctx.invalidate(); - }; - - var refresh = function () { - if (! range) - return; // killed by finalize. range has already been destroyed. - - ctx = new Meteor.deps.Context; - Spark.renderToRange(range, function () { - return ctx.run(htmlFunc); + var range; + var firstRun = true; + var retHtml; + Meteor.autorun(function (handle) { + if (firstRun) { + retHtml = renderer.annotate( + htmlFunc(), Spark._ANNOTATION_ISOLATE, + function (r) { + if (! r) { + // annotation not used; kill our context + handle.stop(); + } else { + range = r; + range.finalize = function () { + // Spark.finalize() was called on our range (presumably + // because it was removed from the document.) Kill + // this context and stop rerunning. + handle.stop(); + }; + } }); - ctx.onInvalidate(refresh); - }; + firstRun = false; + } else { + Spark.renderToRange(range, htmlFunc); + } + }); - ctx.onInvalidate(refresh); - }); + return retHtml; }; /******************************************************************************/ /* Lists */ /******************************************************************************/ -// Run 'f' at flush()-time. If atFlushTime is called multiple times, -// we guarantee that the 'f's will run in the order of their -// respective atFlushTime calls. -// -// XXX either break this out into a separate package or fold it into -// deps -var atFlushQueue = []; -var atFlushContext = null; -var atFlushTime = function (f) { - atFlushQueue.push(f); - - if (! atFlushContext) { - atFlushContext = new Meteor.deps.Context; - atFlushContext.onInvalidate(function () { - var f; - while ((f = atFlushQueue.shift())) { - // Since atFlushContext is truthy, if f() calls atFlushTime - // reentrantly, it's guaranteed to append to atFlushQueue and - // not contruct a new atFlushContext. - f(); - } - atFlushContext = null; - }); - - atFlushContext.invalidate(); - } -}; - Spark.list = function (cursor, itemFunc, elseFunc) { elseFunc = elseFunc || function () { return ''; }; @@ -874,8 +853,8 @@ Spark.list = function (cursor, itemFunc, elseFunc) { // Get the renderer, if any var renderer = Spark._currentRenderer.get(); - var annotate = renderer ? - _.bind(renderer.annotate, renderer) : + var maybeAnnotate = renderer ? + _.bind(renderer.annotate, renderer) : function (html) { return html; }; // Render the initial contents. If we have a renderer, create a @@ -889,11 +868,11 @@ Spark.list = function (cursor, itemFunc, elseFunc) { else { for (var i = 0; i < initialContents.length; i++) { (function (i) { - html += annotate(itemFunc(initialContents[i]), - Spark._ANNOTATION_LIST_ITEM, - function (range) { - itemRanges[i] = range; - }); + html += maybeAnnotate(itemFunc(initialContents[i]), + Spark._ANNOTATION_LIST_ITEM, + function (range) { + itemRanges[i] = range; + }); })(i); // scope i to closure } } @@ -903,13 +882,15 @@ Spark.list = function (cursor, itemFunc, elseFunc) { handle.stop(); stopped = true; }; - html = annotate(html, Spark._ANNOTATION_LIST, function (range) { - outerRange = range; - outerRange.finalize = cleanup; - }, function () { - // We never ended up on the screen (caller discarded our return - // value) - cleanup(); + html = maybeAnnotate(html, Spark._ANNOTATION_LIST, function (range) { + if (! range) { + // We never ended up on the screen (caller discarded our return + // value) + cleanup(); + } else { + outerRange = range; + outerRange.finalize = cleanup; + } }); // No renderer? Then we have no way to update the returned html and @@ -930,7 +911,7 @@ Spark.list = function (cursor, itemFunc, elseFunc) { }; var later = function (f) { - atFlushTime(function () { + Meteor._atFlush(function () { if (! stopped) withEventGuard(f); }); @@ -1124,6 +1105,12 @@ Spark.createLandmark = function (options, htmlFunc) { var html = htmlFunc(landmark); return renderer.annotate( html, Spark._ANNOTATION_LANDMARK, function (range) { + if (! range) { + // annotation not used + options.destroyed && options.destroyed.call(landmark); + return; + } + _.extend(range, { preserve: preserve, constant: !! options.constant, @@ -1140,9 +1127,6 @@ Spark.createLandmark = function (options, htmlFunc) { landmark._range = range; renderer.landmarkRanges.push(range); - }, function () { - // "annotation not used" callback - options.destroyed && options.destroyed.call(landmark); }); }; diff --git a/packages/srp/biginteger.js b/packages/srp/biginteger.js new file mode 100644 index 0000000000..676e01f9a8 --- /dev/null +++ b/packages/srp/biginteger.js @@ -0,0 +1,1279 @@ +/// METEOR WRAPPER +if (typeof Meteor._srp === "undefined") + Meteor._srp = {}; +Meteor._srp.BigInteger = (function () { + + +/// BEGIN jsbn.js + +/* + * Copyright (c) 2003-2005 Tom Wu + * All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * + * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, + * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER + * RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF + * THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT + * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * In addition, the following condition applies: + * + * All redistributions must retain an intact copy of this copyright notice + * and disclaimer. + */ + +// Basic JavaScript BN library - subset useful for RSA encryption. + +// Bits per digit +var dbits; + +// JavaScript engine analysis +var canary = 0xdeadbeefcafe; +var j_lm = ((canary&0xffffff)==0xefcafe); + +// (public) Constructor +function BigInteger(a,b,c) { + if(a != null) + if("number" == typeof a) this.fromNumber(a,b,c); + else if(b == null && "string" != typeof a) this.fromString(a,256); + else this.fromString(a,b); +} + +// return new, unset BigInteger +function nbi() { return new BigInteger(null); } + +// am: Compute w_j += (x*this_i), propagate carries, +// c is initial carry, returns final carry. +// c < 3*dvalue, x < 2*dvalue, this_i < dvalue +// We need to select the fastest one that works in this environment. + +// am1: use a single mult and divide to get the high bits, +// max digit bits should be 26 because +// max internal value = 2*dvalue^2-2*dvalue (< 2^53) +function am1(i,x,w,j,c,n) { + while(--n >= 0) { + var v = x*this[i++]+w[j]+c; + c = Math.floor(v/0x4000000); + w[j++] = v&0x3ffffff; + } + return c; +} +// am2 avoids a big mult-and-extract completely. +// Max digit bits should be <= 30 because we do bitwise ops +// on values up to 2*hdvalue^2-hdvalue-1 (< 2^31) +function am2(i,x,w,j,c,n) { + var xl = x&0x7fff, xh = x>>15; + while(--n >= 0) { + var l = this[i]&0x7fff; + var h = this[i++]>>15; + var m = xh*l+h*xl; + l = xl*l+((m&0x7fff)<<15)+w[j]+(c&0x3fffffff); + c = (l>>>30)+(m>>>15)+xh*h+(c>>>30); + w[j++] = l&0x3fffffff; + } + return c; +} +// Alternately, set max digit bits to 28 since some +// browsers slow down when dealing with 32-bit numbers. +function am3(i,x,w,j,c,n) { + var xl = x&0x3fff, xh = x>>14; + while(--n >= 0) { + var l = this[i]&0x3fff; + var h = this[i++]>>14; + var m = xh*l+h*xl; + l = xl*l+((m&0x3fff)<<14)+w[j]+c; + c = (l>>28)+(m>>14)+xh*h; + w[j++] = l&0xfffffff; + } + return c; +} + +/* XXX METEOR XXX +if(j_lm && (navigator.appName == "Microsoft Internet Explorer")) { + BigInteger.prototype.am = am2; + dbits = 30; +} +else if(j_lm && (navigator.appName != "Netscape")) { + BigInteger.prototype.am = am1; + dbits = 26; +} +else +*/ + +{ // Mozilla/Netscape seems to prefer am3 + BigInteger.prototype.am = am3; + dbits = 28; +} + +BigInteger.prototype.DB = dbits; +BigInteger.prototype.DM = ((1<= 0; --i) r[i] = this[i]; + r.t = this.t; + r.s = this.s; +} + +// (protected) set from integer value x, -DV <= x < DV +function bnpFromInt(x) { + this.t = 1; + this.s = (x<0)?-1:0; + if(x > 0) this[0] = x; + else if(x < -1) this[0] = x+DV; + else this.t = 0; +} + +// return bigint initialized to value +function nbv(i) { var r = nbi(); r.fromInt(i); return r; } + +// (protected) set from string and radix +function bnpFromString(s,b) { + var k; + if(b == 16) k = 4; + else if(b == 8) k = 3; + else if(b == 256) k = 8; // byte array + else if(b == 2) k = 1; + else if(b == 32) k = 5; + else if(b == 4) k = 2; + else { this.fromRadix(s,b); return; } + this.t = 0; + this.s = 0; + var i = s.length, mi = false, sh = 0; + while(--i >= 0) { + var x = (k==8)?s[i]&0xff:intAt(s,i); + if(x < 0) { + if(s.charAt(i) == "-") mi = true; + continue; + } + mi = false; + if(sh == 0) + this[this.t++] = x; + else if(sh+k > this.DB) { + this[this.t-1] |= (x&((1<<(this.DB-sh))-1))<>(this.DB-sh)); + } + else + this[this.t-1] |= x<= this.DB) sh -= this.DB; + } + if(k == 8 && (s[0]&0x80) != 0) { + this.s = -1; + if(sh > 0) this[this.t-1] |= ((1<<(this.DB-sh))-1)< 0 && this[this.t-1] == c) --this.t; +} + +// (public) return string representation in given radix +function bnToString(b) { + if(this.s < 0) return "-"+this.negate().toString(b); + var k; + if(b == 16) k = 4; + else if(b == 8) k = 3; + else if(b == 2) k = 1; + else if(b == 32) k = 5; + else if(b == 4) k = 2; + else return this.toRadix(b); + var km = (1< 0) { + if(p < this.DB && (d = this[i]>>p) > 0) { m = true; r = int2char(d); } + while(i >= 0) { + if(p < k) { + d = (this[i]&((1<>(p+=this.DB-k); + } + else { + d = (this[i]>>(p-=k))&km; + if(p <= 0) { p += this.DB; --i; } + } + if(d > 0) m = true; + if(m) r += int2char(d); + } + } + return m?r:"0"; +} + +// (public) -this +function bnNegate() { var r = nbi(); BigInteger.ZERO.subTo(this,r); return r; } + +// (public) |this| +function bnAbs() { return (this.s<0)?this.negate():this; } + +// (public) return + if this > a, - if this < a, 0 if equal +function bnCompareTo(a) { + var r = this.s-a.s; + if(r != 0) return r; + var i = this.t; + r = i-a.t; + if(r != 0) return r; + while(--i >= 0) if((r=this[i]-a[i]) != 0) return r; + return 0; +} + +// returns bit length of the integer x +function nbits(x) { + var r = 1, t; + if((t=x>>>16) != 0) { x = t; r += 16; } + if((t=x>>8) != 0) { x = t; r += 8; } + if((t=x>>4) != 0) { x = t; r += 4; } + if((t=x>>2) != 0) { x = t; r += 2; } + if((t=x>>1) != 0) { x = t; r += 1; } + return r; +} + +// (public) return the number of bits in "this" +function bnBitLength() { + if(this.t <= 0) return 0; + return this.DB*(this.t-1)+nbits(this[this.t-1]^(this.s&this.DM)); +} + +// (protected) r = this << n*DB +function bnpDLShiftTo(n,r) { + var i; + for(i = this.t-1; i >= 0; --i) r[i+n] = this[i]; + for(i = n-1; i >= 0; --i) r[i] = 0; + r.t = this.t+n; + r.s = this.s; +} + +// (protected) r = this >> n*DB +function bnpDRShiftTo(n,r) { + for(var i = n; i < this.t; ++i) r[i-n] = this[i]; + r.t = Math.max(this.t-n,0); + r.s = this.s; +} + +// (protected) r = this << n +function bnpLShiftTo(n,r) { + var bs = n%this.DB; + var cbs = this.DB-bs; + var bm = (1<= 0; --i) { + r[i+ds+1] = (this[i]>>cbs)|c; + c = (this[i]&bm)<= 0; --i) r[i] = 0; + r[ds] = c; + r.t = this.t+ds+1; + r.s = this.s; + r.clamp(); +} + +// (protected) r = this >> n +function bnpRShiftTo(n,r) { + r.s = this.s; + var ds = Math.floor(n/this.DB); + if(ds >= this.t) { r.t = 0; return; } + var bs = n%this.DB; + var cbs = this.DB-bs; + var bm = (1<>bs; + for(var i = ds+1; i < this.t; ++i) { + r[i-ds-1] |= (this[i]&bm)<>bs; + } + if(bs > 0) r[this.t-ds-1] |= (this.s&bm)<>= this.DB; + } + if(a.t < this.t) { + c -= a.s; + while(i < this.t) { + c += this[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c += this.s; + } + else { + c += this.s; + while(i < a.t) { + c -= a[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c -= a.s; + } + r.s = (c<0)?-1:0; + if(c < -1) r[i++] = this.DV+c; + else if(c > 0) r[i++] = c; + r.t = i; + r.clamp(); +} + +// (protected) r = this * a, r != this,a (HAC 14.12) +// "this" should be the larger one if appropriate. +function bnpMultiplyTo(a,r) { + var x = this.abs(), y = a.abs(); + var i = x.t; + r.t = i+y.t; + while(--i >= 0) r[i] = 0; + for(i = 0; i < y.t; ++i) r[i+x.t] = x.am(0,y[i],r,i,0,x.t); + r.s = 0; + r.clamp(); + if(this.s != a.s) BigInteger.ZERO.subTo(r,r); +} + +// (protected) r = this^2, r != this (HAC 14.16) +function bnpSquareTo(r) { + var x = this.abs(); + var i = r.t = 2*x.t; + while(--i >= 0) r[i] = 0; + for(i = 0; i < x.t-1; ++i) { + var c = x.am(i,x[i],r,2*i,0,1); + if((r[i+x.t]+=x.am(i+1,2*x[i],r,2*i+1,c,x.t-i-1)) >= x.DV) { + r[i+x.t] -= x.DV; + r[i+x.t+1] = 1; + } + } + if(r.t > 0) r[r.t-1] += x.am(i,x[i],r,2*i,0,1); + r.s = 0; + r.clamp(); +} + +// (protected) divide this by m, quotient and remainder to q, r (HAC 14.20) +// r != q, this != m. q or r may be null. +function bnpDivRemTo(m,q,r) { + var pm = m.abs(); + if(pm.t <= 0) return; + var pt = this.abs(); + if(pt.t < pm.t) { + if(q != null) q.fromInt(0); + if(r != null) this.copyTo(r); + return; + } + if(r == null) r = nbi(); + var y = nbi(), ts = this.s, ms = m.s; + var nsh = this.DB-nbits(pm[pm.t-1]); // normalize modulus + if(nsh > 0) { pm.lShiftTo(nsh,y); pt.lShiftTo(nsh,r); } + else { pm.copyTo(y); pt.copyTo(r); } + var ys = y.t; + var y0 = y[ys-1]; + if(y0 == 0) return; + var yt = y0*(1<1)?y[ys-2]>>this.F2:0); + var d1 = this.FV/yt, d2 = (1<= 0) { + r[r.t++] = 1; + r.subTo(t,r); + } + BigInteger.ONE.dlShiftTo(ys,t); + t.subTo(y,y); // "negative" y so we can replace sub with am later + while(y.t < ys) y[y.t++] = 0; + while(--j >= 0) { + // Estimate quotient digit + var qd = (r[--i]==y0)?this.DM:Math.floor(r[i]*d1+(r[i-1]+e)*d2); + if((r[i]+=y.am(0,qd,r,j,0,ys)) < qd) { // Try it out + y.dlShiftTo(j,t); + r.subTo(t,r); + while(r[i] < --qd) r.subTo(t,r); + } + } + if(q != null) { + r.drShiftTo(ys,q); + if(ts != ms) BigInteger.ZERO.subTo(q,q); + } + r.t = ys; + r.clamp(); + if(nsh > 0) r.rShiftTo(nsh,r); // Denormalize remainder + if(ts < 0) BigInteger.ZERO.subTo(r,r); +} + +// (public) this mod a +function bnMod(a) { + var r = nbi(); + this.abs().divRemTo(a,null,r); + if(this.s < 0 && r.compareTo(BigInteger.ZERO) > 0) a.subTo(r,r); + return r; +} + +// Modular reduction using "classic" algorithm +function Classic(m) { this.m = m; } +function cConvert(x) { + if(x.s < 0 || x.compareTo(this.m) >= 0) return x.mod(this.m); + else return x; +} +function cRevert(x) { return x; } +function cReduce(x) { x.divRemTo(this.m,null,x); } +function cMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); } +function cSqrTo(x,r) { x.squareTo(r); this.reduce(r); } + +Classic.prototype.convert = cConvert; +Classic.prototype.revert = cRevert; +Classic.prototype.reduce = cReduce; +Classic.prototype.mulTo = cMulTo; +Classic.prototype.sqrTo = cSqrTo; + +// (protected) return "-1/this % 2^DB"; useful for Mont. reduction +// justification: +// xy == 1 (mod m) +// xy = 1+km +// xy(2-xy) = (1+km)(1-km) +// x[y(2-xy)] = 1-k^2m^2 +// x[y(2-xy)] == 1 (mod m^2) +// if y is 1/x mod m, then y(2-xy) is 1/x mod m^2 +// should reduce x and y(2-xy) by m^2 at each step to keep size bounded. +// JS multiply "overflows" differently from C/C++, so care is needed here. +function bnpInvDigit() { + if(this.t < 1) return 0; + var x = this[0]; + if((x&1) == 0) return 0; + var y = x&3; // y == 1/x mod 2^2 + y = (y*(2-(x&0xf)*y))&0xf; // y == 1/x mod 2^4 + y = (y*(2-(x&0xff)*y))&0xff; // y == 1/x mod 2^8 + y = (y*(2-(((x&0xffff)*y)&0xffff)))&0xffff; // y == 1/x mod 2^16 + // last step - calculate inverse mod DV directly; + // assumes 16 < DB <= 32 and assumes ability to handle 48-bit ints + y = (y*(2-x*y%this.DV))%this.DV; // y == 1/x mod 2^dbits + // we really want the negative inverse, and -DV < y < DV + return (y>0)?this.DV-y:-y; +} + +// Montgomery reduction +function Montgomery(m) { + this.m = m; + this.mp = m.invDigit(); + this.mpl = this.mp&0x7fff; + this.mph = this.mp>>15; + this.um = (1<<(m.DB-15))-1; + this.mt2 = 2*m.t; +} + +// xR mod m +function montConvert(x) { + var r = nbi(); + x.abs().dlShiftTo(this.m.t,r); + r.divRemTo(this.m,null,r); + if(x.s < 0 && r.compareTo(BigInteger.ZERO) > 0) this.m.subTo(r,r); + return r; +} + +// x/R mod m +function montRevert(x) { + var r = nbi(); + x.copyTo(r); + this.reduce(r); + return r; +} + +// x = x/R mod m (HAC 14.32) +function montReduce(x) { + while(x.t <= this.mt2) // pad x so am has enough room later + x[x.t++] = 0; + for(var i = 0; i < this.m.t; ++i) { + // faster way of calculating u0 = x[i]*mp mod DV + var j = x[i]&0x7fff; + var u0 = (j*this.mpl+(((j*this.mph+(x[i]>>15)*this.mpl)&this.um)<<15))&x.DM; + // use am to combine the multiply-shift-add into one call + j = i+this.m.t; + x[j] += this.m.am(0,u0,x,i,0,this.m.t); + // propagate carry + while(x[j] >= x.DV) { x[j] -= x.DV; x[++j]++; } + } + x.clamp(); + x.drShiftTo(this.m.t,x); + if(x.compareTo(this.m) >= 0) x.subTo(this.m,x); +} + +// r = "x^2/R mod m"; x != r +function montSqrTo(x,r) { x.squareTo(r); this.reduce(r); } + +// r = "xy/R mod m"; x,y != r +function montMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); } + +Montgomery.prototype.convert = montConvert; +Montgomery.prototype.revert = montRevert; +Montgomery.prototype.reduce = montReduce; +Montgomery.prototype.mulTo = montMulTo; +Montgomery.prototype.sqrTo = montSqrTo; + +// (protected) true iff this is even +function bnpIsEven() { return ((this.t>0)?(this[0]&1):this.s) == 0; } + +// (protected) this^e, e < 2^32, doing sqr and mul with "r" (HAC 14.79) +function bnpExp(e,z) { + if(e > 0xffffffff || e < 1) return BigInteger.ONE; + var r = nbi(), r2 = nbi(), g = z.convert(this), i = nbits(e)-1; + g.copyTo(r); + while(--i >= 0) { + z.sqrTo(r,r2); + if((e&(1< 0) z.mulTo(r2,g,r); + else { var t = r; r = r2; r2 = t; } + } + return z.revert(r); +} + +// (public) this^e % m, 0 <= e < 2^32 +function bnModPowInt(e,m) { + var z; + if(e < 256 || m.isEven()) z = new Classic(m); else z = new Montgomery(m); + return this.exp(e,z); +} + +// protected +BigInteger.prototype.copyTo = bnpCopyTo; +BigInteger.prototype.fromInt = bnpFromInt; +BigInteger.prototype.fromString = bnpFromString; +BigInteger.prototype.clamp = bnpClamp; +BigInteger.prototype.dlShiftTo = bnpDLShiftTo; +BigInteger.prototype.drShiftTo = bnpDRShiftTo; +BigInteger.prototype.lShiftTo = bnpLShiftTo; +BigInteger.prototype.rShiftTo = bnpRShiftTo; +BigInteger.prototype.subTo = bnpSubTo; +BigInteger.prototype.multiplyTo = bnpMultiplyTo; +BigInteger.prototype.squareTo = bnpSquareTo; +BigInteger.prototype.divRemTo = bnpDivRemTo; +BigInteger.prototype.invDigit = bnpInvDigit; +BigInteger.prototype.isEven = bnpIsEven; +BigInteger.prototype.exp = bnpExp; + +// public +BigInteger.prototype.toString = bnToString; +BigInteger.prototype.negate = bnNegate; +BigInteger.prototype.abs = bnAbs; +BigInteger.prototype.compareTo = bnCompareTo; +BigInteger.prototype.bitLength = bnBitLength; +BigInteger.prototype.mod = bnMod; +BigInteger.prototype.modPowInt = bnModPowInt; + +// "constants" +BigInteger.ZERO = nbv(0); +BigInteger.ONE = nbv(1); + + +/// BEGIN jsbn2.js + +/* + * Copyright (c) 2003-2005 Tom Wu + * All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * + * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, + * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER + * RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF + * THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT + * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * In addition, the following condition applies: + * + * All redistributions must retain an intact copy of this copyright notice + * and disclaimer. + */ + +// Extended JavaScript BN functions, required for RSA private ops. + +// (public) +function bnClone() { var r = nbi(); this.copyTo(r); return r; } + +// (public) return value as integer +function bnIntValue() { + if(this.s < 0) { + if(this.t == 1) return this[0]-this.DV; + else if(this.t == 0) return -1; + } + else if(this.t == 1) return this[0]; + else if(this.t == 0) return 0; + // assumes 16 < DB < 32 + return ((this[1]&((1<<(32-this.DB))-1))<>24; } + +// (public) return value as short (assumes DB>=16) +function bnShortValue() { return (this.t==0)?this.s:(this[0]<<16)>>16; } + +// (protected) return x s.t. r^x < DV +function bnpChunkSize(r) { return Math.floor(Math.LN2*this.DB/Math.log(r)); } + +// (public) 0 if this == 0, 1 if this > 0 +function bnSigNum() { + if(this.s < 0) return -1; + else if(this.t <= 0 || (this.t == 1 && this[0] <= 0)) return 0; + else return 1; +} + +// (protected) convert to radix string +function bnpToRadix(b) { + if(b == null) b = 10; + if(this.signum() == 0 || b < 2 || b > 36) return "0"; + var cs = this.chunkSize(b); + var a = Math.pow(b,cs); + var d = nbv(a), y = nbi(), z = nbi(), r = ""; + this.divRemTo(d,y,z); + while(y.signum() > 0) { + r = (a+z.intValue()).toString(b).substr(1) + r; + y.divRemTo(d,y,z); + } + return z.intValue().toString(b) + r; +} + +// (protected) convert from radix string +function bnpFromRadix(s,b) { + this.fromInt(0); + if(b == null) b = 10; + var cs = this.chunkSize(b); + var d = Math.pow(b,cs), mi = false, j = 0, w = 0; + for(var i = 0; i < s.length; ++i) { + var x = intAt(s,i); + if(x < 0) { + if(s.charAt(i) == "-" && this.signum() == 0) mi = true; + continue; + } + w = b*w+x; + if(++j >= cs) { + this.dMultiply(d); + this.dAddOffset(w,0); + j = 0; + w = 0; + } + } + if(j > 0) { + this.dMultiply(Math.pow(b,j)); + this.dAddOffset(w,0); + } + if(mi) BigInteger.ZERO.subTo(this,this); +} + +// (protected) alternate constructor +function bnpFromNumber(a,b,c) { + if("number" == typeof b) { + // new BigInteger(int,int,RNG) + if(a < 2) this.fromInt(1); + else { + this.fromNumber(a,c); + if(!this.testBit(a-1)) // force MSB set + this.bitwiseTo(BigInteger.ONE.shiftLeft(a-1),op_or,this); + if(this.isEven()) this.dAddOffset(1,0); // force odd + while(!this.isProbablePrime(b)) { + this.dAddOffset(2,0); + if(this.bitLength() > a) this.subTo(BigInteger.ONE.shiftLeft(a-1),this); + } + } + } + else { + // new BigInteger(int,RNG) + var x = new Array(), t = a&7; + x.length = (a>>3)+1; + b.nextBytes(x); + if(t > 0) x[0] &= ((1< 0) { + if(p < this.DB && (d = this[i]>>p) != (this.s&this.DM)>>p) + r[k++] = d|(this.s<<(this.DB-p)); + while(i >= 0) { + if(p < 8) { + d = (this[i]&((1<>(p+=this.DB-8); + } + else { + d = (this[i]>>(p-=8))&0xff; + if(p <= 0) { p += this.DB; --i; } + } + if((d&0x80) != 0) d |= -256; + if(k == 0 && (this.s&0x80) != (d&0x80)) ++k; + if(k > 0 || d != this.s) r[k++] = d; + } + } + return r; +} + +function bnEquals(a) { return(this.compareTo(a)==0); } +function bnMin(a) { return(this.compareTo(a)<0)?this:a; } +function bnMax(a) { return(this.compareTo(a)>0)?this:a; } + +// (protected) r = this op a (bitwise) +function bnpBitwiseTo(a,op,r) { + var i, f, m = Math.min(a.t,this.t); + for(i = 0; i < m; ++i) r[i] = op(this[i],a[i]); + if(a.t < this.t) { + f = a.s&this.DM; + for(i = m; i < this.t; ++i) r[i] = op(this[i],f); + r.t = this.t; + } + else { + f = this.s&this.DM; + for(i = m; i < a.t; ++i) r[i] = op(f,a[i]); + r.t = a.t; + } + r.s = op(this.s,a.s); + r.clamp(); +} + +// (public) this & a +function op_and(x,y) { return x&y; } +function bnAnd(a) { var r = nbi(); this.bitwiseTo(a,op_and,r); return r; } + +// (public) this | a +function op_or(x,y) { return x|y; } +function bnOr(a) { var r = nbi(); this.bitwiseTo(a,op_or,r); return r; } + +// (public) this ^ a +function op_xor(x,y) { return x^y; } +function bnXor(a) { var r = nbi(); this.bitwiseTo(a,op_xor,r); return r; } + +// (public) this & ~a +function op_andnot(x,y) { return x&~y; } +function bnAndNot(a) { var r = nbi(); this.bitwiseTo(a,op_andnot,r); return r; } + +// (public) ~this +function bnNot() { + var r = nbi(); + for(var i = 0; i < this.t; ++i) r[i] = this.DM&~this[i]; + r.t = this.t; + r.s = ~this.s; + return r; +} + +// (public) this << n +function bnShiftLeft(n) { + var r = nbi(); + if(n < 0) this.rShiftTo(-n,r); else this.lShiftTo(n,r); + return r; +} + +// (public) this >> n +function bnShiftRight(n) { + var r = nbi(); + if(n < 0) this.lShiftTo(-n,r); else this.rShiftTo(n,r); + return r; +} + +// return index of lowest 1-bit in x, x < 2^31 +function lbit(x) { + if(x == 0) return -1; + var r = 0; + if((x&0xffff) == 0) { x >>= 16; r += 16; } + if((x&0xff) == 0) { x >>= 8; r += 8; } + if((x&0xf) == 0) { x >>= 4; r += 4; } + if((x&3) == 0) { x >>= 2; r += 2; } + if((x&1) == 0) ++r; + return r; +} + +// (public) returns index of lowest 1-bit (or -1 if none) +function bnGetLowestSetBit() { + for(var i = 0; i < this.t; ++i) + if(this[i] != 0) return i*this.DB+lbit(this[i]); + if(this.s < 0) return this.t*this.DB; + return -1; +} + +// return number of 1 bits in x +function cbit(x) { + var r = 0; + while(x != 0) { x &= x-1; ++r; } + return r; +} + +// (public) return number of set bits +function bnBitCount() { + var r = 0, x = this.s&this.DM; + for(var i = 0; i < this.t; ++i) r += cbit(this[i]^x); + return r; +} + +// (public) true iff nth bit is set +function bnTestBit(n) { + var j = Math.floor(n/this.DB); + if(j >= this.t) return(this.s!=0); + return((this[j]&(1<<(n%this.DB)))!=0); +} + +// (protected) this op (1<>= this.DB; + } + if(a.t < this.t) { + c += a.s; + while(i < this.t) { + c += this[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c += this.s; + } + else { + c += this.s; + while(i < a.t) { + c += a[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c += a.s; + } + r.s = (c<0)?-1:0; + if(c > 0) r[i++] = c; + else if(c < -1) r[i++] = this.DV+c; + r.t = i; + r.clamp(); +} + +// (public) this + a +function bnAdd(a) { var r = nbi(); this.addTo(a,r); return r; } + +// (public) this - a +function bnSubtract(a) { var r = nbi(); this.subTo(a,r); return r; } + +// (public) this * a +function bnMultiply(a) { var r = nbi(); this.multiplyTo(a,r); return r; } + +// (public) this / a +function bnDivide(a) { var r = nbi(); this.divRemTo(a,r,null); return r; } + +// (public) this % a +function bnRemainder(a) { var r = nbi(); this.divRemTo(a,null,r); return r; } + +// (public) [this/a,this%a] +function bnDivideAndRemainder(a) { + var q = nbi(), r = nbi(); + this.divRemTo(a,q,r); + return new Array(q,r); +} + +// (protected) this *= n, this >= 0, 1 < n < DV +function bnpDMultiply(n) { + this[this.t] = this.am(0,n-1,this,0,0,this.t); + ++this.t; + this.clamp(); +} + +// (protected) this += n << w words, this >= 0 +function bnpDAddOffset(n,w) { + while(this.t <= w) this[this.t++] = 0; + this[w] += n; + while(this[w] >= this.DV) { + this[w] -= this.DV; + if(++w >= this.t) this[this.t++] = 0; + ++this[w]; + } +} + +// A "null" reducer +function NullExp() {} +function nNop(x) { return x; } +function nMulTo(x,y,r) { x.multiplyTo(y,r); } +function nSqrTo(x,r) { x.squareTo(r); } + +NullExp.prototype.convert = nNop; +NullExp.prototype.revert = nNop; +NullExp.prototype.mulTo = nMulTo; +NullExp.prototype.sqrTo = nSqrTo; + +// (public) this^e +function bnPow(e) { return this.exp(e,new NullExp()); } + +// (protected) r = lower n words of "this * a", a.t <= n +// "this" should be the larger one if appropriate. +function bnpMultiplyLowerTo(a,n,r) { + var i = Math.min(this.t+a.t,n); + r.s = 0; // assumes a,this >= 0 + r.t = i; + while(i > 0) r[--i] = 0; + var j; + for(j = r.t-this.t; i < j; ++i) r[i+this.t] = this.am(0,a[i],r,i,0,this.t); + for(j = Math.min(a.t,n); i < j; ++i) this.am(0,a[i],r,i,0,n-i); + r.clamp(); +} + +// (protected) r = "this * a" without lower n words, n > 0 +// "this" should be the larger one if appropriate. +function bnpMultiplyUpperTo(a,n,r) { + --n; + var i = r.t = this.t+a.t-n; + r.s = 0; // assumes a,this >= 0 + while(--i >= 0) r[i] = 0; + for(i = Math.max(n-this.t,0); i < a.t; ++i) + r[this.t+i-n] = this.am(n-i,a[i],r,0,0,this.t+i-n); + r.clamp(); + r.drShiftTo(1,r); +} + +// Barrett modular reduction +function Barrett(m) { + // setup Barrett + this.r2 = nbi(); + this.q3 = nbi(); + BigInteger.ONE.dlShiftTo(2*m.t,this.r2); + this.mu = this.r2.divide(m); + this.m = m; +} + +function barrettConvert(x) { + if(x.s < 0 || x.t > 2*this.m.t) return x.mod(this.m); + else if(x.compareTo(this.m) < 0) return x; + else { var r = nbi(); x.copyTo(r); this.reduce(r); return r; } +} + +function barrettRevert(x) { return x; } + +// x = x mod m (HAC 14.42) +function barrettReduce(x) { + x.drShiftTo(this.m.t-1,this.r2); + if(x.t > this.m.t+1) { x.t = this.m.t+1; x.clamp(); } + this.mu.multiplyUpperTo(this.r2,this.m.t+1,this.q3); + this.m.multiplyLowerTo(this.q3,this.m.t+1,this.r2); + while(x.compareTo(this.r2) < 0) x.dAddOffset(1,this.m.t+1); + x.subTo(this.r2,x); + while(x.compareTo(this.m) >= 0) x.subTo(this.m,x); +} + +// r = x^2 mod m; x != r +function barrettSqrTo(x,r) { x.squareTo(r); this.reduce(r); } + +// r = x*y mod m; x,y != r +function barrettMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); } + +Barrett.prototype.convert = barrettConvert; +Barrett.prototype.revert = barrettRevert; +Barrett.prototype.reduce = barrettReduce; +Barrett.prototype.mulTo = barrettMulTo; +Barrett.prototype.sqrTo = barrettSqrTo; + +// (public) this^e % m (HAC 14.85) +function bnModPow(e,m) { + var i = e.bitLength(), k, r = nbv(1), z; + if(i <= 0) return r; + else if(i < 18) k = 1; + else if(i < 48) k = 3; + else if(i < 144) k = 4; + else if(i < 768) k = 5; + else k = 6; + if(i < 8) + z = new Classic(m); + else if(m.isEven()) + z = new Barrett(m); + else + z = new Montgomery(m); + + // precomputation + var g = new Array(), n = 3, k1 = k-1, km = (1< 1) { + var g2 = nbi(); + z.sqrTo(g[1],g2); + while(n <= km) { + g[n] = nbi(); + z.mulTo(g2,g[n-2],g[n]); + n += 2; + } + } + + var j = e.t-1, w, is1 = true, r2 = nbi(), t; + i = nbits(e[j])-1; + while(j >= 0) { + if(i >= k1) w = (e[j]>>(i-k1))&km; + else { + w = (e[j]&((1<<(i+1))-1))<<(k1-i); + if(j > 0) w |= e[j-1]>>(this.DB+i-k1); + } + + n = k; + while((w&1) == 0) { w >>= 1; --n; } + if((i -= n) < 0) { i += this.DB; --j; } + if(is1) { // ret == 1, don't bother squaring or multiplying it + g[w].copyTo(r); + is1 = false; + } + else { + while(n > 1) { z.sqrTo(r,r2); z.sqrTo(r2,r); n -= 2; } + if(n > 0) z.sqrTo(r,r2); else { t = r; r = r2; r2 = t; } + z.mulTo(r2,g[w],r); + } + + while(j >= 0 && (e[j]&(1< 0) { + x.rShiftTo(g,x); + y.rShiftTo(g,y); + } + while(x.signum() > 0) { + if((i = x.getLowestSetBit()) > 0) x.rShiftTo(i,x); + if((i = y.getLowestSetBit()) > 0) y.rShiftTo(i,y); + if(x.compareTo(y) >= 0) { + x.subTo(y,x); + x.rShiftTo(1,x); + } + else { + y.subTo(x,y); + y.rShiftTo(1,y); + } + } + if(g > 0) y.lShiftTo(g,y); + return y; +} + +// (protected) this % n, n < 2^26 +function bnpModInt(n) { + if(n <= 0) return 0; + var d = this.DV%n, r = (this.s<0)?n-1:0; + if(this.t > 0) + if(d == 0) r = this[0]%n; + else for(var i = this.t-1; i >= 0; --i) r = (d*r+this[i])%n; + return r; +} + +// (public) 1/this % m (HAC 14.61) +function bnModInverse(m) { + var ac = m.isEven(); + if((this.isEven() && ac) || m.signum() == 0) return BigInteger.ZERO; + var u = m.clone(), v = this.clone(); + var a = nbv(1), b = nbv(0), c = nbv(0), d = nbv(1); + while(u.signum() != 0) { + while(u.isEven()) { + u.rShiftTo(1,u); + if(ac) { + if(!a.isEven() || !b.isEven()) { a.addTo(this,a); b.subTo(m,b); } + a.rShiftTo(1,a); + } + else if(!b.isEven()) b.subTo(m,b); + b.rShiftTo(1,b); + } + while(v.isEven()) { + v.rShiftTo(1,v); + if(ac) { + if(!c.isEven() || !d.isEven()) { c.addTo(this,c); d.subTo(m,d); } + c.rShiftTo(1,c); + } + else if(!d.isEven()) d.subTo(m,d); + d.rShiftTo(1,d); + } + if(u.compareTo(v) >= 0) { + u.subTo(v,u); + if(ac) a.subTo(c,a); + b.subTo(d,b); + } + else { + v.subTo(u,v); + if(ac) c.subTo(a,c); + d.subTo(b,d); + } + } + if(v.compareTo(BigInteger.ONE) != 0) return BigInteger.ZERO; + if(d.compareTo(m) >= 0) return d.subtract(m); + if(d.signum() < 0) d.addTo(m,d); else return d; + if(d.signum() < 0) return d.add(m); else return d; +} + +var lowprimes = [2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97,101,103,107,109,113,127,131,137,139,149,151,157,163,167,173,179,181,191,193,197,199,211,223,227,229,233,239,241,251,257,263,269,271,277,281,283,293,307,311,313,317,331,337,347,349,353,359,367,373,379,383,389,397,401,409,419,421,431,433,439,443,449,457,461,463,467,479,487,491,499,503,509]; +var lplim = (1<<26)/lowprimes[lowprimes.length-1]; + +// (public) test primality with certainty >= 1-.5^t +function bnIsProbablePrime(t) { + var i, x = this.abs(); + if(x.t == 1 && x[0] <= lowprimes[lowprimes.length-1]) { + for(i = 0; i < lowprimes.length; ++i) + if(x[0] == lowprimes[i]) return true; + return false; + } + if(x.isEven()) return false; + i = 1; + while(i < lowprimes.length) { + var m = lowprimes[i], j = i+1; + while(j < lowprimes.length && m < lplim) m *= lowprimes[j++]; + m = x.modInt(m); + while(i < j) if(m%lowprimes[i++] == 0) return false; + } + return x.millerRabin(t); +} + +// (protected) true if probably prime (HAC 4.24, Miller-Rabin) +function bnpMillerRabin(t) { + var n1 = this.subtract(BigInteger.ONE); + var k = n1.getLowestSetBit(); + if(k <= 0) return false; + var r = n1.shiftRight(k); + t = (t+1)>>1; + if(t > lowprimes.length) t = lowprimes.length; + var a = nbi(); + for(var i = 0; i < t; ++i) { + a.fromInt(lowprimes[i]); + var y = a.modPow(r,this); + if(y.compareTo(BigInteger.ONE) != 0 && y.compareTo(n1) != 0) { + var j = 1; + while(j++ < k && y.compareTo(n1) != 0) { + y = y.modPowInt(2,this); + if(y.compareTo(BigInteger.ONE) == 0) return false; + } + if(y.compareTo(n1) != 0) return false; + } + } + return true; +} + +// protected +BigInteger.prototype.chunkSize = bnpChunkSize; +BigInteger.prototype.toRadix = bnpToRadix; +BigInteger.prototype.fromRadix = bnpFromRadix; +BigInteger.prototype.fromNumber = bnpFromNumber; +BigInteger.prototype.bitwiseTo = bnpBitwiseTo; +BigInteger.prototype.changeBit = bnpChangeBit; +BigInteger.prototype.addTo = bnpAddTo; +BigInteger.prototype.dMultiply = bnpDMultiply; +BigInteger.prototype.dAddOffset = bnpDAddOffset; +BigInteger.prototype.multiplyLowerTo = bnpMultiplyLowerTo; +BigInteger.prototype.multiplyUpperTo = bnpMultiplyUpperTo; +BigInteger.prototype.modInt = bnpModInt; +BigInteger.prototype.millerRabin = bnpMillerRabin; + +// public +BigInteger.prototype.clone = bnClone; +BigInteger.prototype.intValue = bnIntValue; +BigInteger.prototype.byteValue = bnByteValue; +BigInteger.prototype.shortValue = bnShortValue; +BigInteger.prototype.signum = bnSigNum; +BigInteger.prototype.toByteArray = bnToByteArray; +BigInteger.prototype.equals = bnEquals; +BigInteger.prototype.min = bnMin; +BigInteger.prototype.max = bnMax; +BigInteger.prototype.and = bnAnd; +BigInteger.prototype.or = bnOr; +BigInteger.prototype.xor = bnXor; +BigInteger.prototype.andNot = bnAndNot; +BigInteger.prototype.not = bnNot; +BigInteger.prototype.shiftLeft = bnShiftLeft; +BigInteger.prototype.shiftRight = bnShiftRight; +BigInteger.prototype.getLowestSetBit = bnGetLowestSetBit; +BigInteger.prototype.bitCount = bnBitCount; +BigInteger.prototype.testBit = bnTestBit; +BigInteger.prototype.setBit = bnSetBit; +BigInteger.prototype.clearBit = bnClearBit; +BigInteger.prototype.flipBit = bnFlipBit; +BigInteger.prototype.add = bnAdd; +BigInteger.prototype.subtract = bnSubtract; +BigInteger.prototype.multiply = bnMultiply; +BigInteger.prototype.divide = bnDivide; +BigInteger.prototype.remainder = bnRemainder; +BigInteger.prototype.divideAndRemainder = bnDivideAndRemainder; +BigInteger.prototype.modPow = bnModPow; +BigInteger.prototype.modInverse = bnModInverse; +BigInteger.prototype.pow = bnPow; +BigInteger.prototype.gcd = bnGCD; +BigInteger.prototype.isProbablePrime = bnIsProbablePrime; + +// BigInteger interfaces not implemented in jsbn: + +// BigInteger(int signum, byte[] magnitude) +// double doubleValue() +// float floatValue() +// int hashCode() +// long longValue() +// static BigInteger valueOf(long val) + +/// METEOR WRAPPER +return BigInteger; +})(); diff --git a/packages/srp/package.js b/packages/srp/package.js new file mode 100644 index 0000000000..d861368abe --- /dev/null +++ b/packages/srp/package.js @@ -0,0 +1,15 @@ +Package.describe({ + summary: "Library for Secure Remote Password (SRP) exchanges", + internal: true +}); + +Package.on_use(function (api) { + api.use('uuid', ['client', 'server']); + api.add_files(['biginteger.js', 'sha256.js', 'srp.js'], + ['client', 'server']); +}); + +Package.on_test(function (api) { + api.use('srp', ['client', 'server']); + api.add_files(['srp_tests.js'], ['client', 'server']); +}); diff --git a/packages/srp/sha256.js b/packages/srp/sha256.js new file mode 100644 index 0000000000..9b34c69f36 --- /dev/null +++ b/packages/srp/sha256.js @@ -0,0 +1,147 @@ +/// METEOR WRAPPER +// +// XXX this should get packaged and moved into the Meteor.crypto +// namespace, along with other hash functions. +if (typeof Meteor._srp === "undefined") + Meteor._srp = {}; +Meteor._srp.SHA256 = (function () { + + +/** +* +* Secure Hash Algorithm (SHA256) +* http://www.webtoolkit.info/javascript-sha256.html +* http://anmar.eu.org/projects/jssha2/ +* +* Original code by Angel Marin, Paul Johnston. +* +**/ + +function SHA256(s){ + + var chrsz = 8; + var hexcase = 0; + + function safe_add (x, y) { + var lsw = (x & 0xFFFF) + (y & 0xFFFF); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xFFFF); + } + + function S (X, n) { return ( X >>> n ) | (X << (32 - n)); } + function R (X, n) { return ( X >>> n ); } + function Ch(x, y, z) { return ((x & y) ^ ((~x) & z)); } + function Maj(x, y, z) { return ((x & y) ^ (x & z) ^ (y & z)); } + function Sigma0256(x) { return (S(x, 2) ^ S(x, 13) ^ S(x, 22)); } + function Sigma1256(x) { return (S(x, 6) ^ S(x, 11) ^ S(x, 25)); } + function Gamma0256(x) { return (S(x, 7) ^ S(x, 18) ^ R(x, 3)); } + function Gamma1256(x) { return (S(x, 17) ^ S(x, 19) ^ R(x, 10)); } + + function core_sha256 (m, l) { + var K = new Array(0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, 0xE49B69C1, 0xEFBE4786, 0xFC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x6CA6351, 0x14292967, 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2); + var HASH = new Array(0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19); + var W = new Array(64); + var a, b, c, d, e, f, g, h, i, j; + var T1, T2; + + m[l >> 5] |= 0x80 << (24 - l % 32); + m[((l + 64 >> 9) << 4) + 15] = l; + + for ( var i = 0; i>5] |= (str.charCodeAt(i / chrsz) & mask) << (24 - i%32); + } + return bin; + } + + function Utf8Encode(string) { + // METEOR change: + // The webtoolkit.info version of this code added this + // Utf8Encode function (which does seem necessary for dealing + // with arbitrary Unicode), but the following line seems + // problematic: + // + // string = string.replace(/\r\n/g,"\n"); + var utftext = ""; + + for (var n = 0; n < string.length; n++) { + + var c = string.charCodeAt(n); + + if (c < 128) { + utftext += String.fromCharCode(c); + } + else if((c > 127) && (c < 2048)) { + utftext += String.fromCharCode((c >> 6) | 192); + utftext += String.fromCharCode((c & 63) | 128); + } + else { + utftext += String.fromCharCode((c >> 12) | 224); + utftext += String.fromCharCode(((c >> 6) & 63) | 128); + utftext += String.fromCharCode((c & 63) | 128); + } + + } + + return utftext; + } + + function binb2hex (binarray) { + var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; + var str = ""; + for(var i = 0; i < binarray.length * 4; i++) { + str += hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8+4)) & 0xF) + + hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8 )) & 0xF); + } + return str; + } + + s = Utf8Encode(s); + return binb2hex(core_sha256(str2binb(s), s.length * chrsz)); + +} + +/// METEOR WRAPPER +return SHA256; +})(); diff --git a/packages/srp/srp.js b/packages/srp/srp.js new file mode 100644 index 0000000000..25716f3554 --- /dev/null +++ b/packages/srp/srp.js @@ -0,0 +1,341 @@ +(function () { + + if (typeof Meteor._srp === "undefined") + Meteor._srp = {}; + + + /////// PUBLIC CLIENT + + /** + * Generate a new SRP verifier. Password is the plaintext password. + * + * options is optional and can include: + * - identity: String. The SRP username to user. Mostly this is passed + * in for testing. Random UUID if not provided. + * - salt: String. A salt to use. Mostly this is passed in for + * testing. Random UUID if not provided. + * - SRP parameters (see _defaults and paramsFromOptions below) + */ + Meteor._srp.generateVerifier = function (password, options) { + var params = paramsFromOptions(options); + + var identity = (options && options.identity) || Meteor.uuid(); + var salt = (options && options.salt) || Meteor.uuid(); + + var x = params.hash(salt + params.hash(identity + ":" + password)); + var xi = new Meteor._srp.BigInteger(x, 16); + var v = params.g.modPow(xi, params.N); + + + return { + identity: identity, + salt: salt, + verifier: v.toString(16) + }; + }; + + + + /** + * Generate a new SRP client object. Password is the plaintext password. + * + * options is optional and can include: + * - a: client's private ephemeral value. String or + * BigInteger. Normally, this is picked randomly, but it can be + * passed in for testing. + * - SRP parameters (see _defaults and paramsFromOptions below) + */ + Meteor._srp.Client = function (password, options) { + var self = this; + self.params = paramsFromOptions(options); + self.password = password; + + // shorthand + var N = self.params.N; + var g = self.params.g; + + // construct public and private keys. + var a, A; + if (options && options.a) { + if (typeof options.a === "string") + a = new Meteor._srp.BigInteger(options.a, 16); + else if (options.a instanceof Meteor._srp.BigInteger) + a = options.a; + else + throw new Error("Invalid parameter: a"); + + A = g.modPow(a, N); + + if (A.mod(N) === 0) + throw new Error("Invalid parameter: a: A mod N == 0."); + + } else { + while (!A || A.mod(N) === 0) { + a = randInt(); + A = g.modPow(a, N); + } + } + + self.a = a; + self.A = A; + self.Astr = A.toString(16); + }; + + + /** + * Initiate an SRP exchange. + * + * returns { A: 'client public ephemeral key. hex encoded integer.' } + */ + Meteor._srp.Client.prototype.startExchange = function () { + var self = this; + + return { + A: self.Astr + }; + }; + + /** + * Respond to the server's challenge with a proof of password. + * + * challenge is an object with + * - B: server public ephemeral key. hex encoded integer. + * - identity: user's identity (SRP username). + * - salt: user's salt. + * + * returns { M: 'client proof of password. hex encoded integer.' } + * throws an error if it got an invalid challenge. + */ + Meteor._srp.Client.prototype.respondToChallenge = function (challenge) { + var self = this; + + // shorthand + var N = self.params.N; + var g = self.params.g; + var k = self.params.k; + var H = self.params.hash; + + // XXX check for missing / bad parameters. + self.identity = challenge.identity; + self.salt = challenge.salt; + self.Bstr = challenge.B; + self.B = new Meteor._srp.BigInteger(self.Bstr, 16); + + if (self.B.mod(N) === 0) + throw new Error("Server sent invalid key: B mod N == 0."); + + var u = new Meteor._srp.BigInteger(H(self.Astr + self.Bstr), 16); + var x = new Meteor._srp.BigInteger( + H(self.salt + H(self.identity + ":" + self.password)), 16); + + var kgx = k.multiply(g.modPow(x, N)); + var aux = self.a.add(u.multiply(x)); + var S = self.B.subtract(kgx).modPow(aux, N); + var M = H(self.Astr + self.Bstr + S.toString(16)); + var HAMK = H(self.Astr + M + S.toString(16)); + + self.S = S; + self.HAMK = HAMK; + + return { + M: M + }; + }; + + + /** + * Verify server's confirmation message. + * + * confirmation is an object with + * - HAMK: server's proof of password. + * + * returns true or false. + */ + Meteor._srp.Client.prototype.verifyConfirmation = function (confirmation) { + var self = this; + + return (self.HAMK && (confirmation.HAMK === self.HAMK)); + }; + + + + /////// PUBLIC SERVER + + + /** + * Generate a new SRP server object. Password is the plaintext password. + * + * options is optional and can include: + * - b: server's private ephemeral value. String or + * BigInteger. Normally, this is picked randomly, but it can be + * passed in for testing. + * - SRP parameters (see _defaults and paramsFromOptions below) + */ + Meteor._srp.Server = function (verifier, options) { + var self = this; + self.params = paramsFromOptions(options); + self.verifier = verifier; + + // shorthand + var N = self.params.N; + var g = self.params.g; + var k = self.params.k; + var v = new Meteor._srp.BigInteger(self.verifier.verifier, 16); + + // construct public and private keys. + var b, B; + if (options && options.b) { + if (typeof options.b === "string") + b = new Meteor._srp.BigInteger(options.b, 16); + else if (options.b instanceof Meteor._srp.BigInteger) + b = options.b; + else + throw new Error("Invalid parameter: b"); + + B = k.multiply(v).add(g.modPow(b, N)).mod(N); + + if (B.mod(N) === 0) + throw new Error("Invalid parameter: b: B mod N == 0."); + + } else { + while (!B || B.mod(N) === 0) { + b = randInt(); + B = k.multiply(v).add(g.modPow(b, N)).mod(N); + } + } + + self.b = b; + self.B = B; + self.Bstr = B.toString(16); + + }; + + + /** + * Issue a challenge to the client. + * + * Takes a request from the client containing: + * - A: hex encoded int. + * + * Returns a challenge with: + * - B: server public ephemeral key. hex encoded integer. + * - identity: user's identity (SRP username). + * - salt: user's salt. + * + * Throws an error if issued a bad request. + */ + Meteor._srp.Server.prototype.issueChallenge = function (request) { + var self = this; + + // XXX check for missing / bad parameters. + self.Astr = request.A; + self.A = new Meteor._srp.BigInteger(self.Astr, 16); + + if (self.A.mod(self.params.N) === 0) + throw new Error("Client sent invalid key: A mod N == 0."); + + // shorthand + var N = self.params.N; + var H = self.params.hash; + + // Compute M and HAMK in advance. Don't send to client yet. + var u = new Meteor._srp.BigInteger(H(self.Astr + self.Bstr), 16); + var v = new Meteor._srp.BigInteger(self.verifier.verifier, 16); + var avu = self.A.multiply(v.modPow(u, N)); + self.S = avu.modPow(self.b, N); + self.M = H(self.Astr + self.Bstr + self.S.toString(16)); + self.HAMK = H(self.Astr + self.M + self.S.toString(16)); + + return { + identity: self.verifier.identity, + salt: self.verifier.salt, + B: self.Bstr + }; + }; + + + /** + * Verify a response from the client and return confirmation. + * + * Takes a challenge response from the client containing: + * - M: client proof of password. hex encoded int. + * + * Returns a confirmation if the client's proof is good: + * - HAMK: server proof of password. hex encoded integer. + * OR null if the client's proof doesn't match. + */ + Meteor._srp.Server.prototype.verifyResponse = function (response) { + var self = this; + + if (response.M !== self.M) + return null; + + return { + HAMK: self.HAMK + }; + }; + + + + /////// INTERNAL + + /** + * Default parameter values for SRP. + * + */ + Meteor._srp._defaults = { + hash: function (x) { return Meteor._srp.SHA256(x).toLowerCase(); }, + N: new Meteor._srp.BigInteger("EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD15DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3", 16), + g: new Meteor._srp.BigInteger("2") + }; + Meteor._srp._defaults.k = new Meteor._srp.BigInteger( + Meteor._srp._defaults.hash( + Meteor._srp._defaults.N.toString(16) + + Meteor._srp._defaults.g.toString(16)), + 16); + + /** + * Process an options hash to create SRP parameters. + * + * Options can include: + * - hash: Function. Defaults to SHA256. + * - N: String or BigInteger. Defaults to 1024 bit value from RFC 5054 + * - g: String or BigInteger. Defaults to 2. + * - k: String or BigInteger. Defaults to hash(N, g) + */ + var paramsFromOptions = function (options) { + if (!options) // fast path + return Meteor._srp._defaults; + + var ret = _.extend({}, Meteor._srp._defaults); + + _.each(['N', 'g', 'k'], function (p) { + if (options[p]) { + if (typeof options[p] === "string") + ret[p] = new Meteor._srp.BigInteger(options[p], 16); + else if (options[p] instanceof Meteor._srp.BigInteger) + ret[p] = options[p]; + else + throw new Error("Invalid parameter: " + p); + } + }); + + if (options.hash) + ret.hash = function (x) { return options.hash(x).toLowerCase(); }; + + if (!options.k && (options.N || options.g || options.hash)) { + ret.k = ret.hash(ret.N.toString(16) + ret.g.toString(16)); + } + + return ret; + }; + + + var randInt = function () { + // XXX XXX need a better implementation! + return new Meteor._srp.BigInteger(Meteor.uuid().replace(/-/g, ''), 16); + }; + + + +})(); diff --git a/packages/srp/srp_tests.js b/packages/srp/srp_tests.js new file mode 100644 index 0000000000..df032a0a5a --- /dev/null +++ b/packages/srp/srp_tests.js @@ -0,0 +1,119 @@ +(function() { + + Tinytest.add("srp - good exchange", function(test) { + var password = 'hi there!'; + var verifier = Meteor._srp.generateVerifier(password); + + var C = new Meteor._srp.Client(password); + var S = new Meteor._srp.Server(verifier); + + var request = C.startExchange(); + var challenge = S.issueChallenge(request); + var response = C.respondToChallenge(challenge); + var confirmation = S.verifyResponse(response); + + test.isTrue(confirmation); + test.isTrue(C.verifyConfirmation(confirmation)); + + }); + + Tinytest.add("srp - bad exchange", function(test) { + var verifier = Meteor._srp.generateVerifier('one password'); + + var C = new Meteor._srp.Client('another password'); + var S = new Meteor._srp.Server(verifier); + + var request = C.startExchange(); + var challenge = S.issueChallenge(request); + var response = C.respondToChallenge(challenge); + var confirmation = S.verifyResponse(response); + + test.isFalse(confirmation); + }); + + + Tinytest.add("srp - fixed values", function(test) { + // Test exact values during the exchange. We have to be very careful + // about changing the SRP code, because changes could render + // people's existing user database unusable. This test is + // intentionally brittle to catch change that could affect the + // validity of user passwords. + + var identity = "b73d9af9-4e74-4ce0-879c-484828b08436"; + var salt = "85f8b9d3-744a-487d-8982-a50e4c9f552a"; + var password = "95109251-3d8a-4777-bdec-44ffe8d86dfb"; + var a = "dc99c646fa4cb7c24314bb6f4ca2d391297acd0dacb0430a13bbf1e37dcf8071"; + var b = "cf878e00c9f2b6aa48a10f66df9706e64fef2ca399f396d65f5b0a27cb8ae237"; + + var verifier = Meteor._srp.generateVerifier( + password, {identity: identity, salt: salt}); + + var C = new Meteor._srp.Client(password, {a: a}); + var S = new Meteor._srp.Server(verifier, {b: b}); + + var request = C.startExchange(); + test.equal(request.A, "8a75aa61471a92d4c3b5d53698c910af5ef013c42799876c40612d1d5e0dc41d01f669bc022fadcd8a704030483401a1b86b8670191bd9dfb1fb506dd11c688b2f08e9946756263954db2040c1df1894af7af5f839c9215bb445268439157e65e8f100469d575d5d0458e19e8bd4dd4ea2c0b30b1b3f4f39264de4ec596e0bb7"); + + var challenge = S.issueChallenge(request); + test.equal(challenge.B, "77ab0a40ef428aa2fa2bc257c905f352c7f75fbcfdb8761393c9dc0f730bbb0270ba9f837545b410c955c3f761494b329ad23c6efdec7e63509e538c2f68a3526e072550a11dac46017718362205e0c698b5bed67d6ff475aa92c191ca169f865c81a1a577373c449b98df720c7b7ff50536f9919d781e698025fd7164932ba7"); + + var response = C.respondToChallenge(challenge); + test.equal(response.M, "8705d31bb61497279adf44eef6c167dcb7e03aa7a42102c1ea7e73025fbd4cd9"); + + var confirmation = S.verifyResponse(response); + test.equal(confirmation.HAMK, "07a0f200392fa9a084db7acc2021fbc174bfb36956b46835cc12506b68b27bba"); + + test.isTrue(C.verifyConfirmation(confirmation)); + }); + + + Tinytest.add("srp - options", function(test) { + // test that all options are respected. + // + // Note, all test strings here should be hex, because the 'hash' + // function needs to output numbers. + + var baseOptions = { + hash: function (x) { return x; }, + N: 'b', + g: '2', + k: '1' + }; + var verifierOptions = _.extend({ + identity: 'a', + salt: 'b' + }, baseOptions); + var clientOptions = _.extend({ + a: "2" + }, baseOptions); + var serverOptions = _.extend({ + b: "2" + }, baseOptions); + + var verifier = Meteor._srp.generateVerifier('c', verifierOptions);; + + test.equal(verifier.identity, 'a'); + test.equal(verifier.salt, 'b'); + test.equal(verifier.verifier, '3'); + + var C = new Meteor._srp.Client('c', clientOptions); + var S = new Meteor._srp.Server(verifier, serverOptions); + + var request = C.startExchange(); + test.equal(request.A, '4'); + + var challenge = S.issueChallenge(request); + test.equal(challenge.identity, 'a'); + test.equal(challenge.salt, 'b'); + test.equal(challenge.B, '7'); + + var response = C.respondToChallenge(challenge); + test.equal(response.M, '471'); + + var confirmation = S.verifyResponse(response); + test.isTrue(confirmation); + test.equal(confirmation.HAMK, '44711'); + + }); + +})(); diff --git a/packages/stream/stream_client.js b/packages/stream/stream_client.js index 0f70866b75..43aa9b57d7 100644 --- a/packages/stream/stream_client.js +++ b/packages/stream/stream_client.js @@ -8,7 +8,7 @@ Meteor._Stream = function (url) { self.event_callbacks = {}; // name -> [callback] self.server_id = null; self.sent_update_available = false; - self.force_fail = false; + self.force_fail = false; // for debugging. //// Constants @@ -47,11 +47,10 @@ Meteor._Stream = function (url) { retry_count: 0 }; - self.status_listeners = {}; // context.id -> context + self.status_listeners = (Meteor.deps && new Meteor.deps._ContextSet); self.status_changed = function () { - _.each(self.status_listeners, function (context) { - context.invalidate(); - }); + if (self.status_listeners) + self.status_listeners.invalidateAll(); }; //// Retry logic @@ -103,8 +102,6 @@ _.extend(Meteor._Stream.prototype, { if (!self.event_callbacks[name]) self.event_callbacks[name] = []; self.event_callbacks[name].push(callback); - if (self.current_status.connected) - self.socket.on(name, callback); }, // data is a utf8 string. Data sent while not connected is dropped on @@ -120,21 +117,22 @@ _.extend(Meteor._Stream.prototype, { // Get current status. Reactive. status: function () { var self = this; - var context = Meteor.deps && Meteor.deps.Context.current; - if (context && !(context.id in self.status_listeners)) { - self.status_listeners[context.id] = context; - context.onInvalidate(function () { - delete self.status_listeners[context.id]; - }); - } + if (self.status_listeners) + self.status_listeners.addCurrentContext(); return self.current_status; }, // Trigger a reconnect. - reconnect: function () { + reconnect: function (options) { var self = this; - if (self.current_status.connected) - return; // already connected. noop. + + if (self.current_status.connected) { + if (options && options._force) { + // force reconnect. + self._disconnected(); + } // else, noop. + return; + } // if we're mid-connection, stop it. if (self.current_status.status === "connecting") { diff --git a/packages/stream/stream_server.js b/packages/stream/stream_server.js index 21b5dfc7b4..784059d5a1 100644 --- a/packages/stream/stream_server.js +++ b/packages/stream/stream_server.js @@ -21,6 +21,13 @@ Meteor._StreamServer = function () { // this is the default, but we code it explicitly because we depend // on it in stream_client:HEARTBEAT_TIMEOUT heartbeat_delay: 25000, + // The default disconnect_delay is 5 seconds, but if the server ends up CPU + // bound for that much time, SockJS might not notice that the user has + // reconnected because the timer (of disconnect_delay ms) can fire before + // SockJS processes the new connection. Eventually we'll fix this by not + // 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}); self.server.installHandlers(__meteor_bootstrap__.app); diff --git a/packages/stream/stream_tests.js b/packages/stream/stream_tests.js index a08919b0e3..5a1d439557 100644 --- a/packages/stream/stream_tests.js +++ b/packages/stream/stream_tests.js @@ -9,6 +9,30 @@ Tinytest.add("stream - status", function (test) { test.equal(status.retryTime, status.retry_time); }); +testAsyncMulti("stream - reconnect", [ + function (test, expect) { + var callback = _.once(expect(function() { + var status; + status = Meteor.status(); + test.equal(status.status, "connected"); + + Meteor.reconnect(); + status = Meteor.status(); + test.equal(status.status, "connected"); + + Meteor.reconnect({_force: true}); + status = Meteor.status(); + test.equal(status.status, "waiting"); + })); + + if (Meteor.status().status !== "connected") + Meteor.default_connection.stream.on('reset', callback); + else + callback(); + } +]); + + Tinytest.add("stream - sockjs urls are computed correctly", function(test) { var testHasSockjsUrl = function(raw, expectedSockjsUrl) { test.equal(Meteor._Stream._toSockjsUrl(raw), expectedSockjsUrl); diff --git a/packages/test-helpers/onscreendiv.js b/packages/test-helpers/onscreendiv.js index 47446c6f71..b594627e2b 100644 --- a/packages/test-helpers/onscreendiv.js +++ b/packages/test-helpers/onscreendiv.js @@ -50,11 +50,9 @@ OnscreenDiv.prototype.kill = function() { if (self.div.parentNode) self.div.parentNode.removeChild(self.div); - var cx = new Meteor.deps.Context; - cx.onInvalidate(function() { + Meteor._atFlush(function () { Spark.finalize(self.div); }); - cx.invalidate(); }; // remove the DIV from the document diff --git a/packages/test-helpers/reactivevar.js b/packages/test-helpers/reactivevar.js index 2c3c4e7dde..0c789663ef 100644 --- a/packages/test-helpers/reactivevar.js +++ b/packages/test-helpers/reactivevar.js @@ -19,19 +19,11 @@ var ReactiveVar = function(initialValue) { this._value = (typeof initialValue === "undefined" ? null : initialValue); - this._deps = {}; + this._deps = new Meteor.deps._ContextSet; }; ReactiveVar.prototype.get = function() { - var context = Meteor.deps.Context.current; - if (context && !(context.id in this._deps)) { - this._deps[context.id] = context; - var self = this; - context.onInvalidate(function() { - delete self._deps[context.id]; - }); - } - + this._deps.addCurrentContext(); return this._value; }; @@ -43,11 +35,9 @@ ReactiveVar.prototype.set = function(newValue) { this._value = newValue; - for(var id in this._deps) - this._deps[id].invalidate(); - + this._deps.invalidateAll(); }; ReactiveVar.prototype.numListeners = function() { - return _.keys(this._deps).length; + return _.keys(this._deps._contextsById).length; }; diff --git a/packages/test-helpers/wrappedfrag.js b/packages/test-helpers/wrappedfrag.js index ad5f56e896..f019bf2fea 100644 --- a/packages/test-helpers/wrappedfrag.js +++ b/packages/test-helpers/wrappedfrag.js @@ -31,13 +31,11 @@ WrappedFrag.prototype.release = function() { // decrement frag's GC protection reference count // Clean up on flush, if hits 0. Wait to decrement // so no one else cleans it up first. - var cx = new Meteor.deps.Context; - cx.onInvalidate(function() { + Meteor._atFlush(function () { if (! --frag["_protect"]) { Spark.finalize(frag); } }); - cx.invalidate(); return this; }; diff --git a/packages/test-in-browser/driver.js b/packages/test-in-browser/driver.js index eb37196ca9..0a50601299 100644 --- a/packages/test-in-browser/driver.js +++ b/packages/test-in-browser/driver.js @@ -12,19 +12,12 @@ Meteor.startup(function () { }); Template.test_table.running = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } - + resultDeps.addCurrentContext(); return running; }; Template.test_table.passed = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } + resultDeps.addCurrentContext(); // walk whole tree to look for failed tests var walk = function (groups) { @@ -53,10 +46,7 @@ Template.test_table.passed = function() { Template.test_table.total_test_time = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } + resultDeps.addCurrentContext(); // walk whole tree to get all tests var walk = function (groups) { @@ -79,11 +69,7 @@ Template.test_table.total_test_time = function() { Template.test_table.data = function() { - var cx = Meteor.deps.Context.current; - if (cx) { - resultDeps.push(cx); - } - + resultDeps.addCurrentContext(); return resultTree; }; @@ -185,13 +171,10 @@ Template.event.is_debuggable = function() { var resultTree = []; -var resultDeps = []; +var resultDeps = new Meteor.deps._ContextSet; var _resultsChanged = function() { - _.each(resultDeps, function(cx) { - cx.invalidate(); - }); - resultDeps.length = 0; + resultDeps.invalidateAll(); }; var _testTime = function(t) { diff --git a/packages/tinytest/model.js b/packages/tinytest/model.js index c2590a2ae5..b24d5b0f68 100644 --- a/packages/tinytest/model.js +++ b/packages/tinytest/model.js @@ -1 +1,6 @@ Meteor._ServerTestResults = new Meteor.Collection('tinytest_results'); +Meteor._ServerTestResults.allow({ + insert: function() { return true; }, + update: function() { return true; }, + remove: function() { return true; } +}); diff --git a/packages/tinytest/tinytest_server.js b/packages/tinytest/tinytest_server.js index 3e0d1c3987..3718f95aab 100644 --- a/packages/tinytest/tinytest_server.js +++ b/packages/tinytest/tinytest_server.js @@ -1,5 +1,7 @@ Meteor.startup(function () { Meteor._ServerTestResults.remove(); + // Index is definitely not unique and doesn't need to be sparse. + Meteor._ServerTestResults._ensureIndex('run_id'); }); Meteor.publish('tinytest/results', function (run_id) { diff --git a/packages/underscore/package.js b/packages/underscore/package.js index 71b0a526d7..00b473b891 100644 --- a/packages/underscore/package.js +++ b/packages/underscore/package.js @@ -1,5 +1,5 @@ Package.describe({ - summary: "Collection of small helper functions (map, each, bind, ...)" + summary: "Collection of small helper functions: _.map, _.each, ..." }); Package.on_use(function (api, where) {