Merge branch 'release-0.5.0'

This commit is contained in:
David Glasser
2012-10-17 15:14:53 -07:00
181 changed files with 20250 additions and 914 deletions

3
.gitignore vendored
View File

@@ -3,5 +3,6 @@
/dev_bundle
/dev_bundle*.tar.gz
/dist
\#*#
\#*\#
.\#*
.idea

View File

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

View File

@@ -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 <ORGANIZATION> 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 <i@izs.me> (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 <http://unlicense.org/>
----------
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

View File

@@ -1,4 +1,4 @@
meteor (0.4.2-1) unstable; urgency=low
meteor (0.5.0-1) unstable; urgency=low
* Automated debian build.

View File

@@ -5,7 +5,7 @@
## example.
URLBASE="https://d3sqy0vbqsdhku.cloudfront.net"
VERSION="0.4.2"
VERSION="0.5.0"
PKGVERSION="${VERSION}-1"
UNAME=`uname`

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
exports.CURRENT_VERSION = "0.4.2";
exports.CURRENT_VERSION = "0.5.0";
var fs = require("fs");
var http = require("http");

View File

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

View File

@@ -4,4 +4,5 @@
# but you can also edit it by hand.
autopublish
insecure
preserve-inputs

View File

@@ -3,10 +3,10 @@
<h1 id="api">The Meteor API</h1>
Your Javascript code can run in two environments: the <i>client</i>
(browser), and the <i>server</i> (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*.
<h2 id="core"><span>Meteor Core</span></h2>
@@ -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 <i>set</i> and <i>unset</i>
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.
<!-- discuss complete -->
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 &mdash; 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.
<h2 id="connections"><span>Server connections</span></h2>
@@ -305,9 +343,9 @@ the server. The return value is an object with the following fields:
<dt><span class="name">status</span>
<span class="type">String</span></dt>
<dd>Describes the current reconnection status. The possible
values are <code>connected</code> (the connection is up and
running), <code>connecting</code> (disconnected and trying to open a
new connection), and <code>waiting</code> (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).</dd>
<dt><span class="name">retryCount</span>
@@ -319,15 +357,15 @@ the server. The return value is an object with the following fields:
<span class="type">Number or undefined</span></dt>
<dd>The estimated time of the next reconnection attempt. To turn this
into an interval until the next reconnection, use
<code>retryTime - (new Date()).getTime()</code>. This key will
be set only when <code>status</code> is <code>waiting</code>.
`retryTime - (new Date()).getTime()`. This key will
be set only when `status` is `waiting`.
</dd>
</dl>
Instead of using callbacks to notify you on changes, this is
a <a href="#reactivity">reactive</a> data source. You can use it in a
<a href="#templates">template</a> or <a href="#meteor_deps">invalidation
context</a> 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 <a href="#modifiers">modifier
documentation</a>). 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`
&mdash; 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:
<dl class="callbacks">
{{#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}}
</dl>
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`.
<h2 id="meteor_collection_cursor"><span>Cursors</span></h2>
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 <a href="http://www.mongodb.org/display/DOCS/Advanced+Queries" target="_blank">complete documentation.</a>
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 <a href="http://www.mongodb.org/display/DOCS/Updating#Updating-ModifierOperations" target="_blank">full list of modifiers.</a>
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)`.
<h2 id="accounts_api"><span>Accounts</span></h2>
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.
<a id="requestpermissions" name="requestpermissions" />
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` &mdash; with permissions
to access the service's API &mdash; 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: <http://developers.facebook.com/docs/authentication/permissions/>
- GitHub: <http://developer.github.com/v3/oauth/#scopes>
- Google: <https://developers.google.com/accounts/docs/OAuth2Login#scopeparameter>
- 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:
<!-- XXX replace d6 with _.random once we have underscore 1.4.2 -->
// 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;
});
<h2 id="accounts_passwords"><span>Passwords</span></h2>
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`
&mdash; 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@example.com>";
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;
};
<h2 id="templates_api"><span>Templates</span></h2>
A template that you declare as `<{{! }}template name="foo"> ... </{{!
@@ -941,7 +1549,9 @@ Otherwise, the HTML is unadorned and static.
{{> 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 "<em>eventtype selector</em>"}}
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 "<em>event1, event2</em>"}}
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}}
</dl>
@@ -1440,7 +2051,6 @@ sub-template.
{{/api_box_inline}}
<h2 id="timers"><span>Timers</span></h2>
Meteor uses global environment variables
@@ -1468,7 +2078,7 @@ dependencies &mdash; 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 &mdash; like all reactive
sources &mdash; 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.
<h2 id="meteor_http"><span>Meteor.http</span></h2>
`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
<a href="http://www.mailgun.com/" target="_blank">Mailgun</a>) 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.

View File

@@ -145,6 +145,14 @@ Template.api.subscription_onStop = {
]
};
Template.api.subscription_userId = {
id: "publish_userId",
name: "<i>this</i>.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: "<i>this</i>.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: "<i>this</i>.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: "<i>this</i>.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: "<em>collection</em>.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: "<em>collection</em>.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: "<em>collection</em>.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: "<em>collection</em>.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: "<em>collection</em>.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: "<em>cursor</em>.count()",
@@ -422,62 +543,6 @@ Template.api.cursor_observe = {
]
};
Template.api.insert = {
id: "insert",
name: "<em>collection</em>.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: "<em>collection</em>.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: "<em>collection</em>.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 &mdash; 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.loginWith<i>ExternalService</i>([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 ('<a href="http://tools.ietf.org/html/rfc5322" target="_blank">RFC5322'
+ '</a> ' + descr);
return '[RFC5322](http://tools.ietf.org/html/rfc5322) ' + descr;
};
Template.api.email_send = {

View File

@@ -34,7 +34,7 @@ Run `meteor help run` to see the full list of options.
<h3 id="meteorcreate">meteor create <i>name</i></h3>
Create a new Meteor project. Makes a subdirectory named <i>name</i>
Create a new Meteor project. Makes a subdirectory named *name*
and copies in the template app. You can pass an absolute or relative
path.

View File

@@ -21,12 +21,12 @@ when writing those apps.
<h2 id="structuringyourapp">Structuring your application</h2>
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.
<template name="data">
{{#better_markdown}}
<h2 id="data">Data</h2>
<h2 id="dataandsecurity">Data and security</h2>
Meteor makes writing distributed client code as simple as talking to a
local database. It's a clean and simple approach, much easier than
building individual RPC endpoints, slow roundtrips to the server, and
orchestrating invalidation messages.
local database. It's a clean, simple, and secure approach that obviates
the need to implement individual RPC endpoints, manually cache data on
the client to avoid slow roundtrips to the server, and carefully
orchestrate invalidation messages to every client as data changes.
Every Meteor client includes an in-memory database cache. Each client's
cache holds valid copies of some set of documents that are stored in a
server's master database. When a matching document in that database
changes, Meteor automatically synchronizes that change to every
subscribed client.
In Meteor, the client and server share the same database API. The same
exact application code &mdash; like validators and computed properties &mdash; can
often run in both places. But while code running on the server has
direct access to the database, code running on the client does *not*.
This distinction is the basis for Meteor's data security model.
To manage the client caches, your server code <b>publishes</b> sets of
documents, and your client code <b>subscribes</b> to those sets. For
example, if you are building a chat system, the server might publish two
sets: the set of all rooms, and the set of all messages in a given room.
Each client would subscribe to the master set of available rooms and the
set of messages in the currently-selected room. Once subscribed, the
client uses its cache as a fast local database, dramatically simplifying
your client model code.
{{#note}}
By default, a new Meteor app includes the `autopublish` and `insecure`
packages, which together mimic the effect of each client having full
read/write access to the server's database. These are useful
prototyping tools, but typically not appropriate for production
applications. When you're ready, just remove the packages.
{{/note}}
Meteor's protocol for distributing document updates is database
agnostic. By default, Meteor applications use the
familiar <a target="_blank"
href="http://www.mongodb.org/display/DOCS/Manual">MongoDB API</a>:
servers store documents in MongoDB collections, and clients cache those
documents in a client-side cache that implements the same Mongo API for
queries and updates.
Every Meteor client includes an in-memory database cache. To manage the
client cache, the server *publishes* sets of JSON documents, and the
client *subscribes* to those sets. As documents in a set change, the
server patches each client's cache.
// server: publish all room documents, and per-room messages
Meteor.publish("chatrooms");
Each document set is defined by a publish function on the server. The
publish function runs each time a new client subscribes to a document
set. The data in a document set can come from anywhere, but the common
case is to publish a database query.
// server: publish all room documents
Meteor.publish("all-rooms", function () {
return Rooms.find(); // everything
);
// server: publish all messages for a given room
Meteor.publish("messages", function (roomId) {
return Messages.find({room: roomId});
});
// client: subscribe to all rooms, and messages in the first room
Meteor.subscribe("chatrooms");
Meteor.subscribe("messages", Chatrooms.find()[0]._id);
// server: publish the set of parties the logged-in user can see.
Meteor.publish("parties", function () {
return Parties.find({$or: [{"public": true},
{invited: this.userId},
{owner: this.userId}]});
});
Document modifications also propagate automatically. Modification
instructions like `insert`, `remove`, and `update` are executed
immediately on the client's cached data. <i>At the same time</i>, the
client sends that instruction up to the server, which executes the same
change against the master database. Usually the client and server
agree, but should they differ (permissions checking or overlapping with
another client, for example), the server's result will publish back down
to the client. And of course, all other clients with a matching
subscription automatically receive an updated document.
Publish functions can provide different results to each client. In the
last example, a logged in user can only see `Party` documents that
are public, that the user owns, or that the user has been invited to.
// create new message, executes on both client and server.
Messages.insert({room: 2413, text: "hello!"});
Once subscribed, the client uses its cache as a fast local database,
dramatically simplifying client code. Reads never require a costly
round trip to the server. And they're limited to the contents of the
cache: a query for every document in a collection on a client will only
return documents the server is publishing to that client.
Putting it all together, these techniques accomplish <i>latency
compensation</i>. Clients hold a fresh copy of the data they need, and
never need to wait for a roundtrip to the server. And when clients
// client: start a parties subscription
Meteor.subscribe("parties");
// client: return array of Parties this client can read
return Parties.find().fetch(); // synchronous!
Sophisticated clients can turn subscriptions on and off to control how
much data is kept in the cache and manage network traffic. When a
subscription is turned off, all its documents are removed from the cache
unless the same document is also provided by another active
subscription.
When the client *changes* one or more documents, it sends a message to
the server requesting the change. The server checks the proposed change
against a set of allow/deny rules you write as JavaScript functions.
The server only accepts the change if all the rules pass.
// server: don't allow client to insert a party
Parties.allow({
insert: function (userId, party) {
return false;
}
});
// client: this will fail
var party = { ... };
Parties.insert(party);
If the server accepts the change, it applies the change to the database
and automatically propagates the change to other clients subscribed to
the affected documents. If not, the update fails, the server's database
remains untouched, and no other client sees the update.
Meteor has a cute trick, though. When a client issues a write to the
server, it also updates its local cache immediately, without waiting for
the server's response. This means the screen will redraw right away.
If the server accepted the update &mdash; what ought to happen most of the
time in a properly behaving client &mdash; then the client got a jump on the
change and didn't have to wait for the round trip to update its own
screen. If the server rejects the change, Meteor patches up the
client's cache with the server's result.
Putting it all together, these techniques accomplish latency
compensation. Clients hold a fresh copy of the data they need, and
never need to wait for a roundtrip to the server. And when clients
modify data, those modifications can run locally without waiting for the
confirmation from the server, while still giving the server final say
over the requested change.
You can substitute another database for MongoDB by providing a
server-side database driver and/or a client-side cache that implements
an alternative API. The `mongo-livedata` is a good starting point for
such a project.
Meteor includes [Meteor Accounts](#accounts_api), a state-of-the-art
authentication system. It features secure password login using the [Secure
Remote Password
protocol](http://en.wikipedia.org/wiki/Secure_Remote_Password_protocol), and
integration with external services including Facebook, GitHub, Google, Twitter,
and Weibo. Meteor Accounts defines a [`Meteor.users`](#meteor_users) collection
where developers can store application-specific user data.
Meteor also includes pre-built forms for common tasks like login, signup,
password change, and password reset emails. You can add [Accounts
UI](#accountsui) to your app with just one line of code. The `accounts-ui` smart
package even provides a configuration wizard that walks you through the steps to
set up the external login services you're using in your app.
{{#note}}
A pre-release version of Meteor includes a user login system and a set of tools
for securing read and write access to data based on the logged-in user. For more
information, see the
<a target="_blank"
href="https://github.com/meteor/meteor/wiki/Getting-Started-with-Auth">Getting
Started with Auth</a> wiki page.
The current release of Meteor supports MongoDB, the popular document
database, and the examples in this section use the
[MongoDB API](http://www.mongodb.org/display/DOCS/Manual). Future
releases will include support for other databases.
{{/note}}
{{/better_markdown}}
@@ -165,11 +220,10 @@ Started with Auth</a> wiki page.
<h2 id="reactivity">Reactivity</h2>
Meteor embraces the concept of
<a target="_blank" href="http://en.wikipedia.org/wiki/Reactive_programming">
reactive programming</a>. This means that you can write your code in a
simple imperative style, and the result will be automatically
recalculated whenever data changes that your code depends on.
Meteor embraces the concept of [reactive
programming](http://en.wikipedia.org/wiki/Reactive_programming). This means that
you can write your code in a simple imperative style, and the result will be
automatically recalculated whenever data changes that your code depends on.
Meteor.autosubscribe(function () {
Meteor.subscribe("messages", Session.get("currentRoomId"));
@@ -190,28 +244,34 @@ providers like `Session`, on the other hand, make note of
the context they are called from and what data was requested, and they
are prepared to send an invalidation signal when the data changes.
This simple pattern has wide applicability. Above, the programmer is
saved from writing unsubscribe/resubscribe calls and making sure they
are called at the right time. In general, Meteor can eliminate whole
classes of data propagation code which would otherwise clog up your
application with error-prone logic.
This simple pattern (reactive context + reactive data source) has wide
applicability. Above, the programmer is saved from writing
unsubscribe/resubscribe calls and making sure they are called at the
right time. In general, Meteor can eliminate whole classes of data
propagation code which would otherwise clog up your application with
error-prone logic.
These Meteor functions run your code in a reactive context:
* [Templates](#templates)
* [`Meteor.render`](#meteor_render) and [`Meteor.renderList`](#meteor_renderlist)
* [`Meteor.autosubscribe`](#meteor_autosubscribe)
* [Templates](#templates)
* [`Meteor.autorun`](#meteor_autorun)
And the reactive data sources that can trigger changes are:
* <a href="#session">Session</a> variables
* Database queries on <a href="#find">Collections</a>
* <a href="#meteor_status">`Meteor.status`</a>
* [`Session`](#session) variables
* Database queries on [Collections](#find)
* [`Meteor.status`](#meteor_status)
* [`Meteor.user`](#meteor_user)
* [`Meteor.userId`](#meteor_userid)
* [`Meteor.userLoaded`](#meteor_userloaded)
Meteor's <a href="https://github.com/meteor/meteor/blob/master/packages/deps/deps.js" target="_blank">implementation</a>
of reactivity is short and sweet, about 50 lines of code. You can
hook into it yourself to add new reactive contexts or data sources,
using the <a href="#meteor_deps">Meteor.deps</a> module.
Meteor's
[implementation](https://github.com/meteor/meteor/blob/master/packages/deps/deps.js)
of reactivity is short and sweet, about 50 lines of code. You can hook into it
yourself to add new reactive contexts or data sources, using the
[`Meteor.deps`](#meteor_deps) module.
{{/better_markdown}}
</template>
@@ -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 &mdash; 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 <a href="http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/"
target="_blank">zombie templates</a> 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 &mdash; 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 `<input>`
@@ -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 <a href="http://www.handlebarsjs.com/">Handlebars documentation</a>
and <a href="https://github.com/meteor/meteor/wiki/Handlebars">Meteor
Handlebars extensions</a>.
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.
<template name="packages_concept">
{{#better_markdown}}
<h2 id="smartpackages">Smart Packages</h2>
<h2 id="smartpackages">Smart packages</h2>
Meteor has an unusually powerful package system. All of the
functionality you've read about so far is implemented as standard
@@ -455,30 +513,28 @@ JavaScript programs. They can inject code into the client or the
server, or hook new functions into the bundler, so they can extend the
Meteor environment in arbitrary ways. Some examples of packages are:
* The <a href="#coffeescript">coffeescript</a> package extends the
bundler, automatically compiling any <code>.coffee</code> files in
* The [coffeescript](#coffeescript) package extends the
bundler, automatically compiling any `.coffee` files in
your tree. Once added, you can write your application in CoffeeScript
instead of JavaScript.
* The <a href="#jquery">jQuery</a>
and <a href="#backbone">Backbone</a> packages are examples of using
* The [jQuery](#jquery) and [Backbone](#backbone) packages are examples of using
Meteor to prepackage client JavaScript libraries. You could get
the same result by copying the JavaScript files into your tree, but
it's faster to add a package.
* The <a href="#underscore">underscore</a> package extends both the
* The [underscore](#underscore) package extends both the
client and server environments. Many of the core Meteor features,
including Minimongo, the Session object, and reactive Handlebars
templates, are implemented as internal packages automatically
included with every Meteor application.
You can see a list of available packages
with <a href="#meteorlist">meteor list</a>,
add packages to your project
with <a href="#meteoradd">meteor add</a>, and remove them
with <a href="#meteorremove">meteor remove</a>.
with [`meteor list`](#meteorlist), add packages to your project
with [`meteor add`](#meteoradd), and remove them
with [`meteor remove`](#meteorremove).
See the <a href="#packages">Package List</a> section for a description
See the [Package List](#packages) section for a description
of the existing packages.
{{#warning}}
@@ -489,6 +545,10 @@ make your own packages just yet. Coming soon.
{{/better_markdown}}
</template>
<template name="deploying">
{{#better_markdown}}
@@ -500,8 +560,8 @@ HTML, and CSS.
<h3 class="nosection">Running on Meteor's infrastructure</h3>
The easiest way to deploy your application is to use <b>meteor
deploy</b>. We provide it because it's what, personally, we've always
The easiest way to deploy your application is to use `meteor
deploy`. We provide it because it's what, personally, we've always
wanted: an easy way to take an app idea, flesh it out over a weekend,
and put it out there for the world to use, with nothing getting in the
way of creativity.
@@ -515,7 +575,7 @@ update, Meteor will preserve the existing data and just refresh the
code.
You can also deploy to your own domain. Just set up the hostname you
want to use as a CNAME to <code>origin.meteor.com</code>,
want to use as a CNAME to `origin.meteor.com`,
then deploy to that name.
$ meteor deploy www.myapp.com
@@ -548,7 +608,7 @@ package requires a `MAIL_URL` environment variable).
For now, bundles will only run on the platform that the bundle was
created on. To run on a different platform, you'll need to rebuild
the native packages included in the bundle. To do that, make sure you
have <code>npm</code> available, and run the following:
have `npm` available, and run the following:
$ cd bundle/server/node_modules
$ rm -r fibers

View File

@@ -265,6 +265,10 @@ dl.callbacks dt .name, dl.methods dt .name {
font-size: 1.1em;
}
dl.callbacks {
margin-left: 1.5em;
}
#main dd p {
margin-top: 0.5em;
}
@@ -432,11 +436,11 @@ pre {
@media (min-width: 1024px) {
/* ipad landscape and desktop */
#main {
width: 600px;
margin-left: 310px; /* nav width + padding */
width: 610px;
margin-left: 330px; /* nav width + padding */
}
#nav {
width: 250px;
width: 270px;
}
.github-ribbon {
display: block;

View File

@@ -11,7 +11,7 @@
</div>
<div id="main">
<div id="top"></div>
<h1 class="main-headline">Meteor 0.4.2</h1>
<h1 class="main-headline">Meteor 0.5.0</h1>
{{> introduction }}
{{> concepts }}
{{> api }}

View File

@@ -1,4 +1,4 @@
METEOR_VERSION = "0.4.2";
METEOR_VERSION = "0.5.0";
Meteor.startup(function () {
// XXX this is broken by the new multi-page layout. Also, it was
@@ -48,18 +48,29 @@ Meteor.startup(function () {
}
});
window.onhashchange = function () {
scrollToSection(location.hash);
};
var scrollToSection = function (section) {
ignore_waypoints = true;
Session.set("section", section.substr(1));
scroller().animate({
scrollTop: $(section).offset().top
}, 500, 'swing', function () {
window.location.hash = section;
ignore_waypoints = false;
});
};
$('#main, #nav').delegate("a[href^='#']", 'click', function (evt) {
evt.preventDefault();
var sel = $(this).attr('href');
ignore_waypoints = true;
Session.set("section", sel.substr(1));
scroller().animate({
scrollTop: $(sel).offset().top
}, 500, 'swing', function () {
window.location.hash = sel;
ignore_waypoints = false;
});
scrollToSection(sel);
});
// Make external links open in a new tab.
$('a:not([href^="#"])').attr('target', '_blank');
});
var toc = [
@@ -70,11 +81,11 @@ var toc = [
],
"Concepts", [
"Structuring your app",
"Data",
"Data and security",
"Reactivity",
"Live HTML",
"Templates",
"Smart Packages",
"Smart packages",
"Deploying"
],
@@ -88,6 +99,7 @@ var toc = [
"Publish and subscribe", [
"Meteor.publish", [
{instance: "this", name: "userId", id: "publish_userId"},
{instance: "this", name: "set", id: "publish_set"},
{instance: "this", name: "unset", id: "publish_unset"},
{instance: "this", name: "complete", id: "publish_complete"},
@@ -101,6 +113,8 @@ var toc = [
{name: "Methods", id: "methods_header"}, [
"Meteor.methods", [
{instance: "this", name: "userId", id: "method_userId"},
{instance: "this", name: "setUserId", id: "method_setUserId"},
{instance: "this", name: "isSimulation", id: "method_issimulation"},
{instance: "this", name: "unblock", id: "method_unblock"}
],
@@ -121,7 +135,9 @@ var toc = [
{instance: "collection", name: "findOne"},
{instance: "collection", name: "insert"},
{instance: "collection", name: "update"},
{instance: "collection", name: "remove"}
{instance: "collection", name: "remove"},
{instance: "collection", name: "allow"},
{instance: "collection", name: "deny"}
],
"Meteor.Collection.Cursor", [
@@ -135,7 +151,8 @@ var toc = [
{type: "spacer"},
{name: "Selectors", style: "noncode"},
{name: "Modifiers", style: "noncode"},
{name: "Sort specifiers", style: "noncode"}
{name: "Sort specifiers", style: "noncode"},
{name: "Field specifiers", style: "noncode"}
],
"Session", [
@@ -144,6 +161,41 @@ var toc = [
"Session.equals"
],
{name: "Accounts", id: "accounts_api"}, [
"Meteor.user",
"Meteor.userId",
"Meteor.users",
"Meteor.userLoaded",
"Meteor.logout",
"Meteor.loginWithPassword",
{name: "Meteor.loginWithFacebook", id: "meteor_loginwithexternalservice"},
{name: "Meteor.loginWithGithub", id: "meteor_loginwithexternalservice"},
{name: "Meteor.loginWithGoogle", id: "meteor_loginwithexternalservice"},
{name: "Meteor.loginWithTwitter", id: "meteor_loginwithexternalservice"},
{name: "Meteor.loginWithWeibo", id: "meteor_loginwithexternalservice"},
{type: "spacer"},
"Accounts.config",
"Accounts.ui.config",
"Accounts.validateNewUser",
"Accounts.onCreateUser"
],
{name: "Passwords", id: "accounts_passwords"}, [
"Accounts.createUser",
"Accounts.changePassword",
"Accounts.forgotPassword",
"Accounts.resetPassword",
"Accounts.setPassword",
"Accounts.verifyEmail",
{type: "spacer"},
"Accounts.sendResetPasswordEmail",
"Accounts.sendEnrollmentEmail",
"Accounts.sendVerificationEmail",
"Accounts.emailTemplates"
],
{name: "Templates", id: "templates_api"}, [
{prefix: "Template", instance: "myTemplate", id: "template_call"}, [
{name: "rendered", id: "template_rendered"},
@@ -182,6 +234,7 @@ var toc = [
{instance: "context", name: "invalidate"}
],
{name: "Meteor.deps.Context.current", id: "current"},
"Meteor.autorun",
"Meteor.flush"
// ],
@@ -206,10 +259,12 @@ var toc = [
],
"Packages", [ [
"accounts-ui",
"amplify",
"backbone",
"bootstrap",
"coffeescript",
"d3",
"force-ssl",
"jquery",
"less",

View File

@@ -32,14 +32,15 @@ invalidations to clients.
Meteor is a work in progress, but we hope it shows the direction of
our thinking. We'd love to hear your feedback.
&mdash; Geoff, Nick, Matt, and David
&mdash; Geoff, Nick, Matt, David, Avital, and David
## Quick start!
<!-- change colors on these. $ and command output in grey, rest in
white -->
The following works on all <a target="_blank" href="https://github.com/meteor/meteor/wiki/Supported-Platforms">supported platforms</a>.
The following works on all [supported
platforms](https://github.com/meteor/meteor/wiki/Supported-Platforms).
Install Meteor:
@@ -106,17 +107,16 @@ clean, classically beautiful APIs.
<h2 id="resources">Developer Resources</h2>
<!-- https://github.com/blog/273-github-ribbons -->
<a href="http://github.com/meteor/meteor" target="_blank"><img class="github-ribbon visible-desktop" style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_red_aa0000.png" alt="Fork me on GitHub"></a>
<a href="http://github.com/meteor/meteor"><img class="github-ribbon visible-desktop" style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_red_aa0000.png" alt="Fork me on GitHub"></a>
If anything in Meteor catches your interest, we hope you'll get involved
with the project!
<dl class="involved">
<dt><span>Stack Overflow</span></dt>
<dd>The best place to ask (and answer!) technical questions is
on <a href="http://stackoverflow.com/questions/tagged/meteor">Stack
Overflow</a>. Be sure to add the <code>meteor</code> tag to your
question.
<dd>The best place to ask (and answer!) technical questions is on [Stack
Overflow](http://stackoverflow.com/questions/tagged/meteor). Be sure to add
the <code>meteor</code> tag to your question.
</dd>
<dt><span>Mailing lists</span></dt>
@@ -134,7 +134,7 @@ developers hang out here and will answer your questions whenever they
can.</dd>
<dt><span>GitHub</span></dt>
<dd>The code is on <a target="_blank" href="http://github.com/meteor/meteor">GitHub</a>. The best way to send a patch is with a GitHub pull request, and the best way to file a bug is in the GitHub bug tracker.</dd>
<dd>The code is on <a href="http://github.com/meteor/meteor">GitHub</a>. The best way to send a patch is with a GitHub pull request, and the best way to file a bug is in the GitHub bug tracker.</dd>
</dl>
{{/markdown}}

View File

@@ -16,10 +16,12 @@ and removed with:
$ meteor remove <package_name>
{{> pkg_accounts_ui}}
{{> pkg_amplify}}
{{> pkg_backbone}}
{{> pkg_bootstrap}}
{{> pkg_coffeescript}}
{{> pkg_d3}}
{{> pkg_force_ssl}}
{{> pkg_jquery}}
{{> pkg_less}}

View File

@@ -0,0 +1,32 @@
<template name="pkg_accounts_ui">
{{#better_markdown}}
## `accounts-ui`
A turn-key user interface for Meteor Accounts.
To add Accounts and a set of login controls to an application add the
`accounts-ui` package and at least one login provider package:
`accounts-password`, `accounts-facebook`, `accounts-github`,
`accounts-google`, `accounts-twitter`, or `accounts-weibo`.
Then simply add the `{{dstache}}loginButtons}}` helper to an HTML file. This
will place a login widget on the page. If there is only one provider configured
and it is an external service, this will add a login/logout button. If you use
`accounts-password` or use multiple external login services, this will add
a "Sign in" link which opens a dropdown menu with login options. To make the
login dropdown right aligned (useful if you position the login buttons
at the right edge of the screen), use `{{dstache}}loginButtons align=right}}`.
To configure the behavior of `{{dstache}}loginButtons}}`, use
[`Accounts.ui.config`](#accounts_ui_config).
`accounts-ui` also includes modal popup dialogs to handle links from
[`sendResetPasswordEmail`](#accounts_sendresetpasswordemail), [`sendVerificationEmail`](#accounts_sendverificationemail),
and [`sendEnrollmentEmail`](#accounts_sendenrollmentemail). These
do not have be manually placed in HTML: they are automatically activated
when the URLs are loaded.
{{/better_markdown}}
</template>

View File

@@ -10,6 +10,7 @@ components, and several useful utility functions.
Amplify defines a global namespace `amplify` on the client only. It does
not run on the server.
For more information about Amplify, see <a href="http://amplifyjs.com/">http://amplifyjs.com/</a>.
For more information about Amplify, see <http://amplifyjs.com/>.
{{/better_markdown}}
</template>

View File

@@ -8,7 +8,7 @@ functionality, it also provides an API for HTML5 pushState and
client-side URL routing.
For more information about Backbone, see
<a href="http://documentcloud.github.com/backbone/">http://documentcloud.github.com/backbone/</a>.
<http://documentcloud.github.com/backbone/>.
{{/better_markdown}}
</template>

View File

@@ -9,7 +9,7 @@ interactions including typography, forms, buttons, tables, grids, and
navigation.
For more information about Bootstrap, see
<a href="http://twitter.github.com/bootstrap/">http://twitter.github.com/bootstrap/</a>.
<http://twitter.github.com/bootstrap/>.
{{/better_markdown}}
</template>

View File

@@ -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 <a href="http://jashkenas.github.com/coffee-script/">http://jashkenas.github.com/coffee-script/</a>
for more information.
See <http://jashkenas.github.com/coffee-script/> for more information.
{{/better_markdown}}
</template>

View File

@@ -0,0 +1,17 @@
<template name="pkg_d3">
{{#better_markdown}}
## `d3`
[D3.js](http://d3js.org/) is a JavaScript library for manipulating
documents based on data. D3 helps you bring data to life using HTML, SVG
and CSS. D3's emphasis on web standards gives you the full capabilities
of modern browsers without tying yourself to a proprietary framework,
combining powerful visualization components and a data-driven approach
to DOM manipulation.
The `d3` package adds the D3 library to the client JavaScript
bundle. It has no effect on the server.
{{/better_markdown}}
</template>

View File

@@ -3,7 +3,7 @@
## `jquery`
<a href="http://jquery.com/">jQuery</a> 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.

View File

@@ -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 <a href="http://lesscss.org/">http://lesscss.org/</a> for
documentation of the LESS language.
See <http://lesscss.org/> for documentation of the LESS language.
{{/better_markdown}}
</template>

View File

@@ -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 <a href="https://github.com/visionmedia/sass.js">https://github.com/visionmedia/sass.js</a>
for the JavaScript implementation of the Sass language
and <a href="http://sass-lang.com/">http://sass-lang.com/</a> for the
original project.
See <https://github.com/visionmedia/sass.js> for the JavaScript implementation
of the Sass language and <http://sass-lang.com/> for the original project.
{{#warning}}
The Sass JavaScript implementation used by Node is unmaintained and doesn't
implement the newest language syntax documented at <http://sass-lang.com/>. It
may be removed from a future version of Meteor; consider using [Less](#less) or
[Stylus](#stylus) instead.
{{/warning}}
{{/better_markdown}}
</template>

View File

@@ -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 <a target="_blank"
href="https://developers.google.com/webmasters/ajax-crawling/">AJAX
Crawling specification</a> 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 <a target="_blank"
href="http://phantomjs.org/">phantomjs</a>, 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 `<a href="/about">`) 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 <a target="_blank"
href="http://meteor.com/examples/todos">Todos example</a> for a
demonstration.
In order to have links between multiple pages on a site visible to spiders, apps
must use real links (eg `<a href="/about">`) 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` (<a target="_blank"
href="http://phantomjs.org/">http://phantomjs.org</a>) 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}}

View File

@@ -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 <a href="http://learnboost.github.com/stylus/">http://learnboost.github.com/stylus</a>
for documentation of the Stylus language,
and <a href="http://visionmedia.github.com/nib/">http://visionmedia.github.com/nib</a>
for documentation of the nib extensions.
See <http://learnboost.github.com/stylus> for documentation of the Stylus
language, and <http://visionmedia.github.com/nib> for documentation of the nib
extensions.
{{/better_markdown}}
</template>

View File

@@ -9,8 +9,8 @@ concise JavaScript in a functional style.
The `underscore` package defines the `_` namespace on both the client
and the server.
See <a href="http://documentcloud.github.com/underscore/">http://documentcloud.github.com/underscore/</a>
for underscore API documentation.
See <http://documentcloud.github.com/underscore/> for underscore API
documentation.
{{#warning}}
Currently, underscore is included in all projects, as the Meteor

View File

@@ -4,4 +4,5 @@
# but you can also edit it by hand.
autopublish
insecure
preserve-inputs

View File

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

View File

@@ -178,5 +178,6 @@ X={{x}}<br>
<input type="button" value="Add" class="add">
<input type="button" value="Remove" class="remove" {{{disabled}}}>
<input type="button" value="Scram" class="scram">
<input type="button" value="Clear" class="clear">
</div>
</template>

View File

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

1
examples/parties/.meteor/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
local

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,218 @@
<head>
<title>All Tomorrow's Parties</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{{> page}}
</body>
<template name="page">
{{#if showCreateDialog}}
{{> createDialog}}
{{/if}}
{{#if showInviteDialog}}
{{> inviteDialog}}
{{/if}}
<div class="container">
<div class="row">
<div class="span1"> </div>
<div class="span10">
<div class="header row">
<div class="span5">
<h3 style="margin-bottom: 0px">All Tomorrow's Parties</h3>
</div>
<div class="span5">
<div style="float: right">
{{loginButtons align="right"}}
</div>
</div>
</div>
<div class="row">
<div class="span6">
{{> map}}
{{#if currentUser}}
<div class="pagination-centered">
<em><small>Double click the map to post a party!</small></em>
</div>
{{/if}}
</div>
<div class="span4">
{{> details}}
</div>
</div>
</div>
<div class="span1"> </div>
</div>
</div>
</template>
<template name="map">
<div class="map">
{{#constant}}
<svg width="500" height="500">
<circle class="callout" cx=-100 cy=-100></circle>
<g class="circles"></g>
<g class="labels"></g>
</svg>
{{/constant}}
<div>
<small class="attribution muted">&copy;
<a href="http://www.openstreetmap.org/?lat=37.78212&lon=-122.40146&zoom=15&layers=M"
target="_blank">OpenStreetMap</a> contributors</small>
</div>
</div>
</template>
<template name="details">
<div class="details">
{{#if party}}
{{#with party}}
<h1>{{title}}</h1>
<div class="description">{{description}}</div>
{{> attendance}}
<div class="rsvp-buttons">
{{#if currentUser}}
<input type="button" value="I'm going!"
class="btn btn-small rsvp_yes {{maybeChosen "yes"}}">
<input type="button" value="Maybe"
class="btn btn-small rsvp_maybe {{maybeChosen "maybe"}}">
<input type="button" value="No"
class="btn btn-small rsvp_no {{maybeChosen "no"}}">
{{else}}
<i>Sign in to RSVP for this party.</i>
{{/if}}
<p><small>Posted by {{creatorName}}</small></p>
</div>
{{#if canRemove}}
<div class="alert alert-info"><small>
You posted this party and nobody is signed up to go, so if
you like, you could
<b><a href="#" class="remove">delete this listing</a></b>.
</small></div>
{{/if}}
{{/with}}
{{else}}
<h1 class="muted pagination-centered">
{{#if anyParties}}
Click a party to select it
{{else}}
Sign in and double click the map to post a party
{{/if}}
</h1>
{{/if}}
</div>
</template>
<template name="attendance">
<div class="attendance well well-small">
<div class="muted who"><b>Who</b></div>
{{#if public}}
<div>
<b>Everyone</b>
<span class="label label-inverse pull-right">Invited</span>
</div>
{{/if}}
{{#each rsvps}}
<div>
{{rsvpName}}
{{#if rsvpIs "yes"}}
<span class="label label-success pull-right">Going</span>
{{/if}}
{{#if rsvpIs "maybe"}}
<span class="label label-info pull-right">Maybe</span>
{{/if}}
{{#if rsvpIs "no"}}
<span class="label label pull-right">No</span>
{{/if}}
</div>
{{/each}}
{{#unless public}}
{{#each outstandingInvitations}}
<div>
{{invitationName}}
<span class="label label-inverse pull-right">Invited</span>
</div>
{{/each}}
{{/unless}}
{{#if nobody}}
<div>Nobody.</div>
{{/if}}
{{#if canInvite}}
<div class="invite">
<a href="#" class="btn btn-mini invite">Invite people</a>
</div>
{{/if}}
</div>
</template>
<template name="createDialog">
<div class="mask"> </div>
<div class="modal">
<div class="modal-header">
<button type="button" class="close cancel">&times;</button>
<h3>Add party</h3>
</div>
<div class="modal-body">
{{#if error}}
<div class="alert alert-error">{{error}}</div>
{{/if}}
<label>Title</label>
<input type="text" class="title span5">
<label>Description</label>
<textarea class="description span5"></textarea>
<label class="checkbox">
<input type="checkbox" class="private">
Private party &mdash; invitees only
</label>
</div>
<div class="modal-footer">
<a href="#" class="btn cancel">Cancel</a>
<a href="#" class="btn btn-primary save">Add party</a>
</div>
</div>
</template>
<template name="inviteDialog">
<div class="mask"> </div>
<div class="modal">
<div class="modal-header">
<button type="button" class="close done">&times;</button>
<h3>Invite people</h3>
</div>
<div class="modal-body">
{{#each uninvited}}
<div class="invite-row">
<a href="#" class="btn invite">Invite</a>
{{displayName}}
</div>
{{else}}
Everyone on the site has already been invited.
{{/each}}
</div>
<div class="modal-footer">
<a href="#" class="btn btn-primary done">Done</a>
</div>
</div>
</template>

162
examples/parties/model.js Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

View File

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

View File

@@ -8,3 +8,4 @@ backbone
spiderable
jquery
preserve-inputs
insecure

View File

@@ -98,5 +98,3 @@
{{/each}}
</div>
</template>

View File

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

View File

@@ -0,0 +1 @@
local

View File

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

View File

@@ -0,0 +1,123 @@
<head>
<title>accounts-ui-viewer</title>
</head>
<body>
{{> page}}
</body>
<template name="radio">
<span class="radio"><input id="{{key}}:{{value}}" {{maybeChecked}} type="radio" name="{{key}}" value="{{value}}" />{{! no whitespace}}<label for="{{key}}:{{value}}">{{label}}</label></span>
</template>
<template name="button">
<button>{{label}}</button>
</template>
<template name="page">
<div id="controlpane">
<div class="group">
<h3>Dropdown align edge:</h3>
{{radio "alignRight" "false" "Left"}}
{{radio "alignRight" "true" "Right"}}
</div>
<div class="group">
<h3>Positioning:</h3>
{{radio "positioning" "relative" "Relative"}}
{{radio "positioning" "absolute" "Absolute"}}
{{radio "positioning" "floatRight" "Float:right"}}
{{radio "positioning" "inline" "Inline"}}
</div>
<div class="group">
<h3>How many third-party services?</h3>
{{radio "numServices" "0" "0"}}
{{radio "numServices" "1" "1"}}
{{radio "numServices" "2" "2"}}
{{radio "numServices" "3" "3"}}
</div>
<div class="group">
<h3>Has password accounts?</h3>
{{radio "hasPasswords" "false" "No"}}
{{radio "hasPasswords" "true" "Yes"}}
</div>
<div class="group">
<h3>Password sign-up fields:</h3>
{{radio "signupFields" "EMAIL_ONLY" "Email"}}
{{radio "signupFields" "USERNAME_ONLY" "Username"}}
{{radio "signupFields" "USERNAME_AND_EMAIL" "Username & Email"}}
{{radio "signupFields" "USERNAME_AND_OPTIONAL_EMAIL" "Username & Optional Email"}}
</div>
<div class="group">
<h3>Fake-Configure:</h3>
{{button "fakeConfig" "facebook" "Facebook"}}
{{button "fakeConfig" "github" "GitHub"}}
{{button "fakeConfig" "google" "Google"}}
</div>
<div class="group">
<h3>Show Configure Dialog:</h3>
{{button "showConfig" "facebook" "Facebook"}}
{{button "showConfig" "github" "GitHub"}}
{{button "showConfig" "google" "Google"}}
</div>
<div class="group">
<h3>Unconfigure:</h3>
{{button "unconfig" "facebook" "Facebook"}}
{{button "unconfig" "github" "GitHub"}}
{{button "unconfig" "google" "Google"}}
</div>
<div class="group">
<h3>Messages:</h3>
{{button "messages" "error" "Error"}}
{{button "messages" "info" "Info"}}
{{button "messages" "clear" "Clear"}}
</div>
<div class="group">
<h3>Signing in/out</h3>
{{button "sign" "in" "Fake sign-in"}}
{{button "sign" "out" "Sign out"}}
</div>
<div class="group">
<h3>Logged-out Views</h3>
{{button "lov" "signIn" "Sign In"}}
{{button "lov" "createAccount" "Create Account"}}
{{button "lov" "forgotPassword" "Forgot Password"}}
</div>
<div class="group">
<h3>Logged-in Views</h3>
{{button "liv" "accountButtons" "Account Buttons"}}
{{button "liv" "changePassword" "Change Password"}}
{{button "liv" "messageOnly" "Message Only"}}
</div>
<div class="group">
<h3>Other Modals</h3>
{{button "modals" "resetPassword" "Reset Password"}}
{{button "modals" "enrollAccount" "Enroll Account"}}
{{button "modals" "justVerifiedEmail" "Verified Email"}}
</div>
<div class="group">
<h3>Spinner (must be logged in)</h3>
{{radio "fakeUserNotLoaded" "false" "Off"}}
{{radio "fakeUserNotLoaded" "true" "Pretend userLoaded=false"}}
</div>
<div class="group">
<h3>Background Color</h3>
{{radio "bgcolor" "white" "White"}}
{{radio "bgcolor" "black" "Black"}}
{{radio "bgcolor" "red" "Red"}}
</div>
</div>
{{#with settings}}
<div id="previewpane" class="{{settingsClass}}" style="background:{{bgcolor}}">
<div id="preview-wrapper" class="{{outerClass}}">
{{#if match "positioning:inline"}}
Here is a place to sign in, yay!
{{/if}}
{{loginButtons align=dropdownAlign}}
{{#if match "positioning:inline"}}
Isn't that great?
{{/if}}
</div>
<div id="pos-indicator"></div>
</div>
{{/with}}
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
if (!Accounts.facebook) {
Accounts.facebook = {};
}

View File

@@ -0,0 +1,19 @@
<template name="configureLoginServiceDialogForFacebook">
<p>
First, you'll need to register your app on Facebook. Follow these steps:
</p>
<ol>
<li>
Visit <a href="https://developers.facebook.com/apps" target="_blank">https://developers.facebook.com/apps</a>
</li>
<li>
Create New App (Only a name is required.)
</li>
<li>
Under "Select how your app integrates with Facebook", expand "Website with Facebook Login".
</li>
<li>
Set Site URL to: <span class="url">{{siteUrl}}</span>
</li>
</ol>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
if (!Accounts.github) {
Accounts.github = {};
}

View File

@@ -0,0 +1,16 @@
<template name="configureLoginServiceDialogForGithub">
<p>
First, you'll need to get a Github Client ID. Follow these steps:
</p>
<ol>
<li>
Visit <a href="https://github.com/settings/applications/new" target="blank">https://github.com/settings/applications/new</a>
</li>
<li>
Set Main URL to to: <span class="url">{{siteUrl}}</span>
</li>
<li>
Set Callback URL to: <span class="url">{{siteUrl}}_oauth/github?close</span>
</li>
</ol>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
if (!Accounts.google) {
Accounts.google = {};
}

View File

@@ -0,0 +1,30 @@
<template name="configureLoginServiceDialogForGoogle">
<p>
First, you'll need to get a Google Client ID. Follow these steps:
</p>
<ol>
<li>
Visit <a href="https://code.google.com/apis/console/" target="blank">https://code.google.com/apis/console/</a>
</li>
<li>
If necessary, "Create Project"
<li>
Open the "API Access" tab
</li>
<li>
Create another Client ID
</li>
<li>
Click "Edit settings&hellip;"
</li>
<li>
Set Authorized Redirect URIs to: <span class="url">{{siteUrl}}_oauth/google?close</span>
</li>
<li>
Set Authorized Javascript Origins to: <span class="url">{{siteUrl}}</span>
</li>
<li>
Create client ID
</li>
</ol>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
Accounts.oauth = {};

View File

@@ -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/<service name>?<action>"
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 =
'<html><head><script>window.close()</script></head></html>';
res.end(content, 'utf-8');
};
})();

View File

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

View File

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

View File

@@ -0,0 +1 @@
Accounts.oauth1 = {};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
Accounts.oauth2 = {};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
Accounts.emailTemplates = {
from: "Meteor Accounts <no-reply@meteor.com>",
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";
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
Accounts.password = {};

View File

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

View File

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

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