commit 4f475c7697722e946e39e42f38f3dd03a95d8765 Author: matrix.org Date: Tue Aug 12 15:10:52 2014 +0100 Reference Matrix Home Server diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..f433b1a53f --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000..73e0eff6e4 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include docs * +recursive-include tests *.py +recursive-include synapse/persistence/schema *.sql diff --git a/README.rst b/README.rst new file mode 100644 index 0000000000..a34e8bf2ac --- /dev/null +++ b/README.rst @@ -0,0 +1,126 @@ +Installation +============ + +[TODO(kegan): I also needed libffi-dev, which I don't think is included in build-essential.] + +First, the dependencies need to be installed. Start by installing 'python-dev' +and the various tools of the compiler toolchain: + + Installing prerequisites on ubuntu:: + + $ sudo apt-get install build-essential python-dev + + Installing prerequisites on Mac OS X:: + + $ xcode-select --install + +The homeserver has a number of external dependencies, that are easiest +to install by making setup.py do so, in --user mode:: + + $ python setup.py develop --user + +This will run a process of downloading and installing into your +user's .local/lib directory all of the required dependencies that are +missing. + +Once this is done, you may wish to run the homeserver's unit tests, to +check that everything is installed as it should be:: + + $ python setup.py test + +This should end with a 'PASSED' result:: + + Ran 143 tests in 0.601s + + PASSED (successes=143) + + +Running The Home Server +======================= + +In order for other home servers to send messages to your server, they will need +to know its host name. You have two choices here, which will influence the form +of your user IDs: + + 1) Use the machine's own hostname as available on public DNS in the form of its + A or AAAA records. This is easier to set up initially, perhaps for testing, + but lacks the flexibility of SRV. + + 2) Set up a SRV record for your domain name. This requires you create a SRV + record in DNS, but gives the flexibility to run the server on your own + choice of TCP port, on a machine that might not be the same name as the + domain name. + +For the first form, simply pass the required hostname (of the machine) as the +--host parameter:: + + $ python synapse/app/homeserver.py --host machine.my.domain.name + +For the second form, first create your SRV record and publish it in DNS. This +needs to be named _matrix._tcp.YOURDOMAIN, and point at at least one hostname +and port where the server is running. (At the current time we only support a +single server, but we may at some future point support multiple servers, for +backup failover or load-balancing purposes). The DNS record would then look +something like:: + + _matrix._tcp IN SRV 10 0 8448 machine.my.domain.name. + +At this point, you should then run the homeserver with the hostname of this +SRV record, as that is the name other machines will expect it to have:: + + $ python synapse/app/homeserver.py --host my.domain.name --port 8448 + +You may additionally want to pass one or more "-v" options, in order to +increase the verbosity of logging output; at least for initial testing. + + +Running The Web Client +====================== + +At the present time, the web client is not directly served by the homeserver's +HTTP server. To serve this in a form the web browser can reach, arrange for the +'webclient' sub-directory to be made available by any sort of HTTP server that +can serve static files. For example, python's SimpleHTTPServer will suffice:: + + $ cd webclient + $ python -m SimpleHTTPServer + +You can now point your browser at http://localhost:8000/ to find the client. + +If this is the first time you have used the client from that browser (it uses +HTML5 local storage to remember its config), you will need to log in to your +account. If you don't yet have an account, because you've just started the +homeserver for the first time, then you'll need to register one. + +Registering A New Account +------------------------- + +Your new user name will be formed partly from the hostname your server is +running as, and partly from a localpart you specify when you create the +account. Your name will take the form of:: + + @localpart:my.domain.here + (pronounced "at localpart on my dot domain dot here") + +Specify your desired localpart in the topmost box of the "Register for an +account" form, and click the "Register" button. + +Logging In To An Existing Account +--------------------------------- + +[[TODO(paul): It seems the current web client still requests an access_token - + I suspect this part will need updating before we can point people at how to + perform e.g. user+password or 3PID authenticated login]] + + +Building Documentation +====================== + +Before building documentation install spinx and sphinxcontrib-napoleon:: + + $ pip install sphinx + $ pip install sphinxcontrib-napoleon + +Building documentation:: + + $ python setup.py build_sphinx diff --git a/cmdclient/console.py b/cmdclient/console.py new file mode 100755 index 0000000000..595ce429cd --- /dev/null +++ b/cmdclient/console.py @@ -0,0 +1,699 @@ +#! /usr/bin/env python +""" Starts a synapse client console. """ + +from twisted.internet import reactor, defer, threads +from http import TwistedHttpClient + +import argparse +import cmd +import getpass +import json +import shlex +import sys +import time +import urllib +import urlparse + +import nacl.signing +import nacl.encoding + +from syutil.crypto.jsonsign import verify_signed_json, SignatureVerifyException + +CONFIG_JSON = "cmdclient_config.json" + +TRUSTED_ID_SERVERS = [ + 'localhost:8001' +] + +class SynapseCmd(cmd.Cmd): + + """Basic synapse command-line processor. + + This processes commands from the user and calls the relevant HTTP methods. + """ + + def __init__(self, http_client, server_url, identity_server_url, username, token): + cmd.Cmd.__init__(self) + self.http_client = http_client + self.http_client.verbose = True + self.config = { + "url": server_url, + "identityServerUrl": identity_server_url, + "user": username, + "token": token, + "verbose": "on", + "complete_usernames": "on", + "send_delivery_receipts": "on" + } + self.path_prefix = "/matrix/client/api/v1" + self.event_stream_token = "START" + self.prompt = ">>> " + + def do_EOF(self, line): # allows CTRL+D quitting + return True + + def emptyline(self): + pass # else it repeats the previous command + + def _usr(self): + return self.config["user"] + + def _tok(self): + return self.config["token"] + + def _url(self): + return self.config["url"] + self.path_prefix + + def _identityServerUrl(self): + return self.config["identityServerUrl"] + + def _is_on(self, config_name): + if config_name in self.config: + return self.config[config_name] == "on" + return False + + def _domain(self): + return self.config["user"].split(":")[1] + + def do_config(self, line): + """ Show the config for this client: "config" + Edit a key value mapping: "config key value" e.g. "config token 1234" + Config variables: + user: The username to auth with. + token: The access token to auth with. + url: The url of the server. + verbose: [on|off] The verbosity of requests/responses. + complete_usernames: [on|off] Auto complete partial usernames by + assuming they are on the same homeserver as you. + E.g. name >> @name:yourhost + send_delivery_receipts: [on|off] Automatically send receipts to + messages when performing a 'stream' command. + Additional key/values can be added and can be substituted into requests + by using $. E.g. 'config roomid room1' then 'raw get /rooms/$roomid'. + """ + if len(line) == 0: + print json.dumps(self.config, indent=4) + return + + try: + args = self._parse(line, ["key", "val"], force_keys=True) + + # make sure restricted config values are checked + config_rules = [ # key, valid_values + ("verbose", ["on", "off"]), + ("complete_usernames", ["on", "off"]), + ("send_delivery_receipts", ["on", "off"]) + ] + for key, valid_vals in config_rules: + if key == args["key"] and args["val"] not in valid_vals: + print "%s value must be one of %s" % (args["key"], + valid_vals) + return + + # toggle the http client verbosity + if args["key"] == "verbose": + self.http_client.verbose = "on" == args["val"] + + # assign the new config + self.config[args["key"]] = args["val"] + print json.dumps(self.config, indent=4) + + save_config(self.config) + except Exception as e: + print e + + def do_register(self, line): + """Registers for a new account: "register " + : The desired user ID + : Do not automatically clobber config values. + """ + args = self._parse(line, ["userid", "noupdate"]) + path = "/register" + + password = None + pwd = None + pwd2 = "_" + while pwd != pwd2: + pwd = getpass.getpass("(Optional) Type a password for this user: ") + if len(pwd) == 0: + print "Not using a password for this user." + break + pwd2 = getpass.getpass("Retype the password: ") + if pwd != pwd2: + print "Password mismatch." + else: + password = pwd + + body = {} + if "userid" in args: + body["user_id"] = args["userid"] + if password: + body["password"] = password + + reactor.callFromThread(self._do_register, "POST", path, body, + "noupdate" not in args) + + @defer.inlineCallbacks + def _do_register(self, method, path, data, update_config): + url = self._url() + path + json_res = yield self.http_client.do_request(method, url, data=data) + print json.dumps(json_res, indent=4) + if update_config and "user_id" in json_res: + self.config["user"] = json_res["user_id"] + self.config["token"] = json_res["access_token"] + save_config(self.config) + + def do_login(self, line): + """Login as a specific user: "login @bob:localhost" + You MAY be prompted for a password, or instructed to visit a URL. + """ + try: + args = self._parse(line, ["user_id"], force_keys=True) + can_login = threads.blockingCallFromThread( + reactor, + self._check_can_login) + if can_login: + p = getpass.getpass("Enter your password: ") + user = args["user_id"] + if self._is_on("complete_usernames") and not user.startswith("@"): + user = "@" + user + ":" + self._domain() + + reactor.callFromThread(self._do_login, user, p) + print " got %s " % p + except Exception as e: + print e + + @defer.inlineCallbacks + def _do_login(self, user, password): + path = "/login" + data = { + "user": user, + "password": password, + "type": "m.login.password" + } + url = self._url() + path + json_res = yield self.http_client.do_request("POST", url, data=data) + print json_res + + if "access_token" in json_res: + self.config["user"] = user + self.config["token"] = json_res["access_token"] + save_config(self.config) + print "Login successful." + + @defer.inlineCallbacks + def _check_can_login(self): + path = "/login" + # ALWAYS check that the home server can handle the login request before + # submitting! + url = self._url() + path + json_res = yield self.http_client.do_request("GET", url) + print json_res + + if ("type" not in json_res or "m.login.password" != json_res["type"] or + "stages" in json_res): + fallback_url = self._url() + "/login/fallback" + print ("Unable to login via the command line client. Please visit " + "%s to login." % fallback_url) + defer.returnValue(False) + defer.returnValue(True) + + def do_3pidrequest(self, line): + """Requests the association of a third party identifier + The medium of the identifer (currently only 'email') +
The address of the identifer (ie. the email address) + """ + args = self._parse(line, ['medium', 'address']) + + if not args['medium'] == 'email': + print "Only email is supported currently" + return + + postArgs = {'email': args['address'], 'clientSecret': '____'} + + reactor.callFromThread(self._do_3pidrequest, postArgs) + + @defer.inlineCallbacks + def _do_3pidrequest(self, args): + url = self._identityServerUrl()+"/matrix/identity/api/v1/validate/email/requestToken" + + json_res = yield self.http_client.do_request("POST", url, data=urllib.urlencode(args), jsonreq=False, + headers={'Content-Type': ['application/x-www-form-urlencoded']}) + print json_res + if 'tokenId' in json_res: + print "Token ID %s sent" % (json_res['tokenId']) + + def do_3pidvalidate(self, line): + """Validate and associate a third party ID + The medium of the identifer (currently only 'email') + The identifier iof the token given in 3pidrequest + The token sent to your third party identifier address + """ + args = self._parse(line, ['medium', 'tokenId', 'token']) + + if not args['medium'] == 'email': + print "Only email is supported currently" + return + + postArgs = { 'tokenId' : args['tokenId'], 'token' : args['token'] } + postArgs['mxId'] = self.config["user"] + + reactor.callFromThread(self._do_3pidvalidate, postArgs) + + @defer.inlineCallbacks + def _do_3pidvalidate(self, args): + url = self._identityServerUrl()+"/matrix/identity/api/v1/validate/email/submitToken" + + json_res = yield self.http_client.do_request("POST", url, data=urllib.urlencode(args), jsonreq=False, + headers={'Content-Type': ['application/x-www-form-urlencoded']}) + print json_res + + def do_join(self, line): + """Joins a room: "join " """ + try: + args = self._parse(line, ["roomid"], force_keys=True) + self._do_membership_change(args["roomid"], "join", self._usr()) + except Exception as e: + print e + + def do_joinalias(self, line): + try: + args = self._parse(line, ["roomname"], force_keys=True) + path = "/join/%s" % urllib.quote(args["roomname"]) + reactor.callFromThread(self._run_and_pprint, "PUT", path, {}) + except Exception as e: + print e + + def do_topic(self, line): + """"topic [set|get] []" + Set the topic for a room: topic set + Get the topic for a room: topic get + """ + try: + args = self._parse(line, ["action", "roomid", "topic"]) + if "action" not in args or "roomid" not in args: + print "Must specify set|get and a room ID." + return + if args["action"].lower() not in ["set", "get"]: + print "Must specify set|get, not %s" % args["action"] + return + + path = "/rooms/%s/topic" % urllib.quote(args["roomid"]) + + if args["action"].lower() == "set": + if "topic" not in args: + print "Must specify a new topic." + return + body = { + "topic": args["topic"] + } + reactor.callFromThread(self._run_and_pprint, "PUT", path, body) + elif args["action"].lower() == "get": + reactor.callFromThread(self._run_and_pprint, "GET", path) + except Exception as e: + print e + + def do_invite(self, line): + """Invite a user to a room: "invite " """ + try: + args = self._parse(line, ["userid", "roomid"], force_keys=True) + + user_id = args["userid"] + + reactor.callFromThread(self._do_invite, args["roomid"], user_id) + except Exception as e: + print e + + @defer.inlineCallbacks + def _do_invite(self, roomid, userstring): + if (not userstring.startswith('@') and + self._is_on("complete_usernames")): + url = self._identityServerUrl()+"/matrix/identity/api/v1/lookup" + + json_res = yield self.http_client.do_request("GET", url, qparams={'medium':'email','address':userstring}) + + mxid = None + + if 'mxid' in json_res and 'signatures' in json_res: + url = self._identityServerUrl()+"/matrix/identity/api/v1/pubkey/ed25519" + + pubKey = None + pubKeyObj = yield self.http_client.do_request("GET", url) + if 'public_key' in pubKeyObj: + pubKey = nacl.signing.VerifyKey(pubKeyObj['public_key'], encoder=nacl.encoding.HexEncoder) + else: + print "No public key found in pubkey response!" + + sigValid = False + + if pubKey: + for signame in json_res['signatures']: + if signame not in TRUSTED_ID_SERVERS: + print "Ignoring signature from untrusted server %s" % (signame) + else: + try: + verify_signed_json(json_res, signame, pubKey) + sigValid = True + print "Mapping %s -> %s correctly signed by %s" % (userstring, json_res['mxid'], signame) + break + except SignatureVerifyException as e: + print "Invalid signature from %s" % (signame) + print e + + if sigValid: + print "Resolved 3pid %s to %s" % (userstring, json_res['mxid']) + mxid = json_res['mxid'] + else: + print "Got association for %s but couldn't verify signature" % (userstring) + + if not mxid: + mxid = "@" + userstring + ":" + self._domain() + + self._do_membership_change(roomid, "invite", mxid) + + def do_leave(self, line): + """Leaves a room: "leave " """ + try: + args = self._parse(line, ["roomid"], force_keys=True) + path = ("/rooms/%s/members/%s/state" % + (urllib.quote(args["roomid"]), self._usr())) + reactor.callFromThread(self._run_and_pprint, "DELETE", path) + except Exception as e: + print e + + def do_send(self, line): + """Sends a message. "send " """ + args = self._parse(line, ["roomid", "body"]) + msg_id = "m%s" % int(time.time()) + path = "/rooms/%s/messages/%s/%s" % (urllib.quote(args["roomid"]), + self._usr(), + msg_id) + body_json = { + "msgtype": "m.text", + "body": args["body"] + } + reactor.callFromThread(self._run_and_pprint, "PUT", path, body_json) + + def do_list(self, line): + """List data about a room. + "list members [query]" - List all the members in this room. + "list messages [query]" - List all the messages in this room. + + Where [query] will be directly applied as query parameters, allowing + you to use the pagination API. E.g. the last 3 messages in this room: + "list messages from=END&to=START&limit=3" + """ + args = self._parse(line, ["type", "roomid", "qp"]) + if not "type" in args or not "roomid" in args: + print "Must specify type and room ID." + return + if args["type"] not in ["members", "messages"]: + print "Unrecognised type: %s" % args["type"] + return + room_id = args["roomid"] + path = "/rooms/%s/%s/list" % (urllib.quote(room_id), args["type"]) + + qp = {"access_token": self._tok()} + if "qp" in args: + for key_value_str in args["qp"].split("&"): + try: + key_value = key_value_str.split("=") + qp[key_value[0]] = key_value[1] + except: + print "Bad query param: %s" % key_value + return + + reactor.callFromThread(self._run_and_pprint, "GET", path, + query_params=qp) + + def do_create(self, line): + """Creates a room. + "create [public|private] " - Create a room with the + specified visibility. + "create " - Create a room with default visibility. + "create [public|private]" - Create a room with specified visibility. + "create" - Create a room with default visibility. + """ + args = self._parse(line, ["vis", "roomname"]) + # fixup args depending on which were set + body = {} + if "vis" in args and args["vis"] in ["public", "private"]: + body["visibility"] = args["vis"] + + if "roomname" in args: + room_name = args["roomname"] + body["room_alias_name"] = room_name + elif "vis" in args and args["vis"] not in ["public", "private"]: + room_name = args["vis"] + body["room_alias_name"] = room_name + + reactor.callFromThread(self._run_and_pprint, "POST", "/rooms", body) + + def do_raw(self, line): + """Directly send a JSON object: "raw " + : Required. One of "PUT", "GET", "POST", "xPUT", "xGET", + "xPOST". Methods with 'x' prefixed will not automatically append the + access token. + : Required. E.g. "/events" + : Optional. E.g. "{ "msgtype":"custom.text", "body":"abc123"}" + """ + args = self._parse(line, ["method", "path", "data"]) + # sanity check + if "method" not in args or "path" not in args: + print "Must specify path and method." + return + + args["method"] = args["method"].upper() + valid_methods = ["PUT", "GET", "POST", "DELETE", + "XPUT", "XGET", "XPOST", "XDELETE"] + if args["method"] not in valid_methods: + print "Unsupported method: %s" % args["method"] + return + + if "data" not in args: + args["data"] = None + else: + try: + args["data"] = json.loads(args["data"]) + except Exception as e: + print "Data is not valid JSON. %s" % e + return + + qp = {"access_token": self._tok()} + if args["method"].startswith("X"): + qp = {} # remove access token + args["method"] = args["method"][1:] # snip the X + else: + # append any query params the user has set + try: + parsed_url = urlparse.urlparse(args["path"]) + qp.update(urlparse.parse_qs(parsed_url.query)) + args["path"] = parsed_url.path + except: + pass + + reactor.callFromThread(self._run_and_pprint, args["method"], + args["path"], + args["data"], + query_params=qp) + + def do_stream(self, line): + """Stream data from the server: "stream " """ + args = self._parse(line, ["timeout"]) + timeout = 5000 + if "timeout" in args: + try: + timeout = int(args["timeout"]) + except ValueError: + print "Timeout must be in milliseconds." + return + reactor.callFromThread(self._do_event_stream, timeout) + + @defer.inlineCallbacks + def _do_event_stream(self, timeout): + res = yield self.http_client.get_json( + self._url() + "/events", + { + "access_token": self._tok(), + "timeout": str(timeout), + "from": self.event_stream_token + }) + print json.dumps(res, indent=4) + + if "chunk" in res: + for event in res["chunk"]: + if (event["type"] == "m.room.message" and + self._is_on("send_delivery_receipts") and + event["user_id"] != self._usr()): # not sent by us + self._send_receipt(event, "d") + + # update the position in the stram + if "end" in res: + self.event_stream_token = res["end"] + + def _send_receipt(self, event, feedback_type): + path = ("/rooms/%s/messages/%s/%s/feedback/%s/%s" % + (urllib.quote(event["room_id"]), event["user_id"], event["msg_id"], + self._usr(), feedback_type)) + data = {} + reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data, + alt_text="Sent receipt for %s" % event["msg_id"]) + + def _do_membership_change(self, roomid, membership, userid): + path = "/rooms/%s/members/%s/state" % (urllib.quote(roomid), userid) + data = { + "membership": membership + } + reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data) + + def do_displayname(self, line): + """Get or set my displayname: "displayname [new_name]" """ + args = self._parse(line, ["name"]) + path = "/profile/%s/displayname" % (self.config["user"]) + + if "name" in args: + data = {"displayname": args["name"]} + reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data) + else: + reactor.callFromThread(self._run_and_pprint, "GET", path) + + def _do_presence_state(self, state, line): + args = self._parse(line, ["msgstring"]) + path = "/presence/%s/status" % (self.config["user"]) + data = {"state": state} + if "msgstring" in args: + data["status_msg"] = args["msgstring"] + + reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data) + + def do_offline(self, line): + """Set my presence state to OFFLINE""" + self._do_presence_state(0, line) + + def do_away(self, line): + """Set my presence state to AWAY""" + self._do_presence_state(1, line) + + def do_online(self, line): + """Set my presence state to ONLINE""" + self._do_presence_state(2, line) + + def _parse(self, line, keys, force_keys=False): + """ Parses the given line. + + Args: + line : The line to parse + keys : A list of keys to map onto the args + force_keys : True to enforce that the line has a value for every key + Returns: + A dict of key:arg + """ + line_args = shlex.split(line) + if force_keys and len(line_args) != len(keys): + raise IndexError("Must specify all args: %s" % keys) + + # do $ substitutions + for i, arg in enumerate(line_args): + for config_key in self.config: + if ("$" + config_key) in arg: + arg = arg.replace("$" + config_key, + self.config[config_key]) + line_args[i] = arg + + return dict(zip(keys, line_args)) + + @defer.inlineCallbacks + def _run_and_pprint(self, method, path, data=None, + query_params={"access_token": None}, alt_text=None): + """ Runs an HTTP request and pretty prints the output. + + Args: + method: HTTP method + path: Relative path + data: Raw JSON data if any + query_params: dict of query parameters to add to the url + """ + url = self._url() + path + if "access_token" in query_params: + query_params["access_token"] = self._tok() + + json_res = yield self.http_client.do_request(method, url, + data=data, + qparams=query_params) + if alt_text: + print alt_text + else: + print json.dumps(json_res, indent=4) + + +def save_config(config): + with open(CONFIG_JSON, 'w') as out: + json.dump(config, out) + + +def main(server_url, identity_server_url, username, token, config_path): + print "Synapse command line client" + print "===========================" + print "Server: %s" % server_url + print "Type 'help' to get started." + print "Close this console with CTRL+C then CTRL+D." + if not username or not token: + print "- 'register ' - Register an account" + print "- 'stream' - Connect to the event stream" + print "- 'create ' - Create a room" + print "- 'send ' - Send a message" + http_client = TwistedHttpClient() + + # the command line client + syn_cmd = SynapseCmd(http_client, server_url, identity_server_url, username, token) + + # load synapse.json config from a previous session + global CONFIG_JSON + CONFIG_JSON = config_path # bit cheeky, but just overwrite the global + try: + with open(config_path, 'r') as config: + syn_cmd.config = json.load(config) + try: + http_client.verbose = "on" == syn_cmd.config["verbose"] + except: + pass + print "Loaded config from %s" % config_path + except: + pass + + # Twisted-specific: Runs the command processor in Twisted's event loop + # to maintain a single thread for both commands and event processing. + # If using another HTTP client, just call syn_cmd.cmdloop() + reactor.callInThread(syn_cmd.cmdloop) + reactor.run() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser("Starts a synapse client.") + parser.add_argument( + "-s", "--server", dest="server", default="http://localhost:8080", + help="The URL of the home server to talk to.") + parser.add_argument( + "-i", "--identity-server", dest="identityserver", default="http://localhost:8090", + help="The URL of the identity server to talk to.") + parser.add_argument( + "-u", "--username", dest="username", + help="Your username on the server.") + parser.add_argument( + "-t", "--token", dest="token", + help="Your access token.") + parser.add_argument( + "-c", "--config", dest="config", default=CONFIG_JSON, + help="The location of the config.json file to read from.") + args = parser.parse_args() + + if not args.server: + print "You must supply a server URL to communicate with." + parser.print_help() + sys.exit(1) + + server = args.server + if not server.startswith("http://"): + server = "http://" + args.server + + main(server, args.identityserver, args.username, args.token, args.config) diff --git a/cmdclient/http.py b/cmdclient/http.py new file mode 100644 index 0000000000..e71593837a --- /dev/null +++ b/cmdclient/http.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +from twisted.web.client import Agent, readBody +from twisted.web.http_headers import Headers +from twisted.internet import defer, reactor + +from pprint import pformat + +import json +import urllib + + +class HttpClient(object): + """ Interface for talking json over http + """ + + def put_json(self, url, data): + """ Sends the specifed json data using PUT + + Args: + url (str): The URL to PUT data to. + data (dict): A dict containing the data that will be used as + the request body. This will be encoded as JSON. + + Returns: + Deferred: Succeeds when we get *any* HTTP response. + + The result of the deferred is a tuple of `(code, response)`, + where `response` is a dict representing the decoded JSON body. + """ + pass + + def get_json(self, url, args=None): + """ Get's some json from the given host homeserver and path + + Args: + url (str): The URL to GET data from. + args (dict): A dictionary used to create query strings, defaults to + None. + **Note**: The value of each key is assumed to be an iterable + and *not* a string. + + Returns: + Deferred: Succeeds when we get *any* HTTP response. + + The result of the deferred is a tuple of `(code, response)`, + where `response` is a dict representing the decoded JSON body. + """ + pass + + +class TwistedHttpClient(HttpClient): + """ Wrapper around the twisted HTTP client api. + + Attributes: + agent (twisted.web.client.Agent): The twisted Agent used to send the + requests. + """ + + def __init__(self): + self.agent = Agent(reactor) + + @defer.inlineCallbacks + def put_json(self, url, data): + response = yield self._create_put_request( + url, + data, + headers_dict={"Content-Type": ["application/json"]} + ) + body = yield readBody(response) + defer.returnValue((response.code, body)) + + @defer.inlineCallbacks + def get_json(self, url, args=None): + if args: + # generates a list of strings of form "k=v". + qs = urllib.urlencode(args, True) + url = "%s?%s" % (url, qs) + response = yield self._create_get_request(url) + body = yield readBody(response) + defer.returnValue(json.loads(body)) + + def _create_put_request(self, url, json_data, headers_dict={}): + """ Wrapper of _create_request to issue a PUT request + """ + + if "Content-Type" not in headers_dict: + raise defer.error( + RuntimeError("Must include Content-Type header for PUTs")) + + return self._create_request( + "PUT", + url, + producer=_JsonProducer(json_data), + headers_dict=headers_dict + ) + + def _create_get_request(self, url, headers_dict={}): + """ Wrapper of _create_request to issue a GET request + """ + return self._create_request( + "GET", + url, + headers_dict=headers_dict + ) + + @defer.inlineCallbacks + def do_request(self, method, url, data=None, qparams=None, jsonreq=True, headers={}): + if qparams: + url = "%s?%s" % (url, urllib.urlencode(qparams, True)) + + if jsonreq: + prod = _JsonProducer(data) + headers['Content-Type'] = ["application/json"]; + else: + prod = _RawProducer(data) + + if method in ["POST", "PUT"]: + response = yield self._create_request(method, url, + producer=prod, + headers_dict=headers) + else: + response = yield self._create_request(method, url) + + body = yield readBody(response) + defer.returnValue(json.loads(body)) + + @defer.inlineCallbacks + def _create_request(self, method, url, producer=None, headers_dict={}): + """ Creates and sends a request to the given url + """ + headers_dict["User-Agent"] = ["Synapse Cmd Client"] + + retries_left = 5 + print "%s to %s with headers %s" % (method, url, headers_dict) + if self.verbose and producer: + if "password" in producer.data: + temp = producer.data["password"] + producer.data["password"] = "[REDACTED]" + print json.dumps(producer.data, indent=4) + producer.data["password"] = temp + else: + print json.dumps(producer.data, indent=4) + + while True: + try: + response = yield self.agent.request( + method, + url.encode("UTF8"), + Headers(headers_dict), + producer + ) + break + except Exception as e: + print "uh oh: %s" % e + if retries_left: + yield self.sleep(2 ** (5 - retries_left)) + retries_left -= 1 + else: + raise e + + if self.verbose: + print "Status %s %s" % (response.code, response.phrase) + print pformat(list(response.headers.getAllRawHeaders())) + defer.returnValue(response) + + def sleep(self, seconds): + d = defer.Deferred() + reactor.callLater(seconds, d.callback, seconds) + return d + +class _RawProducer(object): + def __init__(self, data): + self.data = data + self.body = data + self.length = len(self.body) + + def startProducing(self, consumer): + consumer.write(self.body) + return defer.succeed(None) + + def pauseProducing(self): + pass + + def stopProducing(self): + pass + +class _JsonProducer(object): + """ Used by the twisted http client to create the HTTP body from json + """ + def __init__(self, jsn): + self.data = jsn + self.body = json.dumps(jsn).encode("utf8") + self.length = len(self.body) + + def startProducing(self, consumer): + consumer.write(self.body) + return defer.succeed(None) + + def pauseProducing(self): + pass + + def stopProducing(self): + pass \ No newline at end of file diff --git a/database-save.sh b/database-save.sh new file mode 100755 index 0000000000..c80f676f76 --- /dev/null +++ b/database-save.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +# This script will write a dump file of local user state if you want to splat +# your entire server database and start again but preserve the identity of +# local users and their access tokens. +# +# To restore it, use +# +# $ sqlite3 homeserver.db < table-save.sql + +sqlite3 homeserver.db <<'EOF' >table-save.sql +.dump users +.dump access_tokens +.dump presence +.dump profiles +EOF diff --git a/demo/README b/demo/README new file mode 100644 index 0000000000..0b584ceb15 --- /dev/null +++ b/demo/README @@ -0,0 +1,22 @@ +Requires you to have done: + python setup.py develop + + +The demo start.sh will start three synapse servers on ports 8080, 8081 and 8082, with host names localhost:$port. This can be easily changed to `hostname`:$port in start.sh if required. +It will also start a web server on port 8000 pointed at the webclient. + +stop.sh will stop the synapse servers and the webclient. + +clean.sh will delete the databases and log files. + +To start a completely new set of servers, run: + + ./demo/stop.sh; ./demo/clean.sh && ./demo/start.sh + + +Logs and sqlitedb will be stored in demo/808{0,1,2}.{log,db} + + + +Also note that when joining a public room on a differnt HS via "#foo:bar.net", then you are (in the current impl) joining a room with room_id "foo". This means that it won't work if your HS already has a room with that name. + diff --git a/demo/clean.sh b/demo/clean.sh new file mode 100755 index 0000000000..d5649fee83 --- /dev/null +++ b/demo/clean.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "$0" )" && pwd )" + +PID_FILE="$DIR/servers.pid" + +if [ -f $PID_FILE ]; then + echo "servers.pid exists!" + exit 1 +fi + +find "$DIR" -name "*.log" -delete +find "$DIR" -name "*.db" -delete + diff --git a/demo/start.sh b/demo/start.sh new file mode 100755 index 0000000000..4701872926 --- /dev/null +++ b/demo/start.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "$0" )" && pwd )" + +CWD=$(pwd) + +cd "$DIR/.." + +for port in "8080" "8081" "8082"; do + echo "Starting server on port $port... " + + python -m synapse.app.homeserver \ + -p "$port" \ + -H "localhost:$port" \ + -f "$DIR/$port.log" \ + -d "$DIR/$port.db" \ + -vv \ + -D --pid-file "$DIR/$port.pid" +done + +echo "Starting webclient on port 8000..." +python "demo/webserver.py" -p 8000 -P "$DIR/webserver.pid" "webclient" + +cd "$CWD" diff --git a/demo/stop.sh b/demo/stop.sh new file mode 100755 index 0000000000..85a1d2c161 --- /dev/null +++ b/demo/stop.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "$0" )" && pwd )" + +FILES=$(find "$DIR" -name "*.pid" -type f); + +for pid_file in $FILES; do + pid=$(cat "$pid_file") + if [[ $pid ]]; then + echo "Killing $pid_file with $pid" + kill $pid + fi +done + diff --git a/demo/webserver.py b/demo/webserver.py new file mode 100644 index 0000000000..78f3213540 --- /dev/null +++ b/demo/webserver.py @@ -0,0 +1,39 @@ +import argparse +import BaseHTTPServer +import os +import SimpleHTTPServer + +from daemonize import Daemonize + + +def setup(): + parser = argparse.ArgumentParser() + parser.add_argument("directory") + parser.add_argument("-p", "--port", dest="port", type=int, default=8080) + parser.add_argument('-P', "--pid-file", dest="pid", default="web.pid") + args = parser.parse_args() + + # Get absolute path to directory to serve, as daemonize changes to '/' + os.chdir(args.directory) + dr = os.getcwd() + + httpd = BaseHTTPServer.HTTPServer( + ('', args.port), + SimpleHTTPServer.SimpleHTTPRequestHandler + ) + + def run(): + os.chdir(dr) + httpd.serve_forever() + + daemon = Daemonize( + app="synapse-webclient", + pid=args.pid, + action=run, + auto_close_fds=False, + ) + + daemon.start() + +if __name__ == '__main__': + setup() diff --git a/docs/client-server/specification b/docs/client-server/specification new file mode 100644 index 0000000000..7df2bb14c5 --- /dev/null +++ b/docs/client-server/specification @@ -0,0 +1,1264 @@ +========================= +Synapse Client-Server API +========================= + +The following specification outlines how a client can send and receive data from +a home server. + +[[TODO(kegan): 4/7/14 Grilling +- Mechanism for getting historical state changes (e.g. topic updates) - add + query param flag? +- Generic mechanism for linking first class events (e.g. feedback) with other s + first class events (e.g. messages)? +- Generic mechanism for updating 'stuff about the room' (e.g. favourite coffee) + AND specifying clobbering rules (clobber/add to list/etc)? +- How to ensure a consistent view for clients paginating through room lists? + They aren't really ordered in any way, and if you're paginating + through them, how can you show them a consistent result set? Temporary 'room + list versions' akin to event version? How does that work? +]] + +[[TODO(kegan): +Outstanding problems / missing spec: +- Push +- Typing notifications +]] + +Terminology +----------- +Stream Tokens: +An opaque token used to make further streaming requests. When using any +pagination streaming API, responses will contain a start and end stream token. +When reconnecting to the stream, these tokens can be used to tell the server +where the client got up to in the stream. + +Event ID: +Every event that comes down the event stream or that is returned from the REST +API has an associated event ID (event_id). This ID will be the same between the +REST API and the event stream, so any duplicate events can be clobbered +correctly without knowing anything else about the event. + +Message ID: +The ID of a message sent by a client in a room. Clients send IMs to each other +in rooms. Each IM sent by a client must have a unique message ID which is unique +for that particular client. + +User ID: +The @username:host style ID of the client. When registering for an account, the +client specifies their username. The user_id is this username along with the +home server's unique hostname. When federating between home servers, the user_id +is used to uniquely identify users across multiple home servers. + +Room ID: +The room_id@host style ID for the room. When rooms are created, the client either +specifies or is allocated a room ID. This room ID must be used to send messages +in that room. Like with clients, there may be multiple rooms with the same ID +across multiple home servers. The room_id is used to uniquely identify a room +when federating. + +Global message ID: +The globally unique ID for a message. This ID is formed from the msg_id, the +client's user_id and the room_id. This uniquely identifies any +message. It is represented with '-' as the delimeter between IDs. The +global_msg_id is of the form: room_id-user_id-msg_id + + +REST API and the Event Stream +----------------------------- +Clients send data to the server via a RESTful API. They can receive data via +this API or from an event stream. An event stream is a special path which +streams all events the client may be interested in. This makes it easy to +immediately receive updates from the REST API. All data is represented as JSON. + +Pagination streaming API +======================== +Clients are often interested in very large datasets. The data itself could +be 1000s of messages in a given room, 1000s of rooms in a public room list, or +1000s of events (presence, typing, messages, etc) in the system. It is not +practical to send vast quantities of data to the client every time they +request a list of public rooms for example. There needs to be a way to show a +subset of this data, and apply various filters to it. This is what the pagination +streaming API is. This API defines standard request/response parameters which +can be used when navigating this stream of data. + +Pagination Request Query Parameters +----------------------------------- +Clients may wish to paginate results from the event stream, or other sources of +information where the amount of information may be a problem, +e.g. in a room with 10,000s messages. The pagination query parameters provide a +way to navigate a 'window' around a large set of data. These +parameters are only valid for GET requests. + + S e r v e r - s i d e d a t a + |-------------------------------------------------| +START ^ ^ END + |_______________| + | + Client-extraction + +'START' and 'END' are magic token values which specify the start and end of the +dataset respectively. + +Query parameters: + from : $streamtoken - The opaque token to start streaming from. + to : $streamtoken - The opaque token to end streaming at. Typically, + clients will not know the item of data to end at, so this will usually be + START or END. + limit : integer - An integer representing the maximum number of items to + return. + +For example, the event stream has events E1 -> E15. The client wants the last 5 +events and doesn't know any previous events: + +S E +|-E1-E2-E3-E4-E5-E6-E7-E8-E9-E10-E11-E12-E13-E14-E15-| +| | | +| _____| | +|__________________ | ___________________| + | | | + GET /events?to=START&limit=5&from=END + Returns: + E15,E14,E13,E12,E11 + + +Another example: a public room list has rooms R1 -> R17. The client is showing 5 +rooms at a time on screen, and is on page 2. They want to +now show page 3 (rooms R11 -> 15): + +S E +| 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | stream token +|-R1-R2-R3-R4-R5-R6-R7-R8-R9-R10-R11-R12-R13-R14-R15-R16-R17| room + |____________| |________________| + | | + Currently | + viewing | + | + GET /rooms/list?from=9&to=END&limit=5 + Returns: R11,R12,R13,R14,R15 + +Note that tokens are treated in an *exclusive*, not inclusive, manner. The end +token from the intial request was '9' which corresponded to R10. When the 2nd +request was made, R10 did not appear again, even though from=9 was specified. If +you know the token, you already have the data. + +Pagination Response +------------------- +Responses to pagination requests MUST follow the format: +{ + "chunk": [ ... , Responses , ... ], + "start" : $streamtoken, + "end" : $streamtoken +} +Where $streamtoken is an opaque token which can be used in another query to +get the next set of results. The "start" and "end" keys can only be omitted if +the complete dataset is provided in "chunk". + +If the client wants earlier results, they should use from=$start_streamtoken, +to=START. Likewise, if the client wants later results, they should use +from=$end_streamtoken, to=END. + +Unless specified, the default pagination parameters are from=START, to=END, +without a limit set. This allows you to hit an API like +/events without any query parameters to get everything. + +The Event Stream +---------------- +The event stream returns events using the pagination streaming API. When the +client disconnects for a while and wants to reconnect to the event stream, they +should specify from=$end_streamtoken. This lets the server know where in the +event stream the client is. These tokens are completely opaque, and the client +cannot infer anything from them. + + GET /events?from=$LAST_STREAM_TOKEN + REST Path: /events + Returns (success): A JSON array of Event Data. + Returns (failure): An Error Response + +LAST_STREAM_TOKEN is the last stream token obtained from the event stream. If the +client is connecting for the first time and does not know any stream tokens, +they can use "START" to request all events from the start. For more information +on this, see "Pagination Request Query Parameters". + +The event stream supports shortpoll and longpoll with the "timeout" query +parameter. This parameter specifies the number of milliseconds the server should +hold onto the connection waiting for incoming events. If no events occur in this +period, the connection will be closed and an empty chunk will be returned. To +use shortpoll, specify "timeout=0". + +Event Data +---------- +This is a JSON object which looks like: +{ + "event_id" : $EVENT_ID, + "type" : $EVENT_TYPE, + $URL_ARGS, + "content" : { + $EVENT_CONTENT + } +} + +EVENT_ID + An ID identifying this event. This is so duplicate events can be suppressed on + the client. + +EVENT_TYPE + The namespaced event type (m.*) + +URL_ARGS + Path specific data from the REST API. + +EVENT_CONTENT + The event content, matching the REST content PUT previously. + +Events are differentiated via the event type "type" key. This is the type of +event being received. This can be expanded upon by using different namespaces. +Every event MUST have a 'type' key. + +Most events will have a corresponding REST URL. This URL will generally have +data in it to represent the resource being modified, +e.g. /rooms/$room_id. The event data will contain extra top-level keys to expose +this information to clients listening on an event +stream. The event content maps directly to the contents submitted via the REST +API. + +For example: + Event Type: m.example.room.members + REST Path: /examples/room/$room_id/members/$user_id + REST Content: { "membership" : "invited" } + +is represented in the event stream as: + +{ + "event_id" : "e_some_event_id", + "type" : "m.example.room.members", + "room_id" : $room_id, + "user_id" : $user_id, + "content" : { + "membership" : "invited" + } +} + +As convention, the URL variable "$varname" will map directly onto the name +of the JSON key "varname". + +Error Responses +--------------- +If the client sends an invalid request, the server MAY respond with an error +response. This is of the form: +{ + "error" : "string", + "errcode" : "string" +} +The 'error' string will be a human-readable error message, usually a sentence +explaining what went wrong. + +The 'errcode' string will be a unique string which can be used to handle an +error message e.g. "M_FORBIDDEN". These error codes should have their namespace +first in ALL CAPS, followed by a single _. For example, if there was a custom +namespace com.mydomain.here, and a "FORBIDDEN" code, the error code should look +like "COM.MYDOMAIN.HERE_FORBIDDEN". There may be additional keys depending on +the error, but the keys 'error' and 'errcode' will always be present. + +Some standard error codes are below: + +M_FORBIDDEN: +Forbidden access, e.g. bad access token, failed login. + +M_BAD_JSON: +Request contained valid JSON, but it was malformed in some way, e.g. missing +required keys, invalid values for keys. + +M_NOT_JSON: +Request did not contain valid JSON. + +M_NOT_FOUND: +No resource was found for this request. + +Some requests have unique error codes: + +M_USER_IN_USE: +Encountered when trying to register a user ID which has been taken. + +M_ROOM_IN_USE: +Encountered when trying to create a room which has been taken. + +M_BAD_PAGINATION: +Encountered when specifying bad pagination values to a Pagination Streaming API. + + +======== +REST API +======== + +All content must be application/json. Some keys are required, while others are +optional. Unless otherwise specified, +all HTTP PUT/POST/DELETEs will return a 200 OK with an empty response body on +success, and a 4xx/5xx with an optional Error Response on failure. When sending +data, if there are no keys to send, an empty JSON object should be sent. + +All POST/PUT/GET/DELETE requests MUST have an 'access_token' query parameter to +allow the server to authenticate the client. All +POST requests MUST be submitted as application/json. + +All paths MUST be namespaced by the version of the API being used. This should +be: + +/matrix/client/api/v1 + +All REST paths in this section MUST be prefixed with this. E.g. + REST Path: /rooms/$room_id + Absolute Path: /matrix/client/api/v1/rooms/$room_id + +Registration +============ +Clients must register with the server in order to use the service. After +registering, the client will be given an +access token which must be used in ALL requests as a query parameter +'access_token'. + +Registering for an account +-------------------------- + POST /register + With: A JSON object containing the key "user_id" which contains the desired + user_id, or an empty JSON object to have the server allocate a user_id + automatically. + Returns (success): 200 OK with a JSON object: + { + "user_id" : "string [user_id]", + "access_token" : "string" + } + Returns (failure): An Error Response. M_USER_IN_USE if the user ID is taken. + + +Unregistering an account +------------------------ + POST /unregister + With query parameters: access_token=$ACCESS_TOKEN + Returns (success): 200 OK + Returns (failure): An Error Response. + + +Logging in to an existing account +================================= +If the client has already registered, they need to be able to login to their +account. The home server may provide many different ways of logging in, such +as user/password auth, login via a social network (OAuth), login by confirming +a token sent to their email address, etc. This section does NOT define how home +servers should authorise their users who want to login to their existing +accounts. This section defines the standard interface which implementations +should follow so that ANY client can login to ANY home server. + +The login process breaks down into the following: + 1: Get login process info. + 2: Submit the login stage credentials. + 3: Get access token or be told the next stage in the login process and repeat + step 2. + +Getting login process info: + GET /login + Returns (success): 200 OK with LoginInfo. + Returns (failure): An Error Response. + +Submitting the login stage credentials: + POST /login + With: LoginSubmission + Returns (success): 200 OK with LoginResult + Returns (failure): An Error Response + +Where LoginInfo is a JSON object which MUST have a "type" key which denotes the +login type. If there are multiple login stages, this object MUST also contain a +"stages" key, which has a JSON array of login types denoting all the steps in +order to login, including the first stage which is in "type". This allows the +client to make an informed decision as to whether or not they can natively +handle the entire login process, or whether they should fallback (see below). + +Where LoginSubmission is a JSON object which MUST have a "type" key. + +Where LoginResult is a JSON object which MUST have either a "next" key OR an +"access_token" key, depending if the login process is over or not. This object +MUST have a "session" key if multiple POSTs need to be sent to /login. + +Fallback +-------- +If the client does NOT know how to handle the given type, they should: + GET /login/fallback +This MUST return an HTML page which can perform the entire login process. + +Password-based +-------------- +Type: "m.login.password" +LoginSubmission: +{ + "type": "m.login.password", + "user": , + "password": +} + +Example: +Assume you are @bob:matrix.org and you wish to login on another mobile device. +First, you GET /login which returns: +{ + "type": "m.login.password" +} +Your client knows how to handle this, so your client prompts the user to enter +their username and password. This is then submitted: +{ + "type": "m.login.password", + "user": "@bob:matrix.org", + "password": "monkey" +} +The server checks this, finds it is valid, and returns: +{ + "access_token": "abcdef0123456789" +} + +OAuth2-based +------------ +Type: "m.login.oauth2" +This is a multi-stage login. + +LoginSubmission: +{ + "type": "m.login.oauth2", + "user": +} +Returns: +{ + "uri": +} + +The home server acts as a 'confidential' Client for the purposes of OAuth2. + +If the uri is a "sevice selection uri", it is a simple page which prompts the +user to choose which service to authorize with. On selection of a service, they +link through to Authorization Request URIs. If there is only 1 service which the +home server accepts when logging in, this indirection can be skipped and the +"uri" key can be the Authorization Request URI. + +The client visits the Authorization Request URI, which then shows the OAuth2 +Allow/Deny prompt. Hitting 'Allow' returns the redirect URI with the auth code. +Home servers can choose any path for the redirect URI. The client should visit +the redirect URI, which will then finish the OAuth2 login process, granting the +home server an access token for the chosen service. When the home server gets +this access token, it knows that the cilent has authed with the 3rd party, and +so can return a LoginResult. + +The OAuth redirect URI (with auth code) MUST return a LoginResult. + +Example: +Assume you are @bob:matrix.org and you wish to login on another mobile device. +First, you GET /login which returns: +{ + "type": "m.login.oauth2" +} +Your client knows how to handle this, so your client prompts the user to enter +their username. This is then submitted: +{ + "type": "m.login.oauth2", + "user": "@bob:matrix.org" +} +The server only accepts auth from Google, so returns the Authorization Request +URI for Google: +{ + "uri": "https://accounts.google.com/o/oauth2/auth?response_type=code& + client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=photos" +} +The client then visits this URI and authorizes the home server. The client then +visits the REDIRECT_URI with the auth code= query parameter which returns: +{ + "access_token": "0123456789abcdef" +} + +Email-based (code) +------------------ +Type: "m.login.email.code" +This is a multi-stage login. + +First LoginSubmission: +{ + "type": "m.login.email.code", + "user": + "email": +} +Returns: +{ + "session": +} + +The email contains a code which must be sent in the next LoginSubmission: +{ + "type": "m.login.email.code", + "session": , + "code": +} +Returns: +{ + "access_token": +} + +Example: +Assume you are @bob:matrix.org and you wish to login on another mobile device. +First, you GET /login which returns: +{ + "type": "m.login.email.code" +} +Your client knows how to handle this, so your client prompts the user to enter +their email address. This is then submitted: +{ + "type": "m.login.email.code", + "user": "@bob:matrix.org", + "email": "bob@mydomain.com" +} +The server confirms that bob@mydomain.com is linked to @bob:matrix.org, then +sends an email to this address and returns: +{ + "session": "ewuigf7462" +} +The client's screen changes to a code submission page. The email arrives and it +says something to the effect of "please enter 2348623 into the app". This is +the submitted along with the session: +{ + "type": "m.login.email.code", + "session": "ewuigf7462", + "code": "2348623" +} +The server accepts this and returns: +{ + "access_token": "abcdef0123456789" +} + +Email-based (url) +----------------- +Type: "m.login.email.url" +This is a multi-stage login. + +First LoginSubmission: +{ + "type": "m.login.email.url", + "user": + "email": +} +Returns: +{ + "session": +} + +The email contains a URL which must be clicked. After it has been clicked, the +client should perform a request: +{ + "type": "m.login.email.code", + "session": +} +Returns: +{ + "access_token": +} + +Example: +Assume you are @bob:matrix.org and you wish to login on another mobile device. +First, you GET /login which returns: +{ + "type": "m.login.email.url" +} +Your client knows how to handle this, so your client prompts the user to enter +their email address. This is then submitted: +{ + "type": "m.login.email.url", + "user": "@bob:matrix.org", + "email": "bob@mydomain.com" +} +The server confirms that bob@mydomain.com is linked to @bob:matrix.org, then +sends an email to this address and returns: +{ + "session": "ewuigf7462" +} +The client then starts polling the server with the following: +{ + "type": "m.login.email.url", + "session": "ewuigf7462" +} +(Alternatively, the server could send the device a push notification when the +email has been validated). The email arrives and it contains a URL to click on. +The user clicks on the which completes the login process with the server. The +next time the client polls, it returns: +{ + "access_token": "abcdef0123456789" +} + +N-Factor auth +------------- +Multiple login stages can be combined with the "next" key in the LoginResult. + +Example: +A server demands an email.code then password auth before logging in. First, the +client performs a GET /login which returns: +{ + "type": "m.login.email.code", + "stages": ["m.login.email.code", "m.login.password"] +} +The client performs the email login (See "Email-based (code)"), but instead of +returning an access_token, it returns: +{ + "next": "m.login.password" +} +The client then presents a user/password screen and the login continues until +this is complete (See "Password-based"), which then returns the "access_token". + +Rooms +===== +A room is a conceptual place where users can send and receive messages. Rooms +can be created, joined and left. Messages are sent +to a room, and all participants in that room will receive the message. Rooms are +uniquely identified via the room_id. + +Creating a room (with a room ID) +-------------------------------- + Event Type: m.room.create [TODO(kegan): Do we generate events for this?] + REST Path: /rooms/$room_id + Valid methods: PUT + Required keys: None. + Optional keys: + visibility : [public|private] - Set whether this room shows up in the public + room list. + Returns: + On Failure: MAY return a suggested alternative room ID if this room ID is + taken. + { + suggested_room_id : $new_room_id + error : "Room already in use." + errcode : "M_ROOM_IN_USE" + } + + +Creating a room (without a room ID) +----------------------------------- + Event Type: m.room.create [TODO(kegan): Do we generate events for this?] + REST Path: /rooms + Valid methods: POST + Required keys: None. + Optional keys: + visibility : [public|private] - Set whether this room shows up in the public + room list. + Returns: + On Success: The allocated room ID. Additional information about the room + such as the visibility MAY be included as extra keys in this response. + { + room_id : $room_id + } + +Setting the topic for a room +---------------------------- + Event Type: m.room.topic + REST Path: /rooms/$room_id/topic + Valid methods: GET/PUT + Required keys: + topic : $topicname - Set the topic to $topicname in room $room_id. + + +See a list of public rooms +-------------------------- + REST Path: /public/rooms?pagination_query_parameters + Valid methods: GET + This API can use pagination query parameters. + Returns: + { + "chunk" : JSON array of RoomInfo JSON objects - Required. + "start" : "string (start token)" - See Pagination Response. + "end" : "string (end token)" - See Pagination Response. + "total" : integer - Optional. The total number of rooms. + } + +RoomInfo: Information about a single room. + Servers MUST send the key: room_id + Servers MAY send the keys: topic, num_members + { + "room_id" : "string", + "topic" : "string", + "num_members" : integer + } + +Room Members +============ + +Invite/Joining/Leaving a room +----------------------------- + Event Type: m.room.member + REST Path: /rooms/$room_id/members/$user_id/state + Valid methods: PUT/GET/DELETE + Required keys: + membership : [join|invite] - The membership state of $user_id in room + $room_id. + +Where: + join - Indicate you ($user_id) are joining the room $room_id. + invite - Indicate that $user_id has been invited to room $room_id. + +User $user_id can leave room $room_id by DELETEing this path. + +Checking the user list of a room +-------------------------------- + REST Path: /rooms/$room_id/members/list + This API can use pagination query parameters. + Valid methods: GET + Returns: + A pagination response with chunk data as m.room.member events. + +Messages +======== +Users send messages to other users in rooms. These messages may be text, images, +video, etc. Clients may also want to acknowledge messages by sending feedback, +in the form of delivery/read receipts. + +Server-attached keys +-------------------- +The server MAY attach additional keys to messages and feedback. If a client +submits keys with the same name, they will be clobbered by +the server. + +Required keys: +from : "string [user_id]" + The user_id of the user who sent the message/feedback. + +Optional keys: +hsob_ts : integer + A timestamp (ms resolution) representing when the message/feedback got to the + sender's home server ("home server outbound timestamp"). + +hsib_ts : integer + A timestamp (ms resolution) representing when the + message/feedback got to the receiver's home server ("home server inbound + timestamp"). This may be the same as hsob_ts if the sender/receiver are on the + same home server. + +Sending messages +---------------- + Event Type: m.room.message + REST Path: /rooms/$room_id/messages/$from/$msg_id + Valid methods: GET/PUT + URL parameters: + $from : user_id - The sender's user_id. This value will be clobbered by the + server before sending. + Required keys: + msgtype: [m.text|m.emote|m.image|m.audio|m.video|m.location|m.file] - + The type of message. Not to be confused with the Event 'type'. + Optional keys: + sender_ts : integer - A timestamp (ms resolution) representing the + wall-clock time when the message was sent from the client. + Reserved keys: + body : "string" - The human readable string for compatibility with clients + which cannot process a given msgtype. This key is optional, but + if it is included, it MUST be human readable text + describing the message. See individual msgtypes for more + info on what this means in practice. + +Each msgtype may have required fields of their own. + +msgtype: m.text +---------------- +Required keys: + body : "string" - The body of the message. +Optional keys: + None. + +msgtype: m.emote +----------------- +Required keys: + body : "string" - *tries to come up with a witty explanation*. +Optional keys: + None. + +msgtype: m.image +----------------- +Required keys: + url : "string" - The URL to the image. +Optional keys: + body : "string" - info : JSON object (ImageInfo) - The image info for image + referred to in 'url'. + thumbnail_url : "string" - The URL to the thumbnail. + thumbnail_info : JSON object (ImageInfo) - The image info for the image + referred to in 'thumbnail_url'. + +ImageInfo: Information about an image. +{ + "size" : integer (size of image in bytes), + "w" : integer (width of image in pixels), + "h" : integer (height of image in pixels), + "mimetype" : "string (e.g. image/jpeg)" +} + +Interpretation of 'body' key: The alt text of the image, or some kind of content +description for accessibility e.g. "image attachment". + +msgtype: m.audio +----------------- +Required keys: + url : "string" - The URL to the audio. +Optional keys: + info : JSON object (AudioInfo) - The audio info for the audio referred to in + 'url'. + +AudioInfo: Information about a piece of audio. +{ + "mimetype" : "string (e.g. audio/aac)", + "size" : integer (size of audio in bytes), + "duration" : integer (duration of audio in milliseconds) +} + +Interpretation of 'body' key: A description of the audio e.g. "Bee Gees - +Stayin' Alive", or some kind of content description for accessibility e.g. +"audio attachment". + +msgtype: m.video +----------------- +Required keys: + url : "string" - The URL to the video. +Optional keys: + info : JSON object (VideoInfo) - The video info for the video referred to in + 'url'. + +VideoInfo: Information about a video. +{ + "mimetype" : "string (e.g. video/mp4)", + "size" : integer (size of video in bytes), + "duration" : integer (duration of video in milliseconds), + "w" : integer (width of video in pixels), + "h" : integer (height of video in pixels), + "thumbnail_url" : "string (URL to image)", + "thumbanil_info" : JSON object (ImageInfo) +} + +Interpretation of 'body' key: A description of the video e.g. "Gangnam style", +or some kind of content description for accessibility e.g. "video attachment". + +msgtype: m.location +-------------------- +Required keys: + geo_uri : "string" - The geo URI representing the location. +Optional keys: + thumbnail_url : "string" - The URL to a thumnail of the location being + represented. + thumbnail_info : JSON object (ImageInfo) - The image info for the image + referred to in 'thumbnail_url'. + +Interpretation of 'body' key: A description of the location e.g. "Big Ben, +London, UK", or some kind of content description for accessibility e.g. +"location attachment". + + +Sending feedback +---------------- +When you receive a message, you may want to send delivery receipt to let the +sender know that the message arrived. You may also want to send a read receipt +when the user has read the message. These receipts are collectively known as +'feedback'. + + Event Type: m.room.message.feedback + REST Path: /rooms/$room_id/messages/$msgfrom/$msg_id/feedback/$from/$feedback + Valid methods: GET/PUT + URL parameters: + $msgfrom - The sender of the message's user_id. + $from : user_id - The sender of the feedback's user_id. This value will be + clobbered by the server before sending. + $feedback : [d|r] - Specify if this is a [d]elivery or [r]ead receipt. + Required keys: + None. + Optional keys: + sender_ts : integer - A timestamp (ms resolution) representing the + wall-clock time when the receipt was sent from the client. + +Receiving messages (bulk/pagination) +------------------------------------ + Event Type: m.room.message + REST Path: /rooms/$room_id/messages/list + Valid methods: GET + Query Parameters: + feedback : [true|false] - Specify if feedback should be bundled with each + message. + This API can use pagination query parameters. + Returns: + A JSON array of Event Data in "chunk" (see Pagination Response). If the + "feedback" parameter was set, the Event Data will also contain a "feedback" + key which contains a JSON array of feedback, with each element as Event Data + with compressed feedback for this message. + +Event Data with compressed feedback is a special type of feedback with +contextual keys removed. It is designed to limit the amount of redundant data +being sent for feedback. This removes the type, event_id, room ID, +message sender ID and message ID keys. + + ORIGINAL (via event streaming) +{ + "event_id":"e1247632487", + "type":"m.room.message.feedback", + "from":"string [user_id]", + "feedback":"string [d|r]", + "room_id":"$room_id", + "msg_id":"$msg_id", + "msgfrom":"$msgfromid", + "content":{ + "sender_ts":139880943 + } +} + + COMPRESSED (via /messages/list) +{ + "from":"string [user_id]", + "feedback":"string [d|r]", + "content":{ + "sender_ts":139880943 + } +} + +When you join a room $room_id, you may want the last 10 messages with feedback. +This is represented as: + GET + /rooms/$room_id/messages/list?from=END&to=START&limit=10&feedback=true + +You may want to get 10 messages even earlier than that without feedback. If the +start stream token from the previous request was stok_019173, this request would +be: + GET + /rooms/$room_id/messages/list?from=stok_019173&to=START&limit=10& + feedback=false + +NOTE: Care must be taken when using this API in conjunction with event + streaming. It is possible that this will return a message which will + then come down the event stream, resulting in a duplicate message. Clients + should clobber based on the global message ID, or event ID. + + +Get current state for all rooms (aka IM Initial Sync API) +------------------------------- + REST Path: /im/sync + Valid methods: GET + This API can use pagination query parameters. Pagination is applied on a per + *room* basis. E.g. limit=1 means "get 1 message for each room" and not "get 1 + room's messages". If there is no limit, all messages for all rooms will be + returned. + If you want 1 room's messages, see "Receiving messages (bulk/pagination)". + Additional query parameters: + feedback: [true] - Bundles feedback with messages. + Returns: + An array of RoomStateInfo. + +RoomStateInfo: A snapshot of information about a single room. + { + "room_id" : "string", + "membership" : "string [join|invite]", + "messages" : { + "start": "string", + "end": "string", + "chunk": + m.room.message pagination stream events (with feedback if specified), + this is the same as "Receiving messages (bulk/pagination)". + } + } +The "membership" key is the calling user's membership state in the given +"room_id". The "messages" key may be omitted if the "membership" value is +"invite". Additional keys may be added to the top-level object, such as: + "topic" : "string" - The topic for the room in question. + "room_image_url" : "string" - The URL of the room image if specified. + "num_members" : integer - The number of members in the room. + + +Profiles +======== + +Getting/Setting your own displayname +------------------------------------ + REST Path: /profile/$user_id/displayname + Valid methods: GET/PUT + Required keys: + displayname : The displayname text + +Getting/Setting your own avatar image URL +----------------------------------------- +The homeserver does not currently store the avatar image itself, but offers +storage for the user to specify a web URL that points at the required image, +leaving it up to clients to fetch it themselves. + REST Path: /profile/$user_id/avatar_url + Valid methods: GET/PUT + Required keys: + avatar_url : The URL path to the required image + +Getting other user's profile information +---------------------------------------- +Either of the above REST methods may be used to fetch other user's profile +information by the client, either on other local users on the same homeserver or +for users from other servers entirely. + + +Presence +======== + +In the following messages, the presence state is an integer enumeration of the +following states: + 0 : OFFLINE + 1 : BUSY + 2 : ONLINE + 3 : FREE_TO_CHAT + +Aside from OFFLINE, the protocol doesn't assign any special meaning to these +states; they are provided as an approximate signal for users to give to other +users and for clients to present them in some way that may be useful. Clients +could have different behaviours for different states of the user's presence, for +example to decide how much prominence or sound to use for incoming event +notifications. + +Getting/Setting your own presence state +--------------------------------------- + REST Path: /presence/$user_id/status + Valid methods: GET/PUT + Required keys: + state : [0|1|2|3] - The user's new presence state + Optional keys: + status_msg : text string provided by the user to explain their status + +Fetching your presence list +--------------------------- + REST Path: /presence_list/$user_id + Valid methods: GET/(post) + Returns: + An array of presence list entries. Each entry is an object with the + following keys: + { + "user_id" : string giving the observed user's ID + "state" : int giving their status + "status_msg" : optional text string + "displayname" : optional text string from the user's profile + "avatar_url" : optional text string from the user's profile + } + +Maintaining your presence list +------------------------------ + REST Path: /presence_list/$user_id + Valid methods: POST/(get) + With: A JSON object optionally containing either of the following keys: + "invite" : a list of strings giving user IDs to invite for presence + subscription + "drop" : a list of strings giving user IDs to remove from your presence + list + +Receiving presence update events +-------------------------------- + Event Type: m.presence + Keys of the event's content are the same as those returned by the presence + list. + +Examples +======== + +The following example is the story of "bob", who signs up at "sy.org" and joins +the public room "room_beta@sy.org". They get the 2 most recent +messages (with feedback) in that room and then send a message in that room. + +For context, here is the complete chat log for room_beta@sy.org: + +Room: "Hello world" (room_beta@sy.org) +Members: (2) alice@randomhost.org, friend_of_alice@randomhost.org +Messages: + alice@randomhost.org : hi friend! + [friend_of_alice@randomhost.org DELIVERED] + alice@randomhost.org : you're my only friend + [friend_of_alice@randomhost.org DELIVERED] + alice@randomhost.org : afk + [friend_of_alice@randomhost.org DELIVERED] + [ bob@sy.org joins ] + bob@sy.org : Hi everyone + [ alice@randomhost.org changes the topic to "FRIENDS ONLY" ] + alice@randomhost.org : Hello!!!! + alice@randomhost.org : Let's go to another room + alice@randomhost.org : You're not my friend + [ alice@randomhost.org invites bob@sy.org to the room + commoners@randomhost.org] + + +REGISTER FOR AN ACCOUNT +POST: /register +Content: {} +Returns: { "user_id" : "bob@sy.org" , "access_token" : "abcdef0123456789" } + +GET PUBLIC ROOM LIST +GET: /rooms/list?access_token=abcdef0123456789 +Returns: +{ + "total":3, + "chunk": + [ + { "room_id":"room_alpha@sy.org", "topic":"I am a fish" }, + { "room_id":"room_beta@sy.org", "topic":"Hello world" }, + { "room_id":"room_xyz@sy.org", "topic":"Goodbye cruel world" } + ] +} + +JOIN ROOM room_beta@sy.org +PUT +/rooms/room_beta%40sy.org/members/bob%40sy.org/state? + access_token=abcdef0123456789 +Content: { "membership" : "join" } +Returns: 200 OK + +GET LATEST 2 MESSAGES WITH FEEDBACK +GET +/rooms/room_beta%40sy.org/messages/list?from=END&to=START&limit=2& + feedback=true&access_token=abcdef0123456789 +Returns: +{ + "chunk": + [ + { + "event_id":"01948374", + "type":"m.room.message", + "room_id":"room_beta@sy.org", + "msg_id":"avefifu", + "from":"alice@randomhost.org", + "hs_ts":139985736, + "content":{ + "msgtype":"m.text", + "body":"afk" + } + "feedback": [ + { + "from":"friend_of_alice@randomhost.org", + "feedback":"d", + "hs_ts":139985850, + "content":{ + "sender_ts":139985843 + } + } + ] + }, + { + "event_id":"028dfe8373", + "type":"m.room.message", + "room_id":"room_beta@sy.org", + "msg_id":"afhgfff", + "from":"alice@randomhost.org", + "hs_ts":139970006, + "content":{ + "msgtype":"m.text", + "body":"you're my only friend" + } + "feedback": [ + { + "from":"friend_of_alice@randomhost.org", + "feedback":"d", + "hs_ts":139970144, + "content":{ + "sender_ts":139970122 + } + } + ] + }, + ], + "start": "stok_04823947", + "end": "etok_1426425" +} + +SEND MESSAGE IN ROOM +PUT +/rooms/room_beta%40sy.org/messages/bob%40sy.org/m0001? + access_token=abcdef0123456789 +Content: { "msgtype" : "text" , "body" : "Hi everyone" } +Returns: 200 OK + + +Checking the event stream for this user: +GET: /events?from=START&access_token=abcdef0123456789 +Returns: +{ + "chunk": + [ + { + "event_id":"e10f3d2b", + "type":"m.room.member", + "room_id":"room_beta@sy.org", + "user_id":"bob@sy.org", + "content":{ + "membership":"join" + } + }, + { + "event_id":"1b352d32", + "type":"m.room.message", + "room_id":"room_beta@sy.org", + "msg_id":"m0001", + "from":"bob@sy.org", + "hs_ts":140193857, + "content":{ + "msgtype":"m.text", + "body":"Hi everyone" + } + } + ], + "start": "stok_9348635", + "end": "etok_1984723" +} + +Client disconnects for a while and the topic is updated in this room, 3 new +messages arrive whilst offline, and bob is invited to another room. + +GET /events?from=etok_1984723&access_token=abcdef0123456789 +Returns: +{ + "chunk": + [ + { + "event_id":"feee0294", + "type":"m.room.topic", + "room_id":"room_beta@sy.org", + "from":"alice@randomhost.org", + "content":{ + "topic":"FRIENDS ONLY", + } + }, + { + "event_id":"a028bd9e", + "type":"m.room.message", + "room_id":"room_beta@sy.org", + "msg_id":"z839409", + "from":"alice@randomhost.org", + "hs_ts":140195000, + "content":{ + "msgtype":"m.text", + "body":"Hello!!!" + } + }, + { + "event_id":"49372d9e", + "type":"m.room.message", + "room_id":"room_beta@sy.org", + "msg_id":"z839410", + "from":"alice@randomhost.org", + "hs_ts":140196000, + "content":{ + "msgtype":"m.text", + "body":"Let's go to another room" + } + }, + { + "event_id":"10abdd01", + "type":"m.room.message", + "room_id":"room_beta@sy.org", + "msg_id":"z839411", + "from":"alice@randomhost.org", + "hs_ts":140197000, + "content":{ + "msgtype":"m.text", + "body":"You're not my friend" + } + }, + { + "event_id":"0018453d", + "type":"m.room.member", + "room_id":"commoners@randomhost.org", + "from":"alice@randomhost.org", + "user_id":"bob@sy.org", + "content":{ + "membership":"invite" + } + }, + ], + "start": "stok_0184288", + "end": "etok_1348723" +} diff --git a/docs/client-server/urls b/docs/client-server/urls new file mode 100644 index 0000000000..c0d2eaa80c --- /dev/null +++ b/docs/client-server/urls @@ -0,0 +1,92 @@ +========================= +Client-Server URL Summary +========================= + +A brief overview of the URL scheme involved in the Synapse Client-Server API. + + +URLs +==== + +Fetch events: + GET /events + +Registering an account + POST /register + +Unregistering an account + POST /unregister + +Rooms +----- + +Creating a room by ID + PUT /rooms/$roomid + +Creating an anonymous room + POST /rooms + +Room topic + GET /rooms/$roomid/topic + PUT /rooms/$roomid/topic + +List rooms + GET /rooms/list + +Invite/Join/Leave + GET /rooms/$roomid/members/$userid/state + PUT /rooms/$roomid/members/$userid/state + DELETE /rooms/$roomid/members/$userid/state + +List members + GET /rooms/$roomid/members/list + +Sending/reading messages + PUT /rooms/$roomid/messages/$sender/$msgid + +Feedback + GET /rooms/$roomid/messages/$sender/$msgid/feedback/$feedbackuser/$feedback + PUT /rooms/$roomid/messages/$sender/$msgid/feedback/$feedbackuser/$feedback + +Paginating messages + GET /rooms/$roomid/messages/list + +Profiles +-------- + +Display name + GET /profile/$userid/displayname + PUT /profile/$userid/displayname + +Avatar URL + GET /profile/$userid/avatar_url + PUT /profile/$userid/avatar_url + +Metadata + GET /profile/$userid/metadata + POST /profile/$userid/metadata + +Presence +-------- + +My state or status message + GET /presence/$userid/status + PUT /presence/$userid/status + also 'GET' for fetching others + +TODO(paul): per-device idle time, device type; similar to above + +My presence list + GET /presence_list/$myuserid + POST /presence_list/$myuserid + body is JSON-encoded dict of keys: + invite: list of UserID strings to invite + drop: list of UserID strings to remove + TODO(paul): define other ops: accept, group management, ordering? + +Presence polling start/stop + POST /presence_list/$myuserid?op=start + POST /presence_list/$myuserid?op=stop + +Presence invite + POST /presence_list/$myuserid/invite/$targetuserid diff --git a/docs/code_style b/docs/code_style new file mode 100644 index 0000000000..d7e2d5e69e --- /dev/null +++ b/docs/code_style @@ -0,0 +1,18 @@ +Basically, PEP8 + +- Max line width: 80 chars. +- Use camel case for class and type names +- Use underscores for functions and variables. +- Use double quotes. +- Use parentheses instead of '\' for line continuation where ever possible (which is pretty much everywhere) +- There should be max a single new line between: + - statements + - functions in a class +- There should be two new lines between: + - definitions in a module (e.g., between different classes) +- There should be spaces where spaces should be and not where there shouldn't be: + - a single space after a comma + - a single space before and after for '=' when used as assignment + - no spaces before and after for '=' for default values and keyword arguments. + +Comments should follow the google code style. This is so that we can generate documentation with sphinx (http://sphinxcontrib-napoleon.readthedocs.org/en/latest/) diff --git a/docs/documentation_style b/docs/documentation_style new file mode 100644 index 0000000000..c365d09dff --- /dev/null +++ b/docs/documentation_style @@ -0,0 +1,43 @@ +=================== +Documentation Style +=================== + +A brief single sentence to describe what this file contains; in this case a +description of the style to write documentation in. + + +Sections +======== + +Each section should be separated from the others by two blank lines. Headings +should be underlined using a row of equals signs (===). Paragraphs should be +separated by a single blank line, and wrap to no further than 80 columns. + +[[TODO(username): if you want to leave some unanswered questions, notes for +further consideration, or other kinds of comment, use a TODO section. Make sure +to notate it with your name so we know who to ask about it!]] + +Subsections +----------- + +If required, subsections can use a row of dashes to underline their header. A +single blank line between subsections of a single section. + + +Bullet Lists +============ + + * Bullet lists can use asterisks with a single space either side. + + * Another blank line between list elements. + + +Definition Lists +================ + +Terms: + Start in the first column, ending with a colon + +Definitions: + Take a two space indent, following immediately from the term without a blank + line before it, but having a blank line afterwards. diff --git a/docs/model/presence b/docs/model/presence new file mode 100644 index 0000000000..7e54505364 --- /dev/null +++ b/docs/model/presence @@ -0,0 +1,249 @@ +======== +Presence +======== + +A description of presence information and visibility between users. + +Overview +======== + +Each user has the concept of Presence information. This encodes a sense of the +"availability" of that user, suitable for display on other user's clients. + + +Presence Information +==================== + +The basic piece of presence information is an enumeration of a small set of +state; such as "free to chat", "online", "busy", or "offline". The default state +unless the user changes it is "online". Lower states suggest some amount of +decreased availability from normal, which might have some client-side effect +like muting notification sounds and suggests to other users not to bother them +unless it is urgent. Equally, the "free to chat" state exists to let the user +announce their general willingness to receive messages moreso than default. + +Home servers should also allow a user to set their state as "hidden" - a state +which behaves as offline, but allows the user to see the client state anyway and +generally interact with client features such as reading message history or +accessing contacts in the address book. + +This basic state field applies to the user as a whole, regardless of how many +client devices they have connected. The home server should synchronise this +status choice among multiple devices to ensure the user gets a consistent +experience. + +Idle Time +--------- + +As well as the basic state field, the presence information can also show a sense +of an "idle timer". This should be maintained individually by the user's +clients, and the homeserver can take the highest reported time as that to +report. Likely this should be presented in fairly coarse granularity; possibly +being limited to letting the home server automatically switch from a "free to +chat" or "online" mode into "idle". + +When a user is offline, the Home Server can still report when the user was last +seen online, again perhaps in a somewhat coarse manner. + +Device Type +----------- + +Client devices that may limit the user experience somewhat (such as "mobile" +devices with limited ability to type on a real keyboard or read large amounts of +text) should report this to the home server, as this is also useful information +to report as "presence" if the user cannot be expected to provide a good typed +response to messages. + + +Presence List +============= + +Each user's home server stores a "presence list" for that user. This stores a +list of other user IDs the user has chosen to add to it (remembering any ACL +Pointer if appropriate). + +To be added to a contact list, the user being added must grant permission. Once +granted, both user's HS(es) store this information, as it allows the user who +has added the contact some more abilities; see below. Since such subscriptions +are likely to be bidirectional, HSes may wish to automatically accept requests +when a reverse subscription already exists. + +As a convenience, presence lists should support the ability to collect users +into groups, which could allow things like inviting the entire group to a new +("ad-hoc") chat room, or easy interaction with the profile information ACL +implementation of the HS. + + +Presence and Permissions +======================== + +For a viewing user to be allowed to see the presence information of a target +user, either + + * The target user has allowed the viewing user to add them to their presence + list, or + + * The two users share at least one room in common + +In the latter case, this allows for clients to display some minimal sense of +presence information in a user list for a room. + +Home servers can also use the user's choice of presence state as a signal for +how to handle new private one-to-one chat message requests. For example, it +might decide: + + "free to chat": accept anything + "online": accept from anyone in my addres book list + "busy": accept from anyone in this "important people" group in my address + book list + + +API Efficiency +============== + +A simple implementation of presence messaging has the ability to cause a large +amount of Internet traffic relating to presence updates. In order to minimise +the impact of such a feature, the following observations can be made: + + * There is no point in a Home Server polling status for peers in a user's + presence list if the user has no clients connected that care about it. + + * It is highly likely that most presence subscriptions will be symmetric - a + given user watching another is likely to in turn be watched by that user. + + * It is likely that most subscription pairings will be between users who share + at least one Room in common, and so their Home Servers are actively + exchanging message PDUs or transactions relating to that Room. + + * Presence update messages do not need realtime guarantees. It is acceptable to + delay delivery of updates for some small amount of time (10 seconds to a + minute). + +The general model of presence information is that of a HS registering its +interest in receiving presence status updates from other HSes, which then +promise to send them when required. Rather than actively polling for the +currentt state all the time, HSes can rely on their relative stability to only +push updates when required. + +A Home Server should not rely on the longterm validity of this presence +information, however, as this would not cover such cases as a user's server +crashing and thus failing to inform their peers that users it used to host are +no longer available online. Therefore, each promise of future updates should +carry with a timeout value (whether explicit in the message, or implicit as some +defined default in the protocol), after which the receiving HS should consider +the information potentially stale and request it again. + +However, because of the likelyhood that two home servers are exchanging messages +relating to chat traffic in a room common to both of them, the ongoing receipt +of these messages can be taken by each server as an implicit notification that +the sending server is still up and running, and therefore that no status changes +have happened; because if they had the server would have sent them. A second, +larger timeout should be applied to this implicit inference however, to protect +against implementation bugs or other reasons that the presence state cache may +become invalid; eventually the HS should re-enquire the current state of users +and update them with its own. + +The following workflows can therefore be used to handle presence updates: + + 1 When a user first appears online their HS sends a message to each other HS + containing at least one user to be watched; each message carrying both a + notification of the sender's new online status, and a request to obtain and + watch the target users' presence information. This message implicitly + promises the sending HS will now push updates to the target HSes. + + 2 The target HSes then respond a single message each, containing the current + status of the requested user(s). These messages too implicitly promise the + target HSes will themselves push updates to the sending HS. + + As these messages arrive at the sending user's HS they can be pushed to the + user's client(s), possibly batched again to ensure not too many small + messages which add extra protocol overheads. + +At this point, all the user's clients now have the current presence status +information for this moment in time, and have promised to send each other +updates in future. + + 3 The HS maintains two watchdog timers per peer HS it is exchanging presence + information with. The first timer should have a relatively small expiry + (perhaps 1 minute), and the second timer should have a much longer time + (perhaps 1 hour). + + 4 Any time any kind of message is received from a peer HS, the short-term + presence timer associated with it is reset. + + 5 Whenever either of these timers expires, an HS should push a status reminder + to the target HS whose timer has now expired, and request again from that + server the status of the subscribed users. + + 6 On receipt of one of these presence status reminders, an HS can reset both + of its presence watchdog timers. + +To avoid bursts of traffic, implementations should attempt to stagger the expiry +of the longer-term watchdog timers for different peer HSes. + +When individual users actively change their status (either by explicit requests +from clients, or inferred changes due to idle timers or client timeouts), the HS +should batch up any status changes for some reasonable amount of time (10 +seconds to a minute). This allows for reduced protocol overheads in the case of +multiple messages needing to be sent to the same peer HS; as is the likely +scenario in many cases, such as a given human user having multiple user +accounts. + + +API Requirements +================ + +The data model presented here puts the following requirements on the APIs: + +Client-Server +------------- + +Requests that a client can make to its Home Server + + * get/set current presence state + Basic enumeration + ability to set a custom piece of text + + * report per-device idle time + After some (configurable?) idle time the device should send a single message + to set the idle duration. The HS can then infer a "start of idle" instant and + use that to keep the device idleness up to date. At some later point the + device can cancel this idleness. + + * report per-device type + Inform the server that this device is a "mobile" device, or perhaps some + other to-be-defined category of reduced capability that could be presented to + other users. + + * start/stop presence polling for my presence list + It is likely that these messages could be implicitly inferred by other + messages, though having explicit control is always useful. + + * get my presence list + [implicit poll start?] + It is possible that the HS doesn't yet have current presence information when + the client requests this. There should be a "don't know" type too. + + * add/remove a user to my presence list + +Server-Server +------------- + +Requests that Home Servers make to others + + * request permission to add a user to presence list + + * allow/deny a request to add to a presence list + + * perform a combined presence state push and subscription request + For each sending user ID, the message contains their new status. + For each receiving user ID, the message should contain an indication on + whether the sending server is also interested in receiving status from that + user; either as an immediate update response now, or as a promise to send + future updates. + +Server to Client +---------------- + +[[TODO(paul): There also needs to be some way for a user's HS to push status +updates of the presence list to clients, but the general server-client event +model currently lacks a space to do that.]] diff --git a/docs/model/profiles b/docs/model/profiles new file mode 100644 index 0000000000..f7d6bd5679 --- /dev/null +++ b/docs/model/profiles @@ -0,0 +1,232 @@ +======== +Profiles +======== + +A description of Synapse user profile metadata support. + + +Overview +======== + +Internally within Synapse users are referred to by an opaque ID, which consists +of some opaque localpart combined with the domain name of their home server. +Obviously this does not yield a very nice user experience; users would like to +see readable names for other users that are in some way meaningful to them. +Additionally, users like to be able to publish "profile" details to inform other +users of other information about them. + +It is also conceivable that since we are attempting to provide a +worldwide-applicable messaging system, that users may wish to present different +subsets of information in their profile to different other people, from a +privacy and permissions perspective. + +A Profile consists of a display name, an (optional?) avatar picture, and a set +of other metadata fields that the user may wish to publish (email address, phone +numbers, website URLs, etc...). We put no requirements on the display name other +than it being a valid Unicode string. Since it is likely that users will end up +having multiple accounts (perhaps by necessity of being hosted in multiple +places, perhaps by choice of wanting multiple distinct identifies), it would be +useful that a metadata field type exists that can refer to another Synapse User +ID, so that clients and HSes can make use of this information. + +Metadata Fields +--------------- + +[[TODO(paul): Likely this list is incomplete; more fields can be defined as we +think of them. At the very least, any sort of supported ID for the 3rd Party ID +servers should be accounted for here.]] + + * Synapse Directory Server username(s) + + * Email address + + * Phone number - classify "home"/"work"/"mobile"/custom? + + * Twitter/Facebook/Google+/... social networks + + * Location - keep this deliberately vague to allow people to choose how + granular it is + + * "Bio" information - date of birth, etc... + + * Synapse User ID of another account + + * Web URL + + * Freeform description text + + +Visibility Permissions +====================== + +A home server implementation could offer the ability to set permissions on +limited visibility of those fields. When another user requests access to the +target user's profile, their own identity should form part of that request. The +HS implementation can then decide which fields to make available to the +requestor. + +A particular detail of implementation could allow the user to create one or more +ACLs; where each list is granted permission to see a given set of non-public +fields (compare to Google+ Circles) and contains a set of other people allowed +to use it. By giving these ACLs strong identities within the HS, they can be +referenced in communications with it, granting other users who encounter these +the "ACL Token" to use the details in that ACL. + +If we further allow an ACL Token to be present on Room join requests or stored +by 3PID servers, then users of these ACLs gain the extra convenience of not +having to manually curate people in the access list; anyone in the room or with +knowledge of the 3rd Party ID is automatically granted access. Every HS and +client implementation would have to be aware of the existence of these ACL +Token, and include them in requests if present, but not every HS implementation +needs to actually provide the full permissions model. This can be used as a +distinguishing feature among competing implementations. However, servers MUST +NOT serve profile information from a cache if there is a chance that its limited +understanding could lead to information leakage. + + +Client Concerns of Multiple Accounts +==================================== + +Because a given person may want to have multiple Synapse User accounts, client +implementations should allow the use of multiple accounts simultaneously +(especially in the field of mobile phone clients, which generally don't support +running distinct instances of the same application). Where features like address +books, presence lists or rooms are presented, the client UI should remember to +make distinct with user account is in use for each. + + +Directory Servers +================= + +Directory Servers can provide a forward mapping from human-readable names to +User IDs. These can provide a service similar to giving domain-namespaced names +for Rooms; in this case they can provide a way for a user to reference their +User ID in some external form (e.g. that can be printed on a business card). + +The format for Synapse user name will consist of a localpart specific to the +directory server, and the domain name of that directory server: + + @localname:some.domain.name + +The localname is separated from the domain name using a colon, so as to ensure +the localname can still contain periods, as users may want this for similarity +to email addresses or the like, which typically can contain them. The format is +also visually quite distinct from email addresses, phone numbers, etc... so +hopefully reasonably "self-describing" when written on e.g. a business card +without surrounding context. + +[[TODO(paul): we might have to think about this one - too close to email? + Twitter? Also it suggests a format scheme for room names of + #localname:domain.name, which I quite like]] + +Directory server administrators should be able to make some kind of policy +decision on how these are allocated. Servers within some "closed" domain (such +as company-specific ones) may wish to verify the validity of a mapping using +their own internal mechanisms; "public" naming servers can operate on a FCFS +basis. There are overlapping concerns here with the idea of the 3rd party +identity servers as well, though in this specific case we are creating a new +namespace to allocate names into. + +It would also be nice from a user experience perspective if the profile that a +given name links to can also declare that name as part of its metadata. +Furthermore as a security and consistency perspective it would be nice if each +end (the directory server and the user's home server) check the validity of the +mapping in some way. This needs investigation from a security perspective to +ensure against spoofing. + +One such model may be that the user starts by declaring their intent to use a +given user name link to their home server, which then contacts the directory +service. At some point later (maybe immediately for "public open FCFS servers", +maybe after some kind of human intervention for verification) the DS decides to +honour this link, and includes it in its served output. It should also tell the +HS of this fact, so that the HS can present this as fact when requested for the +profile information. For efficiency, it may further wish to provide the HS with +a cryptographically-signed certificate as proof, so the HS serving the profile +can provide that too when asked, avoiding requesting HSes from constantly having +to contact the DS to verify this mapping. (Note: This is similar to the security +model often applied in DNS to verify PTR <-> A bidirectional mappings). + + +Identity Servers +================ + +The identity servers should support the concept of pointing a 3PID being able to +store an ACL Token as well as the main User ID. It is however, beyond scope to +do any kind of verification that any third-party IDs that the profile is +claiming match up to the 3PID mappings. + + +User Interface and Expectations Concerns +======================================== + +Given the weak "security" of some parts of this model as compared to what users +might expect, some care should be taken on how it is presented to users, +specifically in the naming or other wording of user interface components. + +Most notably mere knowledge of an ACL Pointer is enough to read the information +stored in it. It is possible that Home or Identity Servers could leak this +information, allowing others to see it. This is a security-vs-convenience +balancing choice on behalf of the user who would choose, or not, to make use of +such a feature to publish their information. + +Additionally, unless some form of strong end-to-end user-based encryption is +used, a user of ACLs for information privacy has to trust other home servers not +to lie about the identify of the user requesting access to the Profile. + + +API Requirements +================ + +The data model presented here puts the following requirements on the APIs: + +Client-Server +------------- + +Requests that a client can make to its Home Server + + * get/set my Display Name + This should return/take a simple "text/plain" field + + * get/set my Avatar URL + The avatar image data itself is not stored by this API; we'll just store a + URL to let the clients fetch it. Optionally HSes could integrate this with + their generic content attacmhent storage service, allowing a user to set + upload their profile Avatar and update the URL to point to it. + + * get/add/remove my metadata fields + Also we need to actually define types of metadata + + * get another user's Display Name / Avatar / metadata fields + +[[TODO(paul): At some later stage we should consider the API for: + + * get/set ACL permissions on my metadata fields + + * manage my ACL tokens +]] + +Server-Server +------------- + +Requests that Home Servers make to others + + * get a user's Display Name / Avatar + + * get a user's full profile - name/avatar + MD fields + This request must allow for specifying the User ID of the requesting user, + for permissions purposes. It also needs to take into account any ACL Tokens + the requestor has. + + * push a change of Display Name to observers (overlaps with the presence API) + +Room Event PDU Types +-------------------- + +Events that are pushed from Home Servers to other Home Servers or clients. + + * user Display Name change + + * user Avatar change + [[TODO(paul): should the avatar image itself be stored in all the room + histories? maybe this event should just be a hint to clients that they should + re-fetch the avatar image]] diff --git a/docs/model/room-join-workflow b/docs/model/room-join-workflow new file mode 100644 index 0000000000..c321a64fab --- /dev/null +++ b/docs/model/room-join-workflow @@ -0,0 +1,113 @@ +================== +Room Join Workflow +================== + +An outline of the workflows required when a user joins a room. + +Discovery +========= + +To join a room, a user has to discover the room by some mechanism in order to +obtain the (opaque) Room ID and a candidate list of likely home servers that +contain it. + +Sending an Invitation +--------------------- + +The most direct way a user discovers the existence of a room is from a +invitation from some other user who is a member of that room. + +The inviter's HS sets the membership status of the invitee to "invited" in the +"m.members" state key by sending a state update PDU. The HS then broadcasts this +PDU among the existing members in the usual way. An invitation message is also +sent to the invited user, containing the Room ID and the PDU ID of this +invitation state change and potentially a list of some other home servers to use +to accept the invite. The user's client can then choose to display it in some +way to alert the user. + +[[TODO(paul): At present, no API has been designed or described to actually send +that invite to the invited user. Likely it will be some facet of the larger +user-user API required for presence, profile management, etc...]] + +Directory Service +----------------- + +Alternatively, the user may discover the channel via a directory service; either +by performing a name lookup, or some kind of browse or search acitivty. However +this is performed, the end result is that the user's home server requests the +Room ID and candidate list from the directory service. + +[[TODO(paul): At present, no API has been designed or described for this +directory service]] + + +Joining +======= + +Once the ID and home servers are obtained, the user can then actually join the +room. + +Accepting an Invite +------------------- + +If a user has received and accepted an invitation to join a room, the invitee's +home server can now send an invite acceptance message to a chosen candidate +server from the list given in the invitation, citing also the PDU ID of the +invitation as "proof" of their invite. (This is required as due to late message +propagation it could be the case that the acceptance is received before the +invite by some servers). If this message is allowed by the candidate server, it +generates a new PDU that updates the invitee's membership status to "joined", +referring back to the acceptance PDU, and broadcasts that as a state change in +the usual way. The newly-invited user is now a full member of the room, and +state propagation proceeds as usual. + +Joining a Public Room +--------------------- + +If a user has discovered the existence of a room they wish to join but does not +have an active invitation, they can request to join it directly by sending a +join message to a candidate server on the list provided by the directory +service. As this list may be out of date, the HS should be prepared to retry +other candidates if the chosen one is no longer aware of the room, because it +has no users as members in it. + +Once a candidate server that is aware of the room has been found, it can +broadcast an update PDU to add the member into the "m.members" key setting their +state directly to "joined" (i.e. bypassing the two-phase invite semantics), +remembering to include the new user's HS in that list. + +Knocking on a Semi-Public Room +------------------------------ + +If a user requests to join a room but the join mode of the room is "knock", the +join is not immediately allowed. Instead, if the user wishes to proceed, they +can instead post a "knock" message, which informs other members of the room that +the would-be joiner wishes to become a member and sets their membership value to +"knocked". If any of them wish to accept this, they can then send an invitation +in the usual way described above. Knowing that the user has already knocked and +expressed an interest in joining, the invited user's home server should +immediately accept that invitation on the user's behalf, and go on to join the +room in the usual way. + +[[NOTE(Erik): Though this may confuse users who expect 'X has joined' to +actually be a user initiated action, i.e. they may expect that 'X' is actually +looking at synapse right now?]] + +[[NOTE(paul): Yes, a fair point maybe we should suggest HSes don't do that, and +just offer an invite to the user as normal]] + +Private and Non-Existent Rooms +------------------------------ + +If a user requests to join a room but the room is either unknown by the home +server receiving the request, or is known by the join mode is "invite" and the +user has not been invited, the server must respond that the room does not exist. +This is to prevent leaking information about the existence and identity of +private rooms. + + +Outstanding Questions +===================== + + * Do invitations or knocks time out and expire at some point? If so when? Time + is hard in distributed systems. diff --git a/docs/model/rooms b/docs/model/rooms new file mode 100644 index 0000000000..0007e48e30 --- /dev/null +++ b/docs/model/rooms @@ -0,0 +1,274 @@ +=========== +Rooms Model +=========== + +A description of the general data model used to implement Rooms, and the +user-level visible effects and implications. + + +Overview +======== + +"Rooms" in Synapse are shared messaging channels over which all the participant +users can exchange messages. Rooms have an opaque persistent identify, a +globally-replicated set of state (consisting principly of a membership set of +users, and other management and miscellaneous metadata), and a message history. + + +Room Identity and Naming +======================== + +Rooms can be arbitrarily created by any user on any home server; at which point +the home server will sign the message that creates the channel, and the +fingerprint of this signature becomes the strong persistent identify of the +room. This now identifies the room to any home server in the network regardless +of its original origin. This allows the identify of the room to outlive any +particular server. Subject to appropriate permissions [to be discussed later], +any current member of a room can invite others to join it, can post messages +that become part of its history, and can change the persistent state of the room +(including its current set of permissions). + +Home servers can provide a directory service, allowing a lookup from a +convenient human-readable form of room label to a room ID. This mapping is +scoped to the particular home server domain and so simply represents that server +administrator's opinion of what room should take that label; it does not have to +be globally replicated and does not form part of the stored state of that room. + +This room name takes the form + + #localname:some.domain.name + +for similarity and consistency with user names on directories. + +To join a room (and therefore to be allowed to inspect past history, post new +messages to it, and read its state), a user must become aware of the room's +fingerprint ID. There are two mechanisms to allow this: + + * An invite message from someone else in the room + + * A referral from a room directory service + +As room IDs are opaque and ephemeral, they can serve as a mechanism to create +"ad-hoc" rooms deliberately unnamed, for small group-chats or even private +one-to-one message exchange. + + +Stored State and Permissions +============================ + +Every room has a globally-replicated set of stored state. This state is a set of +key/value or key/subkey/value pairs. The value of every (sub)key is a +JSON-representable object. The main key of a piece of stored state establishes +its meaning; some keys store sub-keys to allow a sub-structure within them [more +detail below]. Some keys have special meaning to Synapse, as they relate to +management details of the room itself, storing such details as user membership, +and permissions of users to alter the state of the room itself. Other keys may +store information to present to users, which the system does not directly rely +on. The key space itself is namespaced, allowing 3rd party extensions, subject +to suitable permission. + +Permission management is based on the concept of "power-levels". Every user +within a room has an integer assigned, being their "power-level" within that +room. Along with its actual data value, each key (or subkey) also stores the +minimum power-level a user must have in order to write to that key, the +power-level of the last user who actually did write to it, and the PDU ID of +that state change. + +To be accepted as valid, a change must NOT: + + * Be made by a user having a power-level lower than required to write to the + state key + + * Alter the required power-level for that state key to a value higher than the + user has + + * Increase that user's own power-level + + * Grant any other user a power-level higher than the level of the user making + the change + +[[TODO(paul): consider if relaxations should be allowed; e.g. is the current +outright-winner allowed to raise their own level, to allow for "inflation"?]] + + +Room State Keys +=============== + +[[TODO(paul): if this list gets too big it might become necessary to move it +into its own doc]] + +The following keys have special semantics or meaning to Synapse itself: + +m.member (has subkeys) + Stores a sub-key for every Synapse User ID which is currently a member of + this room. Its value gives the membership type ("knocked", "invited", + "joined"). + +m.power_levels + Stores a mapping from Synapse User IDs to their power-level in the room. If + they are not present in this mapping, the default applies. + + The reason to store this as a single value rather than a value with subkeys + is that updates to it are atomic; allowing a number of colliding-edit + problems to be avoided. + +m.default_level + Gives the default power-level for members of the room that do not have one + specified in their membership key. + +m.invite_level + If set, gives the minimum power-level required for members to invite others + to join, or to accept knock requests from non-members requesting access. If + absent, then invites are not allowed. An invitation involves setting their + membership type to "invited", in addition to sending the invite message. + +m.join_rules + Encodes the rules on how non-members can join the room. Has the following + possibilities: + "public" - a non-member can join the room directly + "knock" - a non-member cannot join the room, but can post a single "knock" + message requesting access, which existing members may approve or deny + "invite" - non-members cannot join the room without an invite from an + existing member + "private" - nobody who is not in the 'may_join' list or already a member + may join by any mechanism + + In any of the first three modes, existing members with sufficient permission + can send invites to non-members if allowed by the "m.invite_level" key. A + "private" room is not allowed to have the "m.invite_level" set. + + A client may use the value of this key to hint at the user interface + expectations to provide; in particular, a private chat with one other use + might warrant specific handling in the client. + +m.may_join + A list of User IDs that are always allowed to join the room, regardless of any + of the prevailing join rules and invite levels. These apply even to private + rooms. These are stored in a single list with normal update-powerlevel + permissions applied; users cannot arbitrarily remove themselves from the list. + +m.add_state_level + The power-level required for a user to be able to add new state keys. + +m.public_history + If set and true, anyone can request the history of the room, without needing + to be a member of the room. + +m.archive_servers + For "public" rooms with public history, gives a list of home servers that + should be included in message distribution to the room, even if no users on + that server are present. These ensure that a public room can still persist + even if no users are currently members of it. This list should be consulted by + the dirctory servers as the candidate list they respond with. + +The following keys are provided by Synapse for user benefit, but their value is +not otherwise used by Synapse. + +m.name + Stores a short human-readable name for the room, such that clients can display + to a user to assist in identifying which room is which. + + This name specifically is not the strong ID used by the message transport + system to refer to the room, because it may be changed from time to time. + +m.topic + Stores the current human-readable topic + + +Room Creation Templates +======================= + +A client (or maybe home server?) could offer a few templates for the creation of +new rooms. For example, for a simple private one-to-one chat the channel could +assign the creator a power-level of 1, requiring a level of 1 to invite, and +needing an invite before members can join. An invite is then sent to the other +party, and if accepted and the other user joins, the creator's power-level can +now be reduced to 0. This now leaves a room with two participants in it being +unable to add more. + + +Rooms that Continue History +=========================== + +An option that could be considered for room creation, is that when a new room is +created the creator could specify a PDU ID into an existing room, as the history +continuation point. This would be stored as an extra piece of meta-data on the +initial PDU of the room's creation. (It does not appear in the normal previous +PDU linkage). + +This would allow users in rooms to "fork" a room, if it is considered that the +conversations in the room no longer fit its original purpose, and wish to +diverge. Existing permissions on the original room would continue to apply of +course, for viewing that history. If both rooms are considered "public" we might +also want to define a message to post into the original room to represent this +fork point, and give a reference to the new room. + + +User Direct Message Rooms +========================= + +There is no need to build a mechanism for directly sending messages between +users, because a room can handle this ability. To allow direct user-to-user chat +messaging we simply need to be able to create rooms with specific set of +permissions to allow this direct messaging. + +Between any given pair of user IDs that wish to exchange private messages, there +will exist a single shared Room, created lazily by either side. These rooms will +need a certain amount of special handling in both home servers and display on +clients, but as much as possible should be treated by the lower layers of code +the same as other rooms. + +Specially, a client would likely offer a special menu choice associated with +another user (in room member lists, presence list, etc..) as "direct chat". That +would perform all the necessary steps to create the private chat room. Receiving +clients should display these in a special way too as the room name is not +important; instead it should distinguish them on the Display Name of the other +party. + +Home Servers will need a client-API option to request setting up a new user-user +chat room, which will then need special handling within the server. It will +create a new room with the following + + m.member: the proposing user + m.join_rules: "private" + m.may_join: both users + m.power_levels: empty + m.default_level: 0 + m.add_state_level: 0 + m.public_history: False + +Having created the room, it can send an invite message to the other user in the +normal way - the room permissions state that no users can be set to the invited +state, but because they're in the may_join list then they'd be allowed to join +anyway. + +In this arrangement there is now a room with both users may join but neither has +the power to invite any others. Both users now have the confidence that (at +least within the messaging system itself) their messages remain private and +cannot later be provably leaked to a third party. They can freely set the topic +or name if they choose and add or edit any other state of the room. The update +powerlevel of each of these fixed properties should be 1, to lock out the users +from being able to alter them. + + +Anti-Glare +========== + +There exists the possibility of a race condition if two users who have no chat +history with each other simultaneously create a room and invite the other to it. +This is called a "glare" situation. There are two possible ideas for how to +resolve this: + + * Each Home Server should persist the mapping of (user ID pair) to room ID, so + that duplicate requests can be suppressed. On receipt of a room creation + request that the HS thinks there already exists a room for, the invitation to + join can be rejected if: + a) the HS believes the sending user is already a member of the room (and + maybe their HS has forgotten this fact), or + b) the proposed room has a lexicographically-higher ID than the existing + room (to resolve true race condition conflicts) + + * The room ID for a private 1:1 chat has a special form, determined by + concatenting the User IDs of both members in a deterministic order, such that + it doesn't matter which side creates it first; the HSes can just ignore + (or merge?) received PDUs that create the room twice. diff --git a/docs/model/third-party-id b/docs/model/third-party-id new file mode 100644 index 0000000000..1f8138ddf7 --- /dev/null +++ b/docs/model/third-party-id @@ -0,0 +1,108 @@ +====================== +Third Party Identities +====================== + +A description of how email addresses, mobile phone numbers and other third +party identifiers can be used to authenticate and discover users in Matrix. + + +Overview +======== + +New users need to authenticate their account. An email or SMS text message can +be a convenient form of authentication. Users already have email addresses +and phone numbers for contacts in their address book. They want to communicate +with those contacts in Matrix without manually exchanging a Matrix User ID with +them. + +Third Party IDs +--------------- + +[[TODO(markjh): Describe the format of a 3PID]] + + +Third Party ID Associations +--------------------------- + +An Associaton is a binding between a Matrix User ID and a Third Party ID (3PID). +Each 3PID can be associated with one Matrix User ID at a time. + +[[TODO(markjh): JSON format of the association.]] + +Verification +------------ + +An Assocation must be verified by a trusted Verification Server. Email +addresses and phone numbers can be verified by sending a token to the address +which a client can supply to the verifier to confirm ownership. + +An email Verification Server may be capable of verifying all email 3PIDs or may +be restricted to verifying addresses for a particular domain. A phone number +Verification Server may be capable of verifying all phone numbers or may be +restricted to verifying numbers for a given country or phone prefix. + +Verification Servers fulfil a similar role to Certificate Authorities in PKI so +a similar level of vetting should be required before clients trust their +signatures. + +A Verification Server may wish to check for existing Associations for a 3PID +before creating a new Association. + +Discovery +--------- + +Users can discover Associations using a trusted Identity Server. Each +Association will be signed by the Identity Server. An Identity Server may store +the entire space of Associations or may delegate to other Identity Servers when +looking up Associations. + +Each Association returned from an Identity Server must be signed by a +Verification Server. Clients should check these signatures. + +Identity Servers fulfil a similar role to DNS servers. + +Privacy +------- + +A User may publish the association between their phone number and Matrix User ID +on the Identity Server without publishing the number in their Profile hosted on +their Home Server. + +Identity Servers should refrain from publishing reverse mappings and should +take steps, such as rate limiting, to prevent attackers enumerating the space of +mappings. + +Federation +========== + +Delegation +---------- + +Verification Servers could delegate signing to another server by issuing +certificate to that server allowing it to verify and sign a subset of 3PID on +its behalf. It would be necessary to provide a language for describing which +subset of 3PIDs that server had authority to validate. Alternatively it could +delegate the verification step to another server but sign the resulting +association itself. + +The 3PID space will have a heirachical structure like DNS so Identity Servers +can delegate lookups to other servers. An Identity Server should be prepared +to host or delegate any valid association within the subset of the 3PIDs it is +resonsible for. + +Multiple Root Verification Servers +---------------------------------- + +There can be multiple root Verification Servers and an Association could be +signed by multiple servers if different clients trust different subsets of +the verification servers. + +Multiple Root Identity Servers +------------------------------ + +There can be be multiple root Identity Servers. Clients will add each +Association to all root Identity Servers. + +[[TODO(markjh): Describe how clients find the list of root Identity Servers]] + + diff --git a/docs/protocol_examples b/docs/protocol_examples new file mode 100644 index 0000000000..61a599b432 --- /dev/null +++ b/docs/protocol_examples @@ -0,0 +1,64 @@ +PUT /send/abc/ HTTP/1.1 +Host: ... +Content-Length: ... +Content-Type: application/json + +{ + "origin": "localhost:5000", + "pdus": [ + { + "content": {}, + "context": "tng", + "depth": 12, + "is_state": false, + "origin": "localhost:5000", + "pdu_id": 1404381396854, + "pdu_type": "feedback", + "prev_pdus": [ + [ + "1404381395883", + "localhost:6000" + ] + ], + "ts": 1404381427581 + } + ], + "prev_ids": [ + "1404381396852" + ], + "ts": 1404381427823 +} + +HTTP/1.1 200 OK +... + +====================================== + +GET /pull/-1/ HTTP/1.1 +Host: ... +Content-Length: 0 + +HTTP/1.1 200 OK +Content-Length: ... +Content-Type: application/json + +{ + origin: ..., + prev_ids: ..., + data: [ + { + data_id: ..., + prev_pdus: [...], + depth: ..., + ts: ..., + context: ..., + origin: ..., + content: { + ... + } + }, + ..., + ] +} + + diff --git a/docs/python_architecture b/docs/python_architecture new file mode 100644 index 0000000000..eca36a1902 --- /dev/null +++ b/docs/python_architecture @@ -0,0 +1,53 @@ += Server to Server = + +== Server to Server Stack == + +To use the server to server stack, home servers should only need to interact with the Messaging layer. + +The server to server side of things is designed into 4 distinct layers: + + 1. Messaging Layer + 2. Pdu Layer + 3. Transaction Layer + 4. Transport Layer + +Where the bottom (the transport layer) is what talks to the internet via HTTP, and the top (the messaging layer) talks to the rest of the Home Server with a domain specific API. + +1. Messaging Layer + This is what the rest of the Home Server hits to send messages, join rooms, etc. It also allows you to register callbacks for when it get's notified by lower levels that e.g. a new message has been received. + + It is responsible for serializing requests to send to the data layer, and to parse requests received from the data layer. + + +2. PDU Layer + This layer handles: + * duplicate pdu_id's - i.e., it makes sure we ignore them. + * responding to requests for a given pdu_id + * responding to requests for all metadata for a given context (i.e. room) + * handling incoming pagination requests + + So it has to parse incoming messages to discover which are metadata and which aren't, and has to correctly clobber existing metadata where appropriate. + + For incoming PDUs, it has to check the PDUs it references to see if we have missed any. If we have go and ask someone (another home server) for it. + + +3. Transaction Layer + This layer makes incoming requests idempotent. I.e., it stores which transaction id's we have seen and what our response were. If we have already seen a message with the given transaction id, we do not notify higher levels but simply respond with the previous response. + +transaction_id is from "GET /send//" + + It's also responsible for batching PDUs into single transaction for sending to remote destinations, so that we only ever have one transaction in flight to a given destination at any one time. + + This is also responsible for answering requests for things after a given set of transactions, i.e., ask for everything after 'ver' X. + + +4. Transport Layer + This is responsible for starting a HTTP server and hitting the correct callbacks on the Transaction layer, as well as sending both data and requests for data. + + +== Persistence == + +We persist things in a single sqlite3 database. All database queries get run on a separate, dedicated thread. This that we only ever have one query running at a time, making it a lot easier to do things in a safe manner. + +The queries are located in the synapse.persistence.transactions module, and the table information in the synapse.persistence.tables module. + diff --git a/docs/server-server/protocol-format b/docs/server-server/protocol-format new file mode 100644 index 0000000000..2838253ab7 --- /dev/null +++ b/docs/server-server/protocol-format @@ -0,0 +1,59 @@ + +Transaction +=========== + +Required keys: + +============ =================== =============================================== + Key Type Description +============ =================== =============================================== +origin String DNS name of homeserver making this transaction. +ts Integer Timestamp in milliseconds on originating + homeserver when this transaction started. +previous_ids List of Strings List of transactions that were sent immediately + prior to this transaction. +pdus List of Objects List of updates contained in this transaction. +============ =================== =============================================== + + +PDU +=== + +Required keys: + +============ ================== ================================================ + Key Type Description +============ ================== ================================================ +context String Event context identifier +origin String DNS name of homeserver that created this PDU. +pdu_id String Unique identifier for PDU within the context for + the originating homeserver. +ts Integer Timestamp in milliseconds on originating + homeserver when this PDU was created. +pdu_type String PDU event type. +prev_pdus List of Pairs The originating homeserver and PDU ids of the + of Strings most recent PDUs the homeserver was aware of for + this context when it made this PDU. +depth Integer The maximum depth of the previous PDUs plus one. +============ ================== ================================================ + +Keys for state updates: + +================== ============ ================================================ + Key Type Description +================== ============ ================================================ +is_state Boolean True if this PDU is updating state. +state_key String Optional key identifying the updated state within + the context. +power_level Integer The asserted power level of the user performing + the update. +min_update Integer The required power level needed to replace this + update. +prev_state_id String The homeserver of the update this replaces +prev_state_origin String The PDU id of the update this replaces. +user String The user updating the state. +================== ============ ================================================ + + + + diff --git a/docs/server-server/security-threat-model b/docs/server-server/security-threat-model new file mode 100644 index 0000000000..cf0430e43d --- /dev/null +++ b/docs/server-server/security-threat-model @@ -0,0 +1,141 @@ +Overview +======== + +Scope +----- + +This document considers threats specific to the server to server federation +synapse protocol. + + +Attacker +-------- + +It is assumed that the attacker can see and manipulate all network traffic +between any of the servers and may be in control of one or more homeservers +participating in the federation protocol. + +Threat Model +============ + +Denial of Service +----------------- + +The attacker could attempt to prevent delivery of messages to or from the +victim in order to: + + * Disrupt service or marketing campaign of a commercial competitor. + * Censor a discussion or censor a participant in a discussion. + * Perform general vandalism. + +Threat: Resource Exhaustion +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could cause the victims server to exhaust a particular resource +(e.g. open TCP connections, CPU, memory, disk storage) + +Threat: Unrecoverable Consistency Violations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could send messages which created an unrecoverable "split-brain" +state in the cluster such that the victim's servers could no longer dervive a +consistent view of the chatroom state. + +Threat: Bad History +~~~~~~~~~~~~~~~~~~~ + +An attacker could convince the victim to accept invalid messages which the +victim would then include in their view of the chatroom history. Other servers +in the chatroom would reject the invalid messages and potentially reject the +victims messages as well since they depended on the invalid messages. + +Threat: Block Network Traffic +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could try to firewall traffic between the victim's server and some +or all of the other servers in the chatroom. + +Threat: High Volume of Messages +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could send large volumes of messages to a chatroom with the victim +making the chatroom unusable. + +Threat: Banning users without necessary authorisation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could attempt to ban a user from a chatroom with the necessary +authorisation. + +Spoofing +-------- + +An attacker could try to send a message claiming to be from the victim without +the victim having sent the message in order to: + + * Impersonate the victim while performing illict activity. + * Obtain privileges of the victim. + +Threat: Altering Message Contents +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could try to alter the contents of an existing message from the +victim. + +Threat: Fake Message "origin" Field +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could try to send a new message purporting to be from the victim +with a phony "origin" field. + +Spamming +-------- + +The attacker could try to send a high volume of solicicted or unsolicted +messages to the victim in order to: + + * Find victims for scams. + * Market unwanted products. + +Threat: Unsoliticted Messages +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could try to send messages to victims who do not wish to receive +them. + +Threat: Abusive Messages +~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could send abusive or threatening messages to the victim + +Spying +------ + +The attacker could try to access message contents or metadata for messages sent +by the victim or to the victim that were not intended to reach the attacker in +order to: + + * Gain sensitive personal or commercial information. + * Impersonate the victim using credentials contained in the messages. + (e.g. password reset messages) + * Discover who the victim was talking to and when. + +Threat: Disclosure during Transmission +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could try to expose the message contents or metadata during +transmission between the servers. + +Threat: Disclosure to Servers Outside Chatroom +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could try to convince servers within a chatroom to send messages to +a server it controls that was not authorised to be within the chatroom. + +Threat: Disclosure to Servers Within Chatroom +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An attacker could take control of a server within a chatroom to expose message +contents or metadata for messages in that room. + + diff --git a/docs/server-server/specification b/docs/server-server/specification new file mode 100644 index 0000000000..e1e49cc069 --- /dev/null +++ b/docs/server-server/specification @@ -0,0 +1,177 @@ +============================ +Synapse Server-to-Server API +============================ + +A description of the protocol used to communicate between Synapse home servers; +also known as Federation. + + +Overview +======== + +The server-server API is a mechanism by which two home servers can exchange +Synapse event messages, both as a real-time push of current events, and as a +historic fetching mechanism to synchronise past history for clients to view. It +uses HTTP connections between each pair of servers involved as the underlying +transport. Messages are exchanged between servers in real-time by active pushing +from each server's HTTP client into the server of the other. Queries to fetch +historic data for the purpose of back-filling scrollback buffers and the like +can also be performed. + + + { Synapse entities } { Synapse entities } + ^ | ^ | + | events | | events | + | V | V + +------------------+ +------------------+ + | |---------( HTTP )---------->| | + | Home Server | | Home Server | + | |<--------( HTTP )-----------| | + +------------------+ +------------------+ + + +Transactions and PDUs +===================== + +The communication between home servers is performed by a bidirectional exchange +of messages. These messages are called Transactions, and are encoded as JSON +objects with a dict as the top-level element, passed over HTTP. A Transaction is +meaningful only to the pair of home servers that exchanged it; they are not +globally-meaningful. + +Each transaction has an opaque ID and timestamp (UNIX epoch time in miliseconds) +generated by its origin server, an origin and destination server name, a list of +"previous IDs", and a list of PDUs - the actual message payload that the +Transaction carries. + + {"transaction_id":"916d630ea616342b42e98a3be0b74113", + "ts":1404835423000, + "origin":"red", + "destination":"blue", + "prev_ids":["e1da392e61898be4d2009b9fecce5325"], + "pdus":[...]} + +The "previous IDs" field will contain a list of previous transaction IDs that +the origin server has sent to this destination. Its purpose is to act as a +sequence checking mechanism - the destination server can check whether it has +successfully received that Transaction, or ask for a retransmission if not. + +The "pdus" field of a transaction is a list, containing zero or more PDUs.[*] +Each PDU is itself a dict containing a number of keys, the exact details of +which will vary depending on the type of PDU. + +(* Normally the PDU list will be non-empty, but the server should cope with +receiving an "empty" transaction, as this is useful for informing peers of other +transaction IDs they should be aware of. This effectively acts as a push +mechanism to encourage peers to continue to replicate content.) + +All PDUs have an ID, a context, a declaration of their type, a list of other PDU +IDs that have been seen recently on that context (regardless of which origin +sent them), and a nested content field containing the actual event content. + +[[TODO(paul): Update this structure so that 'pdu_id' is a two-element +[origin,ref] pair like the prev_pdus are]] + + {"pdu_id":"a4ecee13e2accdadf56c1025af232176", + "context":"#example.green", + "origin":"green", + "ts":1404838188000, + "pdu_type":"m.text", + "prev_pdus":[["blue","99d16afbc857975916f1d73e49e52b65"]], + "content":... + "is_state":false} + +In contrast to the transaction layer, it is important to note that the prev_pdus +field of a PDU refers to PDUs that any origin server has sent, rather than +previous IDs that this origin has sent. This list may refer to other PDUs sent +by the same origin as the current one, or other origins. + +Because of the distributed nature of participants in a Synapse conversation, it +is impossible to establish a globally-consistent total ordering on the events. +However, by annotating each outbound PDU at its origin with IDs of other PDUs it +has received, a partial ordering can be constructed allowing causallity +relationships to be preserved. A client can then display these messages to the +end-user in some order consistent with their content and ensure that no message +that is semantically in reply of an earlier one is ever displayed before it. + +PDUs fall into two main categories: those that deliver Events, and those that +synchronise State. For PDUs that relate to State synchronisation, additional +keys exist to support this: + + {..., + "is_state":true, + "state_key":TODO + "power_level":TODO + "prev_state_id":TODO + "prev_state_origin":TODO} + +[[TODO(paul): At this point we should probably have a long description of how +State management works, with descriptions of clobbering rules, power levels, etc +etc... But some of that detail is rather up-in-the-air, on the whiteboard, and +so on. This part needs refining. And writing in its own document as the details +relate to the server/system as a whole, not specifically to server-server +federation.]] + + +Protocol URLs +============= + +For active pushing of messages representing live activity "as it happens": + + PUT /send/:transaction_id/ + Body: JSON encoding of a single Transaction + + Response: [[TODO(paul): I don't actually understand what + ReplicationLayer.on_transaction() is doing here, so I'm not sure what the + response ought to be]] + + The transaction_id path argument will override any ID given in the JSON body. + The destination name will be set to that of the receiving server itself. Each + embedded PDU in the transaction body will be processed. + + +To fetch a particular PDU: + + GET /pdu/:origin/:pdu_id/ + + Response: JSON encoding of a single Transaction containing one PDU + + Retrieves a given PDU from the server. The response will contain a single new + Transaction, inside which will be the requested PDU. + + +To fetch all the state of a given context: + + GET /state/:context/ + + Response: JSON encoding of a single Transaction containing multiple PDUs + + Retrieves a snapshot of the entire current state of the given context. The + response will contain a single Transaction, inside which will be a list of + PDUs that encode the state. + + +To paginate events on a given context: + + GET /paginate/:context/ + Query args: v, limit + + Response: JSON encoding of a single Transaction containing multiple PDUs + + Retrieves a sliding-window history of previous PDUs that occurred on the + given context. Starting from the PDU ID(s) given in the "v" argument, the + PDUs that preceeded it are retrieved, up to a total number given by the + "limit" argument. These are then returned in a new Transaction containing all + off the PDUs. + + +To stream events all the events: + + GET /pull/ + Query args: origin, v + + Response: JSON encoding of a single Transaction consisting of multiple PDUs + + Retrieves all of the transactions later than any version given by the "v" + arguments. [[TODO(paul): I'm not sure what the "origin" argument does because + I think at some point in the code it's got swapped around.]] diff --git a/docs/sphinx/conf.py b/docs/sphinx/conf.py new file mode 100644 index 0000000000..15c19834fc --- /dev/null +++ b/docs/sphinx/conf.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +# +# Synapse documentation build configuration file, created by +# sphinx-quickstart on Tue Jun 10 17:31:02 2014. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.abspath('..')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.coverage', + 'sphinx.ext.ifconfig', + 'sphinxcontrib.napoleon', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Synapse' +copyright = u'2014, TNG' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.0' +# The full version, including alpha/beta/rc tags. +release = '1.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Synapsedoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'Synapse.tex', u'Synapse Documentation', + u'TNG', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'synapse', u'Synapse Documentation', + [u'TNG'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'Synapse', u'Synapse Documentation', + u'TNG', 'Synapse', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'http://docs.python.org/': None} + +napoleon_include_special_with_doc = True +napoleon_use_ivar = True diff --git a/docs/sphinx/index.rst b/docs/sphinx/index.rst new file mode 100644 index 0000000000..76a4c0c7bf --- /dev/null +++ b/docs/sphinx/index.rst @@ -0,0 +1,20 @@ +.. Synapse documentation master file, created by + sphinx-quickstart on Tue Jun 10 17:31:02 2014. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Synapse's documentation! +=================================== + +Contents: + +.. toctree:: + synapse + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/sphinx/modules.rst b/docs/sphinx/modules.rst new file mode 100644 index 0000000000..1c7f70bd13 --- /dev/null +++ b/docs/sphinx/modules.rst @@ -0,0 +1,7 @@ +synapse +======= + +.. toctree:: + :maxdepth: 4 + + synapse diff --git a/docs/sphinx/synapse.api.auth.rst b/docs/sphinx/synapse.api.auth.rst new file mode 100644 index 0000000000..931eb59836 --- /dev/null +++ b/docs/sphinx/synapse.api.auth.rst @@ -0,0 +1,7 @@ +synapse.api.auth module +======================= + +.. automodule:: synapse.api.auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.constants.rst b/docs/sphinx/synapse.api.constants.rst new file mode 100644 index 0000000000..a1e3c47f68 --- /dev/null +++ b/docs/sphinx/synapse.api.constants.rst @@ -0,0 +1,7 @@ +synapse.api.constants module +============================ + +.. automodule:: synapse.api.constants + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.dbobjects.rst b/docs/sphinx/synapse.api.dbobjects.rst new file mode 100644 index 0000000000..e9d31167e0 --- /dev/null +++ b/docs/sphinx/synapse.api.dbobjects.rst @@ -0,0 +1,7 @@ +synapse.api.dbobjects module +============================ + +.. automodule:: synapse.api.dbobjects + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.errors.rst b/docs/sphinx/synapse.api.errors.rst new file mode 100644 index 0000000000..f1c6881478 --- /dev/null +++ b/docs/sphinx/synapse.api.errors.rst @@ -0,0 +1,7 @@ +synapse.api.errors module +========================= + +.. automodule:: synapse.api.errors + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.event_stream.rst b/docs/sphinx/synapse.api.event_stream.rst new file mode 100644 index 0000000000..9291cb2dbc --- /dev/null +++ b/docs/sphinx/synapse.api.event_stream.rst @@ -0,0 +1,7 @@ +synapse.api.event_stream module +=============================== + +.. automodule:: synapse.api.event_stream + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.events.factory.rst b/docs/sphinx/synapse.api.events.factory.rst new file mode 100644 index 0000000000..2e71ff6070 --- /dev/null +++ b/docs/sphinx/synapse.api.events.factory.rst @@ -0,0 +1,7 @@ +synapse.api.events.factory module +================================= + +.. automodule:: synapse.api.events.factory + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.events.room.rst b/docs/sphinx/synapse.api.events.room.rst new file mode 100644 index 0000000000..6cd5998599 --- /dev/null +++ b/docs/sphinx/synapse.api.events.room.rst @@ -0,0 +1,7 @@ +synapse.api.events.room module +============================== + +.. automodule:: synapse.api.events.room + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.events.rst b/docs/sphinx/synapse.api.events.rst new file mode 100644 index 0000000000..b762da55ee --- /dev/null +++ b/docs/sphinx/synapse.api.events.rst @@ -0,0 +1,18 @@ +synapse.api.events package +========================== + +Submodules +---------- + +.. toctree:: + + synapse.api.events.factory + synapse.api.events.room + +Module contents +--------------- + +.. automodule:: synapse.api.events + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.handlers.events.rst b/docs/sphinx/synapse.api.handlers.events.rst new file mode 100644 index 0000000000..d2e1b54ac0 --- /dev/null +++ b/docs/sphinx/synapse.api.handlers.events.rst @@ -0,0 +1,7 @@ +synapse.api.handlers.events module +================================== + +.. automodule:: synapse.api.handlers.events + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.handlers.factory.rst b/docs/sphinx/synapse.api.handlers.factory.rst new file mode 100644 index 0000000000..b04a93f740 --- /dev/null +++ b/docs/sphinx/synapse.api.handlers.factory.rst @@ -0,0 +1,7 @@ +synapse.api.handlers.factory module +=================================== + +.. automodule:: synapse.api.handlers.factory + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.handlers.federation.rst b/docs/sphinx/synapse.api.handlers.federation.rst new file mode 100644 index 0000000000..61a6542210 --- /dev/null +++ b/docs/sphinx/synapse.api.handlers.federation.rst @@ -0,0 +1,7 @@ +synapse.api.handlers.federation module +====================================== + +.. automodule:: synapse.api.handlers.federation + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.handlers.register.rst b/docs/sphinx/synapse.api.handlers.register.rst new file mode 100644 index 0000000000..388f144eca --- /dev/null +++ b/docs/sphinx/synapse.api.handlers.register.rst @@ -0,0 +1,7 @@ +synapse.api.handlers.register module +==================================== + +.. automodule:: synapse.api.handlers.register + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.handlers.room.rst b/docs/sphinx/synapse.api.handlers.room.rst new file mode 100644 index 0000000000..8ca156c7ff --- /dev/null +++ b/docs/sphinx/synapse.api.handlers.room.rst @@ -0,0 +1,7 @@ +synapse.api.handlers.room module +================================ + +.. automodule:: synapse.api.handlers.room + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.handlers.rst b/docs/sphinx/synapse.api.handlers.rst new file mode 100644 index 0000000000..e84f563fcb --- /dev/null +++ b/docs/sphinx/synapse.api.handlers.rst @@ -0,0 +1,21 @@ +synapse.api.handlers package +============================ + +Submodules +---------- + +.. toctree:: + + synapse.api.handlers.events + synapse.api.handlers.factory + synapse.api.handlers.federation + synapse.api.handlers.register + synapse.api.handlers.room + +Module contents +--------------- + +.. automodule:: synapse.api.handlers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.notifier.rst b/docs/sphinx/synapse.api.notifier.rst new file mode 100644 index 0000000000..631b42a497 --- /dev/null +++ b/docs/sphinx/synapse.api.notifier.rst @@ -0,0 +1,7 @@ +synapse.api.notifier module +=========================== + +.. automodule:: synapse.api.notifier + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.register_events.rst b/docs/sphinx/synapse.api.register_events.rst new file mode 100644 index 0000000000..79ad4ce211 --- /dev/null +++ b/docs/sphinx/synapse.api.register_events.rst @@ -0,0 +1,7 @@ +synapse.api.register_events module +================================== + +.. automodule:: synapse.api.register_events + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.room_events.rst b/docs/sphinx/synapse.api.room_events.rst new file mode 100644 index 0000000000..bead1711f5 --- /dev/null +++ b/docs/sphinx/synapse.api.room_events.rst @@ -0,0 +1,7 @@ +synapse.api.room_events module +============================== + +.. automodule:: synapse.api.room_events + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.rst b/docs/sphinx/synapse.api.rst new file mode 100644 index 0000000000..f4d39ff331 --- /dev/null +++ b/docs/sphinx/synapse.api.rst @@ -0,0 +1,30 @@ +synapse.api package +=================== + +Subpackages +----------- + +.. toctree:: + + synapse.api.events + synapse.api.handlers + synapse.api.streams + +Submodules +---------- + +.. toctree:: + + synapse.api.auth + synapse.api.constants + synapse.api.errors + synapse.api.notifier + synapse.api.storage + +Module contents +--------------- + +.. automodule:: synapse.api + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.server.rst b/docs/sphinx/synapse.api.server.rst new file mode 100644 index 0000000000..b01600235e --- /dev/null +++ b/docs/sphinx/synapse.api.server.rst @@ -0,0 +1,7 @@ +synapse.api.server module +========================= + +.. automodule:: synapse.api.server + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.storage.rst b/docs/sphinx/synapse.api.storage.rst new file mode 100644 index 0000000000..afa40685c4 --- /dev/null +++ b/docs/sphinx/synapse.api.storage.rst @@ -0,0 +1,7 @@ +synapse.api.storage module +========================== + +.. automodule:: synapse.api.storage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.stream.rst b/docs/sphinx/synapse.api.stream.rst new file mode 100644 index 0000000000..0d5e3f01bf --- /dev/null +++ b/docs/sphinx/synapse.api.stream.rst @@ -0,0 +1,7 @@ +synapse.api.stream module +========================= + +.. automodule:: synapse.api.stream + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.streams.event.rst b/docs/sphinx/synapse.api.streams.event.rst new file mode 100644 index 0000000000..2ac45a35c8 --- /dev/null +++ b/docs/sphinx/synapse.api.streams.event.rst @@ -0,0 +1,7 @@ +synapse.api.streams.event module +================================ + +.. automodule:: synapse.api.streams.event + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.api.streams.rst b/docs/sphinx/synapse.api.streams.rst new file mode 100644 index 0000000000..72eb205caf --- /dev/null +++ b/docs/sphinx/synapse.api.streams.rst @@ -0,0 +1,17 @@ +synapse.api.streams package +=========================== + +Submodules +---------- + +.. toctree:: + + synapse.api.streams.event + +Module contents +--------------- + +.. automodule:: synapse.api.streams + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.app.homeserver.rst b/docs/sphinx/synapse.app.homeserver.rst new file mode 100644 index 0000000000..54b93da8fe --- /dev/null +++ b/docs/sphinx/synapse.app.homeserver.rst @@ -0,0 +1,7 @@ +synapse.app.homeserver module +============================= + +.. automodule:: synapse.app.homeserver + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.app.rst b/docs/sphinx/synapse.app.rst new file mode 100644 index 0000000000..4535b79827 --- /dev/null +++ b/docs/sphinx/synapse.app.rst @@ -0,0 +1,17 @@ +synapse.app package +=================== + +Submodules +---------- + +.. toctree:: + + synapse.app.homeserver + +Module contents +--------------- + +.. automodule:: synapse.app + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.db.rst b/docs/sphinx/synapse.db.rst new file mode 100644 index 0000000000..83df6c03db --- /dev/null +++ b/docs/sphinx/synapse.db.rst @@ -0,0 +1,10 @@ +synapse.db package +================== + +Module contents +--------------- + +.. automodule:: synapse.db + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.handler.rst b/docs/sphinx/synapse.federation.handler.rst new file mode 100644 index 0000000000..5597f5c46d --- /dev/null +++ b/docs/sphinx/synapse.federation.handler.rst @@ -0,0 +1,7 @@ +synapse.federation.handler module +================================= + +.. automodule:: synapse.federation.handler + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.messaging.rst b/docs/sphinx/synapse.federation.messaging.rst new file mode 100644 index 0000000000..4bbaabf3ef --- /dev/null +++ b/docs/sphinx/synapse.federation.messaging.rst @@ -0,0 +1,7 @@ +synapse.federation.messaging module +=================================== + +.. automodule:: synapse.federation.messaging + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.pdu_codec.rst b/docs/sphinx/synapse.federation.pdu_codec.rst new file mode 100644 index 0000000000..8f0b15a63c --- /dev/null +++ b/docs/sphinx/synapse.federation.pdu_codec.rst @@ -0,0 +1,7 @@ +synapse.federation.pdu_codec module +=================================== + +.. automodule:: synapse.federation.pdu_codec + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.persistence.rst b/docs/sphinx/synapse.federation.persistence.rst new file mode 100644 index 0000000000..db7ab8ade1 --- /dev/null +++ b/docs/sphinx/synapse.federation.persistence.rst @@ -0,0 +1,7 @@ +synapse.federation.persistence module +===================================== + +.. automodule:: synapse.federation.persistence + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.replication.rst b/docs/sphinx/synapse.federation.replication.rst new file mode 100644 index 0000000000..49e26e0928 --- /dev/null +++ b/docs/sphinx/synapse.federation.replication.rst @@ -0,0 +1,7 @@ +synapse.federation.replication module +===================================== + +.. automodule:: synapse.federation.replication + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.rst b/docs/sphinx/synapse.federation.rst new file mode 100644 index 0000000000..7240c7901b --- /dev/null +++ b/docs/sphinx/synapse.federation.rst @@ -0,0 +1,22 @@ +synapse.federation package +========================== + +Submodules +---------- + +.. toctree:: + + synapse.federation.handler + synapse.federation.pdu_codec + synapse.federation.persistence + synapse.federation.replication + synapse.federation.transport + synapse.federation.units + +Module contents +--------------- + +.. automodule:: synapse.federation + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.transport.rst b/docs/sphinx/synapse.federation.transport.rst new file mode 100644 index 0000000000..877956b3c9 --- /dev/null +++ b/docs/sphinx/synapse.federation.transport.rst @@ -0,0 +1,7 @@ +synapse.federation.transport module +=================================== + +.. automodule:: synapse.federation.transport + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.federation.units.rst b/docs/sphinx/synapse.federation.units.rst new file mode 100644 index 0000000000..8f9212b07d --- /dev/null +++ b/docs/sphinx/synapse.federation.units.rst @@ -0,0 +1,7 @@ +synapse.federation.units module +=============================== + +.. automodule:: synapse.federation.units + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.persistence.rst b/docs/sphinx/synapse.persistence.rst new file mode 100644 index 0000000000..37c0c23720 --- /dev/null +++ b/docs/sphinx/synapse.persistence.rst @@ -0,0 +1,19 @@ +synapse.persistence package +=========================== + +Submodules +---------- + +.. toctree:: + + synapse.persistence.service + synapse.persistence.tables + synapse.persistence.transactions + +Module contents +--------------- + +.. automodule:: synapse.persistence + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.persistence.service.rst b/docs/sphinx/synapse.persistence.service.rst new file mode 100644 index 0000000000..3514d3c76f --- /dev/null +++ b/docs/sphinx/synapse.persistence.service.rst @@ -0,0 +1,7 @@ +synapse.persistence.service module +================================== + +.. automodule:: synapse.persistence.service + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.persistence.tables.rst b/docs/sphinx/synapse.persistence.tables.rst new file mode 100644 index 0000000000..907b02769d --- /dev/null +++ b/docs/sphinx/synapse.persistence.tables.rst @@ -0,0 +1,7 @@ +synapse.persistence.tables module +================================= + +.. automodule:: synapse.persistence.tables + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.persistence.transactions.rst b/docs/sphinx/synapse.persistence.transactions.rst new file mode 100644 index 0000000000..475c02a8c5 --- /dev/null +++ b/docs/sphinx/synapse.persistence.transactions.rst @@ -0,0 +1,7 @@ +synapse.persistence.transactions module +======================================= + +.. automodule:: synapse.persistence.transactions + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.rest.base.rst b/docs/sphinx/synapse.rest.base.rst new file mode 100644 index 0000000000..84d2d9b31d --- /dev/null +++ b/docs/sphinx/synapse.rest.base.rst @@ -0,0 +1,7 @@ +synapse.rest.base module +======================== + +.. automodule:: synapse.rest.base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.rest.events.rst b/docs/sphinx/synapse.rest.events.rst new file mode 100644 index 0000000000..ebbe26c746 --- /dev/null +++ b/docs/sphinx/synapse.rest.events.rst @@ -0,0 +1,7 @@ +synapse.rest.events module +========================== + +.. automodule:: synapse.rest.events + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.rest.register.rst b/docs/sphinx/synapse.rest.register.rst new file mode 100644 index 0000000000..a4a48a8a8f --- /dev/null +++ b/docs/sphinx/synapse.rest.register.rst @@ -0,0 +1,7 @@ +synapse.rest.register module +============================ + +.. automodule:: synapse.rest.register + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.rest.room.rst b/docs/sphinx/synapse.rest.room.rst new file mode 100644 index 0000000000..63fc5c2840 --- /dev/null +++ b/docs/sphinx/synapse.rest.room.rst @@ -0,0 +1,7 @@ +synapse.rest.room module +======================== + +.. automodule:: synapse.rest.room + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.rest.rst b/docs/sphinx/synapse.rest.rst new file mode 100644 index 0000000000..016af926b2 --- /dev/null +++ b/docs/sphinx/synapse.rest.rst @@ -0,0 +1,20 @@ +synapse.rest package +==================== + +Submodules +---------- + +.. toctree:: + + synapse.rest.base + synapse.rest.events + synapse.rest.register + synapse.rest.room + +Module contents +--------------- + +.. automodule:: synapse.rest + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.rst b/docs/sphinx/synapse.rst new file mode 100644 index 0000000000..e7869e0e5d --- /dev/null +++ b/docs/sphinx/synapse.rst @@ -0,0 +1,30 @@ +synapse package +=============== + +Subpackages +----------- + +.. toctree:: + + synapse.api + synapse.app + synapse.federation + synapse.persistence + synapse.rest + synapse.util + +Submodules +---------- + +.. toctree:: + + synapse.server + synapse.state + +Module contents +--------------- + +.. automodule:: synapse + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.server.rst b/docs/sphinx/synapse.server.rst new file mode 100644 index 0000000000..7f33f084d7 --- /dev/null +++ b/docs/sphinx/synapse.server.rst @@ -0,0 +1,7 @@ +synapse.server module +===================== + +.. automodule:: synapse.server + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.state.rst b/docs/sphinx/synapse.state.rst new file mode 100644 index 0000000000..744be2a8be --- /dev/null +++ b/docs/sphinx/synapse.state.rst @@ -0,0 +1,7 @@ +synapse.state module +==================== + +.. automodule:: synapse.state + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.util.async.rst b/docs/sphinx/synapse.util.async.rst new file mode 100644 index 0000000000..542bb54444 --- /dev/null +++ b/docs/sphinx/synapse.util.async.rst @@ -0,0 +1,7 @@ +synapse.util.async module +========================= + +.. automodule:: synapse.util.async + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.util.dbutils.rst b/docs/sphinx/synapse.util.dbutils.rst new file mode 100644 index 0000000000..afaa9eb749 --- /dev/null +++ b/docs/sphinx/synapse.util.dbutils.rst @@ -0,0 +1,7 @@ +synapse.util.dbutils module +=========================== + +.. automodule:: synapse.util.dbutils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.util.http.rst b/docs/sphinx/synapse.util.http.rst new file mode 100644 index 0000000000..344af5a490 --- /dev/null +++ b/docs/sphinx/synapse.util.http.rst @@ -0,0 +1,7 @@ +synapse.util.http module +======================== + +.. automodule:: synapse.util.http + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.util.lockutils.rst b/docs/sphinx/synapse.util.lockutils.rst new file mode 100644 index 0000000000..16ee26cabd --- /dev/null +++ b/docs/sphinx/synapse.util.lockutils.rst @@ -0,0 +1,7 @@ +synapse.util.lockutils module +============================= + +.. automodule:: synapse.util.lockutils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.util.logutils.rst b/docs/sphinx/synapse.util.logutils.rst new file mode 100644 index 0000000000..2b79fa7a4b --- /dev/null +++ b/docs/sphinx/synapse.util.logutils.rst @@ -0,0 +1,7 @@ +synapse.util.logutils module +============================ + +.. automodule:: synapse.util.logutils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.util.rst b/docs/sphinx/synapse.util.rst new file mode 100644 index 0000000000..01a0c3a591 --- /dev/null +++ b/docs/sphinx/synapse.util.rst @@ -0,0 +1,21 @@ +synapse.util package +==================== + +Submodules +---------- + +.. toctree:: + + synapse.util.async + synapse.util.http + synapse.util.lockutils + synapse.util.logutils + synapse.util.stringutils + +Module contents +--------------- + +.. automodule:: synapse.util + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sphinx/synapse.util.stringutils.rst b/docs/sphinx/synapse.util.stringutils.rst new file mode 100644 index 0000000000..ec626eee28 --- /dev/null +++ b/docs/sphinx/synapse.util.stringutils.rst @@ -0,0 +1,7 @@ +synapse.util.stringutils module +=============================== + +.. automodule:: synapse.util.stringutils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/terminology b/docs/terminology new file mode 100644 index 0000000000..575cc0c808 --- /dev/null +++ b/docs/terminology @@ -0,0 +1,86 @@ +=========== +Terminology +=========== + +A list of definitions of specific terminology used among these documents. +These terms were originally taken from the server-server documentation, and may +not currently match the exact meanings used in other places; though as a +medium-term goal we should encourage the unification of this terminology. + + +Terms +===== + +Context: + A single human-level entity of interest (currently, a chat room) + +EDU (Ephemeral Data Unit): + A message that relates directly to a given pair of home servers that are + exchanging it. EDUs are short-lived messages that related only to one single + pair of servers; they are not persisted for a long time and are not forwarded + on to other servers. Because of this, they have no internal ID nor previous + EDUs reference chain. + +Event: + A record of activity that records a single thing that happened on to a context + (currently, a chat room). These are the "chat messages" that Synapse makes + available. + [[NOTE(paul): The current server-server implementation calls these simply + "messages" but the term is too ambiguous here; I've called them Events]] + +Pagination: + The process of synchronising historic state from one home server to another, + to backfill the event storage so that scrollback can be presented to the + client(s). + +PDU (Persistent Data Unit): + A message that relates to a single context, irrespective of the server that + is communicating it. PDUs either encode a single Event, or a single State + change. A PDU is referred to by its PDU ID; the pair of its origin server + and local reference from that server. + +PDU ID: + The pair of PDU Origin and PDU Reference, that together globally uniquely + refers to a specific PDU. + +PDU Origin: + The name of the origin server that generated a given PDU. This may not be the + server from which it has been received, due to the way they are copied around + from server to server. The origin always records the original server that + created it. + +PDU Reference: + A local ID used to refer to a specific PDU from a given origin server. These + references are opaque at the protocol level, but may optionally have some + structured meaning within a given origin server or implementation. + +Presence: + The concept of whether a user is currently online, how available they declare + they are, and so on. See also: doc/model/presence + +Profile: + A set of metadata about a user, such as a display name, provided for the + benefit of other users. See also: doc/model/profiles + +Room ID: + An opaque string (of as-yet undecided format) that identifies a particular + room and used in PDUs referring to it. + +Room Alias: + A human-readable string of the form #name:some.domain that users can use as a + pointer to identify a room; a Directory Server will map this to its Room ID + +State: + A set of metadata maintained about a Context, which is replicated among the + servers in addition to the history of Events. + +User ID: + A string of the form @localpart:domain.name that identifies a user for + wire-protocol purposes. The localpart is meaningless outside of a particular + home server. This takes a human-readable form that end-users can use directly + if they so wish, avoiding the 3PIDs. + +Transaction: + A message which relates to the communication between a given pair of servers. + A transaction contains possibly-empty lists of PDUs and EDUs. + diff --git a/docs/versioning b/docs/versioning new file mode 100644 index 0000000000..2f94bb6ef6 --- /dev/null +++ b/docs/versioning @@ -0,0 +1,11 @@ +Versioning is, like, hard for paginating backwards because of the number of Home Servers involved. + +The way we solve this is by doing versioning as an acyclic directed graph of PDUs. For pagination purposes, this is done on a per context basis. +When we send a PDU we include all PDUs that have been received for that context that hasn't been subsequently listed in a later PDU. The trivial case is a simple list of PDUs, e.g. A <- B <- C. However, if two servers send out a PDU at the same to, both B and C would point at A - a later PDU would then list both B and C. + +Problems with opaque version strings: + - How do you do clustering without mandating that a cluster can only have one transaction in flight to a given remote home server at a time. + If you have multiple transactions sent at once, then you might drop one transaction, receive anotherwith a version that is later than the dropped transaction and which point ARGH WE LOST A TRANSACTION. + - How do you do pagination? A version string defines a point in a stream w.r.t. a single home server, not a point in the context. + +We only need to store the ends of the directed graph, we DO NOT need to do the whole one table of nodes and one of edges. diff --git a/example/cursesio.py b/example/cursesio.py new file mode 100644 index 0000000000..83b4a9f186 --- /dev/null +++ b/example/cursesio.py @@ -0,0 +1,154 @@ +import curses +import curses.wrapper +from curses.ascii import isprint + +from twisted.internet import reactor + + +class CursesStdIO(): + def __init__(self, stdscr, callback=None): + self.statusText = "Synapse test app -" + self.searchText = '' + self.stdscr = stdscr + + self.logLine = '' + + self.callback = callback + + self._setup() + + def _setup(self): + self.stdscr.nodelay(1) # Make non blocking + + self.rows, self.cols = self.stdscr.getmaxyx() + self.lines = [] + + curses.use_default_colors() + + self.paintStatus(self.statusText) + self.stdscr.refresh() + + def set_callback(self, callback): + self.callback = callback + + def fileno(self): + """ We want to select on FD 0 """ + return 0 + + def connectionLost(self, reason): + self.close() + + def print_line(self, text): + """ add a line to the internal list of lines""" + + self.lines.append(text) + self.redraw() + + def print_log(self, text): + self.logLine = text + self.redraw() + + def redraw(self): + """ method for redisplaying lines + based on internal list of lines """ + + self.stdscr.clear() + self.paintStatus(self.statusText) + i = 0 + index = len(self.lines) - 1 + while i < (self.rows - 3) and index >= 0: + self.stdscr.addstr(self.rows - 3 - i, 0, self.lines[index], + curses.A_NORMAL) + i = i + 1 + index = index - 1 + + self.printLogLine(self.logLine) + + self.stdscr.refresh() + + def paintStatus(self, text): + if len(text) > self.cols: + raise RuntimeError("TextTooLongError") + + self.stdscr.addstr( + self.rows - 2, 0, + text + ' ' * (self.cols - len(text)), + curses.A_STANDOUT) + + def printLogLine(self, text): + self.stdscr.addstr( + 0, 0, + text + ' ' * (self.cols - len(text)), + curses.A_STANDOUT) + + def doRead(self): + """ Input is ready! """ + curses.noecho() + c = self.stdscr.getch() # read a character + + if c == curses.KEY_BACKSPACE: + self.searchText = self.searchText[:-1] + + elif c == curses.KEY_ENTER or c == 10: + text = self.searchText + self.searchText = '' + + self.print_line(">> %s" % text) + + try: + if self.callback: + self.callback.on_line(text) + except Exception as e: + self.print_line(str(e)) + + self.stdscr.refresh() + + elif isprint(c): + if len(self.searchText) == self.cols - 2: + return + self.searchText = self.searchText + chr(c) + + self.stdscr.addstr(self.rows - 1, 0, + self.searchText + (' ' * ( + self.cols - len(self.searchText) - 2))) + + self.paintStatus(self.statusText + ' %d' % len(self.searchText)) + self.stdscr.move(self.rows - 1, len(self.searchText)) + self.stdscr.refresh() + + def logPrefix(self): + return "CursesStdIO" + + def close(self): + """ clean up """ + + curses.nocbreak() + self.stdscr.keypad(0) + curses.echo() + curses.endwin() + + +class Callback(object): + + def __init__(self, stdio): + self.stdio = stdio + + def on_line(self, text): + self.stdio.print_line(text) + + +def main(stdscr): + screen = CursesStdIO(stdscr) # create Screen object + + callback = Callback(screen) + + screen.set_callback(callback) + + stdscr.refresh() + reactor.addReader(screen) + reactor.run() + screen.close() + + +if __name__ == '__main__': + curses.wrapper(main) diff --git a/example/test_messaging.py b/example/test_messaging.py new file mode 100644 index 0000000000..9a45425173 --- /dev/null +++ b/example/test_messaging.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- + +""" This is an example of using the server to server implementation to do a +basic chat style thing. It accepts commands from stdin and outputs to stdout. + +It assumes that ucids are of the form @, and uses as +the address of the remote home server to hit. + +Usage: + python test_messaging.py + +Currently assumes the local address is localhost: + +""" + + +from synapse.federation import ( + ReplicationHandler +) + +from synapse.federation.units import Pdu + +from synapse.util import origin_from_ucid + +from synapse.app.homeserver import SynapseHomeServer + +#from synapse.util.logutils import log_function + +from twisted.internet import reactor, defer +from twisted.python import log + +import argparse +import json +import logging +import os +import re + +import cursesio +import curses.wrapper + + +logger = logging.getLogger("example") + + +def excpetion_errback(failure): + logging.exception(failure) + + +class InputOutput(object): + """ This is responsible for basic I/O so that a user can interact with + the example app. + """ + + def __init__(self, screen, user): + self.screen = screen + self.user = user + + def set_home_server(self, server): + self.server = server + + def on_line(self, line): + """ This is where we process commands. + """ + + try: + m = re.match("^join (\S+)$", line) + if m: + # The `sender` wants to join a room. + room_name, = m.groups() + self.print_line("%s joining %s" % (self.user, room_name)) + self.server.join_room(room_name, self.user, self.user) + #self.print_line("OK.") + return + + m = re.match("^invite (\S+) (\S+)$", line) + if m: + # `sender` wants to invite someone to a room + room_name, invitee = m.groups() + self.print_line("%s invited to %s" % (invitee, room_name)) + self.server.invite_to_room(room_name, self.user, invitee) + #self.print_line("OK.") + return + + m = re.match("^send (\S+) (.*)$", line) + if m: + # `sender` wants to message a room + room_name, body = m.groups() + self.print_line("%s send to %s" % (self.user, room_name)) + self.server.send_message(room_name, self.user, body) + #self.print_line("OK.") + return + + m = re.match("^paginate (\S+)$", line) + if m: + # we want to paginate a room + room_name, = m.groups() + self.print_line("paginate %s" % room_name) + self.server.paginate(room_name) + return + + self.print_line("Unrecognized command") + + except Exception as e: + logger.exception(e) + + def print_line(self, text): + self.screen.print_line(text) + + def print_log(self, text): + self.screen.print_log(text) + + +class IOLoggerHandler(logging.Handler): + + def __init__(self, io): + logging.Handler.__init__(self) + self.io = io + + def emit(self, record): + if record.levelno < logging.WARN: + return + + msg = self.format(record) + self.io.print_log(msg) + + +class Room(object): + """ Used to store (in memory) the current membership state of a room, and + which home servers we should send PDUs associated with the room to. + """ + def __init__(self, room_name): + self.room_name = room_name + self.invited = set() + self.participants = set() + self.servers = set() + + self.oldest_server = None + + self.have_got_metadata = False + + def add_participant(self, participant): + """ Someone has joined the room + """ + self.participants.add(participant) + self.invited.discard(participant) + + server = origin_from_ucid(participant) + self.servers.add(server) + + if not self.oldest_server: + self.oldest_server = server + + def add_invited(self, invitee): + """ Someone has been invited to the room + """ + self.invited.add(invitee) + self.servers.add(origin_from_ucid(invitee)) + + +class HomeServer(ReplicationHandler): + """ A very basic home server implentation that allows people to join a + room and then invite other people. + """ + def __init__(self, server_name, replication_layer, output): + self.server_name = server_name + self.replication_layer = replication_layer + self.replication_layer.set_handler(self) + + self.joined_rooms = {} + + self.output = output + + def on_receive_pdu(self, pdu): + """ We just received a PDU + """ + pdu_type = pdu.pdu_type + + if pdu_type == "sy.room.message": + self._on_message(pdu) + elif pdu_type == "sy.room.member" and "membership" in pdu.content: + if pdu.content["membership"] == "join": + self._on_join(pdu.context, pdu.state_key) + elif pdu.content["membership"] == "invite": + self._on_invite(pdu.origin, pdu.context, pdu.state_key) + else: + self.output.print_line("#%s (unrec) %s = %s" % + (pdu.context, pdu.pdu_type, json.dumps(pdu.content)) + ) + + #def on_state_change(self, pdu): + ##self.output.print_line("#%s (state) %s *** %s" % + ##(pdu.context, pdu.state_key, pdu.pdu_type) + ##) + + #if "joinee" in pdu.content: + #self._on_join(pdu.context, pdu.content["joinee"]) + #elif "invitee" in pdu.content: + #self._on_invite(pdu.origin, pdu.context, pdu.content["invitee"]) + + def _on_message(self, pdu): + """ We received a message + """ + self.output.print_line("#%s %s %s" % + (pdu.context, pdu.content["sender"], pdu.content["body"]) + ) + + def _on_join(self, context, joinee): + """ Someone has joined a room, either a remote user or a local user + """ + room = self._get_or_create_room(context) + room.add_participant(joinee) + + self.output.print_line("#%s %s %s" % + (context, joinee, "*** JOINED") + ) + + def _on_invite(self, origin, context, invitee): + """ Someone has been invited + """ + room = self._get_or_create_room(context) + room.add_invited(invitee) + + self.output.print_line("#%s %s %s" % + (context, invitee, "*** INVITED") + ) + + if not room.have_got_metadata and origin is not self.server_name: + logger.debug("Get room state") + self.replication_layer.get_state_for_context(origin, context) + room.have_got_metadata = True + + @defer.inlineCallbacks + def send_message(self, room_name, sender, body): + """ Send a message to a room! + """ + destinations = yield self.get_servers_for_context(room_name) + + try: + yield self.replication_layer.send_pdu( + Pdu.create_new( + context=room_name, + pdu_type="sy.room.message", + content={"sender": sender, "body": body}, + origin=self.server_name, + destinations=destinations, + ) + ) + except Exception as e: + logger.exception(e) + + @defer.inlineCallbacks + def join_room(self, room_name, sender, joinee): + """ Join a room! + """ + self._on_join(room_name, joinee) + + destinations = yield self.get_servers_for_context(room_name) + + try: + pdu = Pdu.create_new( + context=room_name, + pdu_type="sy.room.member", + is_state=True, + state_key=joinee, + content={"membership": "join"}, + origin=self.server_name, + destinations=destinations, + ) + yield self.replication_layer.send_pdu(pdu) + except Exception as e: + logger.exception(e) + + @defer.inlineCallbacks + def invite_to_room(self, room_name, sender, invitee): + """ Invite someone to a room! + """ + self._on_invite(self.server_name, room_name, invitee) + + destinations = yield self.get_servers_for_context(room_name) + + try: + yield self.replication_layer.send_pdu( + Pdu.create_new( + context=room_name, + is_state=True, + pdu_type="sy.room.member", + state_key=invitee, + content={"membership": "invite"}, + origin=self.server_name, + destinations=destinations, + ) + ) + except Exception as e: + logger.exception(e) + + def paginate(self, room_name, limit=5): + room = self.joined_rooms.get(room_name) + + if not room: + return + + dest = room.oldest_server + + return self.replication_layer.paginate(dest, room_name, limit) + + def _get_room_remote_servers(self, room_name): + return [i for i in self.joined_rooms.setdefault(room_name,).servers] + + def _get_or_create_room(self, room_name): + return self.joined_rooms.setdefault(room_name, Room(room_name)) + + def get_servers_for_context(self, context): + return defer.succeed( + self.joined_rooms.setdefault(context, Room(context)).servers + ) + + +def main(stdscr): + parser = argparse.ArgumentParser() + parser.add_argument('user', type=str) + parser.add_argument('-v', '--verbose', action='count') + args = parser.parse_args() + + user = args.user + server_name = origin_from_ucid(user) + + ## Set up logging ## + + root_logger = logging.getLogger() + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(lineno)d - ' + '%(levelname)s - %(message)s') + if not os.path.exists("logs"): + os.makedirs("logs") + fh = logging.FileHandler("logs/%s" % user) + fh.setFormatter(formatter) + + root_logger.addHandler(fh) + root_logger.setLevel(logging.DEBUG) + + # Hack: The only way to get it to stop logging to sys.stderr :( + log.theLogPublisher.observers = [] + observer = log.PythonLoggingObserver() + observer.start() + + ## Set up synapse server + + curses_stdio = cursesio.CursesStdIO(stdscr) + input_output = InputOutput(curses_stdio, user) + + curses_stdio.set_callback(input_output) + + app_hs = SynapseHomeServer(server_name, db_name="dbs/%s" % user) + replication = app_hs.get_replication_layer() + + hs = HomeServer(server_name, replication, curses_stdio) + + input_output.set_home_server(hs) + + ## Add input_output logger + io_logger = IOLoggerHandler(input_output) + io_logger.setFormatter(formatter) + root_logger.addHandler(io_logger) + + ## Start! ## + + try: + port = int(server_name.split(":")[1]) + except: + port = 12345 + + app_hs.get_http_server().start_listening(port) + + reactor.addReader(curses_stdio) + + reactor.run() + + +if __name__ == "__main__": + curses.wrapper(main) diff --git a/graph/graph.py b/graph/graph.py new file mode 100644 index 0000000000..c600773412 --- /dev/null +++ b/graph/graph.py @@ -0,0 +1,137 @@ + +import sqlite3 +import pydot +import cgi +import json +import datetime +import argparse +import urllib2 + + +def make_name(pdu_id, origin): + return "%s@%s" % (pdu_id, origin) + + +def make_graph(pdus, room, filename_prefix): + pdu_map = {} + node_map = {} + + origins = set() + colors = set(("red", "green", "blue", "yellow", "purple")) + + for pdu in pdus: + origins.add(pdu.get("origin")) + + color_map = {color: color for color in colors if color in origins} + colors -= set(color_map.values()) + + color_map[None] = "black" + + for o in origins: + if o in color_map: + continue + try: + c = colors.pop() + color_map[o] = c + except: + print "Run out of colours!" + color_map[o] = "black" + + graph = pydot.Dot(graph_name="Test") + + for pdu in pdus: + name = make_name(pdu.get("pdu_id"), pdu.get("origin")) + pdu_map[name] = pdu + + t = datetime.datetime.fromtimestamp( + float(pdu["ts"]) / 1000 + ).strftime('%Y-%m-%d %H:%M:%S,%f') + + label = ( + "<" + "%(name)s
" + "Type: %(type)s
" + "State key: %(state_key)s
" + "Content: %(content)s
" + "Time: %(time)s
" + "Depth: %(depth)s
" + ">" + ) % { + "name": name, + "type": pdu.get("pdu_type"), + "state_key": pdu.get("state_key"), + "content": cgi.escape(json.dumps(pdu.get("content")), quote=True), + "time": t, + "depth": pdu.get("depth"), + } + + node = pydot.Node( + name=name, + label=label, + color=color_map[pdu.get("origin")] + ) + node_map[name] = node + graph.add_node(node) + + for pdu in pdus: + start_name = make_name(pdu.get("pdu_id"), pdu.get("origin")) + for i, o in pdu.get("prev_pdus", []): + end_name = make_name(i, o) + + if end_name not in node_map: + print "%s not in nodes" % end_name + continue + + edge = pydot.Edge(node_map[start_name], node_map[end_name]) + graph.add_edge(edge) + + # Add prev_state edges, if they exist + if pdu.get("prev_state_id") and pdu.get("prev_state_origin"): + prev_state_name = make_name( + pdu.get("prev_state_id"), pdu.get("prev_state_origin") + ) + + if prev_state_name in node_map: + state_edge = pydot.Edge( + node_map[start_name], node_map[prev_state_name], + style='dotted' + ) + graph.add_edge(state_edge) + + graph.write('%s.dot' % filename_prefix, format='raw', prog='dot') + graph.write_png("%s.png" % filename_prefix, prog='dot') + graph.write_svg("%s.svg" % filename_prefix, prog='dot') + + +def get_pdus(host, room): + transaction = json.loads( + urllib2.urlopen( + "http://%s/context/%s/" % (host, room) + ).read() + ) + + return transaction["pdus"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Generate a PDU graph for a given room by talking " + "to the given homeserver to get the list of PDUs. \n" + "Requires pydot." + ) + parser.add_argument( + "-p", "--prefix", dest="prefix", + help="String to prefix output files with" + ) + parser.add_argument('host') + parser.add_argument('room') + + args = parser.parse_args() + + host = args.host + room = args.room + prefix = args.prefix if args.prefix else "%s_graph" % (room) + + pdus = get_pdus(host, room) + + make_graph(pdus, room, prefix) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000..2830831f00 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,10 @@ +[build_sphinx] +source-dir = docs/sphinx +build-dir = docs/build +all_files = 1 + +[aliases] +test = trial + +[trial] +test_suite = tests diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..189b33b91a --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +import os +from setuptools import setup, find_packages + + +# Utility function to read the README file. +# Used for the long_description. It's nice, because now 1) we have a top level +# README file and 2) it's easier to type in the README file than to put a raw +# string in below ... +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +setup( + name="SynapseHomeServer", + version="0.1", + packages=find_packages(exclude=["tests"]), + description="Reference Synapse Home Server", + install_requires=[ + "syutil==0.0.1", + "Twisted>=14.0.0", + "service_identity>=1.0.0", + "pyasn1", + "pynacl", + "daemonize", + "py-bcrypt", + ], + dependency_links=[ + "git+ssh://git@git.openmarket.com/tng/syutil.git#egg=syutil-0.0.1", + ], + setup_requires=[ + "setuptools_trial", + "setuptools>=1.0.0", # Needs setuptools that supports git+ssh. It's not obvious when support for this was introduced. + "mock" + ], + include_package_data=True, + long_description=read("README.rst"), + entry_points=""" + [console_scripts] + synapse-homeserver=synapse.app.homeserver:run + """ +) diff --git a/sphinx_api_docs.sh b/sphinx_api_docs.sh new file mode 100644 index 0000000000..ee72b29657 --- /dev/null +++ b/sphinx_api_docs.sh @@ -0,0 +1 @@ +sphinx-apidoc -o docs/sphinx/ synapse/ -ef diff --git a/synapse/__init__.py b/synapse/__init__.py new file mode 100644 index 0000000000..aa760fb341 --- /dev/null +++ b/synapse/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" This is a reference implementation of a synapse home server. +""" diff --git a/synapse/api/__init__.py b/synapse/api/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/api/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/api/auth.py b/synapse/api/auth.py new file mode 100644 index 0000000000..5c66a7261f --- /dev/null +++ b/synapse/api/auth.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module contains classes for authenticating the user.""" +from twisted.internet import defer + +from synapse.api.constants import Membership +from synapse.api.errors import AuthError, StoreError +from synapse.api.events.room import (RoomTopicEvent, RoomMemberEvent, + MessageEvent, FeedbackEvent) + +import logging + +logger = logging.getLogger(__name__) + + +class Auth(object): + + def __init__(self, hs): + self.hs = hs + self.store = hs.get_datastore() + + @defer.inlineCallbacks + def check(self, event, raises=False): + """ Checks if this event is correctly authed. + + Returns: + True if the auth checks pass. + Raises: + AuthError if there was a problem authorising this event. This will + be raised only if raises=True. + """ + try: + if event.type in [RoomTopicEvent.TYPE, MessageEvent.TYPE, + FeedbackEvent.TYPE]: + yield self.check_joined_room(event.room_id, event.user_id) + defer.returnValue(True) + elif event.type == RoomMemberEvent.TYPE: + allowed = yield self.is_membership_change_allowed(event) + defer.returnValue(allowed) + else: + raise AuthError(500, "Unknown event type %s" % event.type) + except AuthError as e: + logger.info("Event auth check failed on event %s with msg: %s", + event, e.msg) + if raises: + raise e + defer.returnValue(False) + + @defer.inlineCallbacks + def check_joined_room(self, room_id, user_id): + try: + member = yield self.store.get_room_member( + room_id=room_id, + user_id=user_id + ) + if not member or member.membership != Membership.JOIN: + raise AuthError(403, "User %s not in room %s" % + (user_id, room_id)) + defer.returnValue(member) + except AttributeError: + pass + defer.returnValue(None) + + @defer.inlineCallbacks + def is_membership_change_allowed(self, event): + # does this room even exist + room = yield self.store.get_room(event.room_id) + if not room: + raise AuthError(403, "Room does not exist") + + # get info about the caller + try: + caller = yield self.store.get_room_member( + user_id=event.user_id, + room_id=event.room_id) + except: + caller = None + caller_in_room = caller and caller.membership == "join" + + # get info about the target + try: + target = yield self.store.get_room_member( + user_id=event.target_user_id, + room_id=event.room_id) + except: + target = None + target_in_room = target and target.membership == "join" + + membership = event.content["membership"] + + if Membership.INVITE == membership: + # Invites are valid iff caller is in the room and target isn't. + if not caller_in_room: # caller isn't joined + raise AuthError(403, "You are not in room %s." % event.room_id) + elif target_in_room: # the target is already in the room. + raise AuthError(403, "%s is already in the room." % + event.target_user_id) + elif Membership.JOIN == membership: + # Joins are valid iff caller == target and they were: + # invited: They are accepting the invitation + # joined: It's a NOOP + if event.user_id != event.target_user_id: + raise AuthError(403, "Cannot force another user to join.") + elif room.is_public: + pass # anyone can join public rooms. + elif (not caller or caller.membership not in + [Membership.INVITE, Membership.JOIN]): + raise AuthError(403, "You are not invited to this room.") + elif Membership.LEAVE == membership: + if not caller_in_room: # trying to leave a room you aren't joined + raise AuthError(403, "You are not in room %s." % event.room_id) + elif event.target_user_id != event.user_id: + # trying to force another user to leave + raise AuthError(403, "Cannot force %s to leave." % + event.target_user_id) + else: + raise AuthError(500, "Unknown membership %s" % membership) + + defer.returnValue(True) + + def get_user_by_req(self, request): + """ Get a registered user's ID. + + Args: + request - An HTTP request with an access_token query parameter. + Returns: + UserID : User ID object of the user making the request + Raises: + AuthError if no user by that token exists or the token is invalid. + """ + # Can optionally look elsewhere in the request (e.g. headers) + try: + return self.get_user_by_token(request.args["access_token"][0]) + except KeyError: + raise AuthError(403, "Missing access token.") + + @defer.inlineCallbacks + def get_user_by_token(self, token): + """ Get a registered user's ID. + + Args: + token (str)- The access token to get the user by. + Returns: + UserID : User ID object of the user who has that access token. + Raises: + AuthError if no user by that token exists or the token is invalid. + """ + try: + user_id = yield self.store.get_user_by_token(token=token) + defer.returnValue(self.hs.parse_userid(user_id)) + except StoreError: + raise AuthError(403, "Unrecognised access token.") diff --git a/synapse/api/constants.py b/synapse/api/constants.py new file mode 100644 index 0000000000..37bf41bfb3 --- /dev/null +++ b/synapse/api/constants.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Contains constants from the specification.""" + + +class Membership(object): + + """Represents the membership states of a user in a room.""" + INVITE = u"invite" + JOIN = u"join" + KNOCK = u"knock" + LEAVE = u"leave" + + +class Feedback(object): + + """Represents the types of feedback a user can send in response to a + message.""" + + DELIVERED = u"d" + READ = u"r" + LIST = (DELIVERED, READ) + + +class PresenceState(object): + """Represents the presence state of a user.""" + OFFLINE = 0 + BUSY = 1 + ONLINE = 2 + FREE_FOR_CHAT = 3 diff --git a/synapse/api/errors.py b/synapse/api/errors.py new file mode 100644 index 0000000000..7ad4d636c2 --- /dev/null +++ b/synapse/api/errors.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Contains exceptions and error codes.""" + +import logging + + +class Codes(object): + FORBIDDEN = "M_FORBIDDEN" + BAD_JSON = "M_BAD_JSON" + NOT_JSON = "M_NOT_JSON" + USER_IN_USE = "M_USER_IN_USE" + ROOM_IN_USE = "M_ROOM_IN_USE" + BAD_PAGINATION = "M_BAD_PAGINATION" + UNKNOWN = "M_UNKNOWN" + NOT_FOUND = "M_NOT_FOUND" + + +class CodeMessageException(Exception): + """An exception with integer code and message string attributes.""" + + def __init__(self, code, msg): + logging.error("%s: %s, %s", type(self).__name__, code, msg) + super(CodeMessageException, self).__init__("%d: %s" % (code, msg)) + self.code = code + self.msg = msg + + +class SynapseError(CodeMessageException): + """A base error which can be caught for all synapse events.""" + def __init__(self, code, msg, errcode=""): + """Constructs a synapse error. + + Args: + code (int): The integer error code (typically an HTTP response code) + msg (str): The human-readable error message. + err (str): The error code e.g 'M_FORBIDDEN' + """ + super(SynapseError, self).__init__(code, msg) + self.errcode = errcode + + +class RoomError(SynapseError): + """An error raised when a room event fails.""" + pass + + +class RegistrationError(SynapseError): + """An error raised when a registration event fails.""" + pass + + +class AuthError(SynapseError): + """An error raised when there was a problem authorising an event.""" + + def __init__(self, *args, **kwargs): + if "errcode" not in kwargs: + kwargs["errcode"] = Codes.FORBIDDEN + super(AuthError, self).__init__(*args, **kwargs) + + +class EventStreamError(SynapseError): + """An error raised when there a problem with the event stream.""" + pass + + +class LoginError(SynapseError): + """An error raised when there was a problem logging in.""" + pass + + +class StoreError(SynapseError): + """An error raised when there was a problem storing some data.""" + pass + + +def cs_exception(exception): + if isinstance(exception, SynapseError): + return cs_error( + exception.msg, + Codes.UNKNOWN if not exception.errcode else exception.errcode) + elif isinstance(exception, CodeMessageException): + return cs_error(exception.msg) + else: + logging.error("Unknown exception type: %s", type(exception)) + + +def cs_error(msg, code=Codes.UNKNOWN, **kwargs): + """ Utility method for constructing an error response for client-server + interactions. + + Args: + msg (str): The error message. + code (int): The error code. + kwargs : Additional keys to add to the response. + Returns: + A dict representing the error response JSON. + """ + err = {"error": msg, "errcode": code} + for key, value in kwargs.iteritems(): + err[key] = value + return err diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py new file mode 100644 index 0000000000..bc2daf3361 --- /dev/null +++ b/synapse/api/events/__init__.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from synapse.api.errors import SynapseError, Codes +from synapse.util.jsonobject import JsonEncodedObject + + +class SynapseEvent(JsonEncodedObject): + + """Base class for Synapse events. These are JSON objects which must abide + by a certain well-defined structure. + """ + + # Attributes that are currently assumed by the federation side: + # Mandatory: + # - event_id + # - room_id + # - type + # - is_state + # + # Optional: + # - state_key (mandatory when is_state is True) + # - prev_events (these can be filled out by the federation layer itself.) + # - prev_state + + valid_keys = [ + "event_id", + "type", + "room_id", + "user_id", # sender/initiator + "content", # HTTP body, JSON + ] + + internal_keys = [ + "is_state", + "state_key", + "prev_events", + "prev_state", + "depth", + "destinations", + "origin", + ] + + required_keys = [ + "event_id", + "room_id", + "content", + ] + + def __init__(self, raises=True, **kwargs): + super(SynapseEvent, self).__init__(**kwargs) + if "content" in kwargs: + self.check_json(self.content, raises=raises) + + def get_content_template(self): + """ Retrieve the JSON template for this event as a dict. + + The template must be a dict representing the JSON to match. Only + required keys should be present. The values of the keys in the template + are checked via type() to the values of the same keys in the actual + event JSON. + + NB: If loading content via json.loads, you MUST define strings as + unicode. + + For example: + Content: + { + "name": u"bob", + "age": 18, + "friends": [u"mike", u"jill"] + } + Template: + { + "name": u"string", + "age": 0, + "friends": [u"string"] + } + The values "string" and 0 could be anything, so long as the types + are the same as the content. + """ + raise NotImplementedError("get_content_template not implemented.") + + def check_json(self, content, raises=True): + """Checks the given JSON content abides by the rules of the template. + + Args: + content : A JSON object to check. + raises: True to raise a SynapseError if the check fails. + Returns: + True if the content passes the template. Returns False if the check + fails and raises=False. + Raises: + SynapseError if the check fails and raises=True. + """ + # recursively call to inspect each layer + err_msg = self._check_json(content, self.get_content_template()) + if err_msg: + if raises: + raise SynapseError(400, err_msg, Codes.BAD_JSON) + else: + return False + else: + return True + + def _check_json(self, content, template): + """Check content and template matches. + + If the template is a dict, each key in the dict will be validated with + the content, else it will just compare the types of content and + template. This basic type check is required because this function will + be recursively called and could be called with just strs or ints. + + Args: + content: The content to validate. + template: The validation template. + Returns: + str: An error message if the validation fails, else None. + """ + if type(content) != type(template): + return "Mismatched types: %s" % template + + if type(template) == dict: + for key in template: + if key not in content: + return "Missing %s key" % key + + if type(content[key]) != type(template[key]): + return "Key %s is of the wrong type." % key + + if type(content[key]) == dict: + # we must go deeper + msg = self._check_json(content[key], template[key]) + if msg: + return msg + elif type(content[key]) == list: + # make sure each item type in content matches the template + for entry in content[key]: + msg = self._check_json(entry, template[key][0]) + if msg: + return msg diff --git a/synapse/api/events/factory.py b/synapse/api/events/factory.py new file mode 100644 index 0000000000..ea7afa234e --- /dev/null +++ b/synapse/api/events/factory.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from synapse.api.events.room import ( + RoomTopicEvent, MessageEvent, RoomMemberEvent, FeedbackEvent, + InviteJoinEvent, RoomConfigEvent +) + +from synapse.util.stringutils import random_string + + +class EventFactory(object): + + _event_classes = [ + RoomTopicEvent, + MessageEvent, + RoomMemberEvent, + FeedbackEvent, + InviteJoinEvent, + RoomConfigEvent + ] + + def __init__(self): + self._event_list = {} # dict of TYPE to event class + for event_class in EventFactory._event_classes: + self._event_list[event_class.TYPE] = event_class + + def create_event(self, etype=None, **kwargs): + kwargs["type"] = etype + if "event_id" not in kwargs: + kwargs["event_id"] = random_string(10) + + try: + handler = self._event_list[etype] + except KeyError: # unknown event type + # TODO allow custom event types. + raise NotImplementedError("Unknown etype=%s" % etype) + + return handler(**kwargs) diff --git a/synapse/api/events/room.py b/synapse/api/events/room.py new file mode 100644 index 0000000000..b31cd19f4b --- /dev/null +++ b/synapse/api/events/room.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from . import SynapseEvent + + +class RoomTopicEvent(SynapseEvent): + TYPE = "m.room.topic" + + def __init__(self, **kwargs): + kwargs["state_key"] = "" + super(RoomTopicEvent, self).__init__(**kwargs) + + def get_content_template(self): + return {"topic": u"string"} + + +class RoomMemberEvent(SynapseEvent): + TYPE = "m.room.member" + + valid_keys = SynapseEvent.valid_keys + [ + "target_user_id", # target + "membership", # action + ] + + def __init__(self, **kwargs): + if "target_user_id" in kwargs: + kwargs["state_key"] = kwargs["target_user_id"] + super(RoomMemberEvent, self).__init__(**kwargs) + + def get_content_template(self): + return {"membership": u"string"} + + +class MessageEvent(SynapseEvent): + TYPE = "m.room.message" + + valid_keys = SynapseEvent.valid_keys + [ + "msg_id", # unique per room + user combo + ] + + def __init__(self, **kwargs): + super(MessageEvent, self).__init__(**kwargs) + + def get_content_template(self): + return {"msgtype": u"string"} + + +class FeedbackEvent(SynapseEvent): + TYPE = "m.room.message.feedback" + + valid_keys = SynapseEvent.valid_keys + [ + "msg_id", # the message ID being acknowledged + "msg_sender_id", # person who is sending the feedback is 'user_id' + "feedback_type", # the type of feedback (delivery, read, etc) + ] + + def __init__(self, **kwargs): + super(FeedbackEvent, self).__init__(**kwargs) + + def get_content_template(self): + return {} + + +class InviteJoinEvent(SynapseEvent): + TYPE = "m.room.invite_join" + + valid_keys = SynapseEvent.valid_keys + [ + "target_user_id", + "target_host", + ] + + def __init__(self, **kwargs): + super(InviteJoinEvent, self).__init__(**kwargs) + + def get_content_template(self): + return {} + + +class RoomConfigEvent(SynapseEvent): + TYPE = "m.room.config" + + def __init__(self, **kwargs): + kwargs["state_key"] = "" + super(RoomConfigEvent, self).__init__(**kwargs) + + def get_content_template(self): + return {} diff --git a/synapse/api/notifier.py b/synapse/api/notifier.py new file mode 100644 index 0000000000..974f7f0ba0 --- /dev/null +++ b/synapse/api/notifier.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from synapse.api.constants import Membership +from synapse.api.events.room import RoomMemberEvent + +from twisted.internet import defer +from twisted.internet import reactor + +import logging + +logger = logging.getLogger(__name__) + + +class Notifier(object): + + def __init__(self, hs): + self.store = hs.get_datastore() + self.hs = hs + self.stored_event_listeners = {} + + @defer.inlineCallbacks + def on_new_room_event(self, event, store_id): + """Called when there is a new room event which may potentially be sent + down listening users' event streams. + + This function looks for interested *users* who may want to be notified + for this event. This is different to users requesting from the event + stream which looks for interested *events* for this user. + + Args: + event (SynapseEvent): The new event, which must have a room_id + store_id (int): The ID of this event after it was stored with the + data store. + '""" + member_list = yield self.store.get_room_members(room_id=event.room_id, + membership="join") + if not member_list: + member_list = [] + + member_list = [u.user_id for u in member_list] + + # invites MUST prod the person being invited, who won't be in the room. + if (event.type == RoomMemberEvent.TYPE and + event.content["membership"] == Membership.INVITE): + member_list.append(event.target_user_id) + + for user_id in member_list: + if user_id in self.stored_event_listeners: + self._notify_and_callback( + user_id=user_id, + event_data=event.get_dict(), + stream_type=event.type, + store_id=store_id) + + def on_new_user_event(self, user_id, event_data, stream_type, store_id): + if user_id in self.stored_event_listeners: + self._notify_and_callback( + user_id=user_id, + event_data=event_data, + stream_type=stream_type, + store_id=store_id + ) + + def _notify_and_callback(self, user_id, event_data, stream_type, store_id): + logger.debug( + "Notifying %s of a new event.", + user_id + ) + + stream_ids = list(self.stored_event_listeners[user_id]) + for stream_id in stream_ids: + self._notify_and_callback_stream(user_id, stream_id, event_data, + stream_type, store_id) + + if not self.stored_event_listeners[user_id]: + del self.stored_event_listeners[user_id] + + def _notify_and_callback_stream(self, user_id, stream_id, event_data, + stream_type, store_id): + + event_listener = self.stored_event_listeners[user_id].pop(stream_id) + return_event_object = { + k: event_listener[k] for k in ["start", "chunk", "end"] + } + + # work out the new end token + token = event_listener["start"] + end = self._next_token(stream_type, store_id, token) + return_event_object["end"] = end + + # add the event to the chunk + chunk = event_listener["chunk"] + chunk.append(event_data) + + # callback the defer. We know this can't have been resolved before as + # we always remove the event_listener from the map before resolving. + event_listener["defer"].callback(return_event_object) + + def _next_token(self, stream_type, store_id, current_token): + stream_handler = self.hs.get_handlers().event_stream_handler + return stream_handler.get_event_stream_token( + stream_type, + store_id, + current_token + ) + + def store_events_for(self, user_id=None, stream_id=None, from_tok=None): + """Store all incoming events for this user. This should be paired with + get_events_for to return chunked data. + + Args: + user_id (str): The user to monitor incoming events for. + stream (object): The stream that is receiving events + from_tok (str): The token to monitor incoming events from. + """ + event_listener = { + "start": from_tok, + "chunk": [], + "end": from_tok, + "defer": defer.Deferred(), + } + + if user_id not in self.stored_event_listeners: + self.stored_event_listeners[user_id] = {stream_id: event_listener} + else: + self.stored_event_listeners[user_id][stream_id] = event_listener + + def purge_events_for(self, user_id=None, stream_id=None): + """Purges any stored events for this user. + + Args: + user_id (str): The user to purge stored events for. + """ + try: + del self.stored_event_listeners[user_id][stream_id] + if not self.stored_event_listeners[user_id]: + del self.stored_event_listeners[user_id] + except KeyError: + pass + + def get_events_for(self, user_id=None, stream_id=None, timeout=0): + """Retrieve stored events for this user, waiting if necessary. + + It is advisable to wrap this call in a maybeDeferred. + + Args: + user_id (str): The user to get events for. + timeout (int): The time in seconds to wait before giving up. + Returns: + A Deferred or a dict containing the chunk data, depending on if + there was data to return yet. The Deferred callback may be None if + there were no events before the timeout expired. + """ + logger.debug("%s is listening for events.", user_id) + + if len(self.stored_event_listeners[user_id][stream_id]["chunk"]) > 0: + logger.debug("%s returning existing chunk.", user_id) + return self.stored_event_listeners[user_id][stream_id] + + reactor.callLater( + (timeout / 1000.0), self._timeout, user_id, stream_id + ) + return self.stored_event_listeners[user_id][stream_id]["defer"] + + def _timeout(self, user_id, stream_id): + try: + # We remove the event_listener from the map so that we can't + # resolve the deferred twice. + event_listeners = self.stored_event_listeners[user_id] + event_listener = event_listeners.pop(stream_id) + event_listener["defer"].callback(None) + logger.debug("%s event listening timed out.", user_id) + except KeyError: + pass diff --git a/synapse/api/streams/__init__.py b/synapse/api/streams/__init__.py new file mode 100644 index 0000000000..08137c1e79 --- /dev/null +++ b/synapse/api/streams/__init__.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from synapse.api.errors import SynapseError + + +class PaginationConfig(object): + + """A configuration object which stores pagination parameters.""" + + def __init__(self, from_tok=None, to_tok=None, limit=0): + self.from_tok = from_tok + self.to_tok = to_tok + self.limit = limit + + @classmethod + def from_request(cls, request, raise_invalid_params=True): + params = { + "from_tok": PaginationStream.TOK_START, + "to_tok": PaginationStream.TOK_END, + "limit": 0 + } + + query_param_mappings = [ # 3-tuple of qp_key, attribute, rules + ("from", "from_tok", lambda x: type(x) == str), + ("to", "to_tok", lambda x: type(x) == str), + ("limit", "limit", lambda x: x.isdigit()) + ] + + for qp, attr, is_valid in query_param_mappings: + if qp in request.args: + if is_valid(request.args[qp][0]): + params[attr] = request.args[qp][0] + elif raise_invalid_params: + raise SynapseError(400, "%s parameter is invalid." % qp) + + return PaginationConfig(**params) + + +class PaginationStream(object): + + """ An interface for streaming data as chunks. """ + + TOK_START = "START" + TOK_END = "END" + + def get_chunk(self, config=None): + """ Return the next chunk in the stream. + + Args: + config (PaginationConfig): The config to aid which chunk to get. + Returns: + A dict containing the new start token "start", the new end token + "end" and the data "chunk" as a list. + """ + raise NotImplementedError() + + +class StreamData(object): + + """ An interface for obtaining streaming data from a table. """ + + def __init__(self, hs): + self.hs = hs + self.store = hs.get_datastore() + + def get_rows(self, user_id, from_pkey, to_pkey, limit): + """ Get event stream data between the specified pkeys. + + Args: + user_id : The user's ID + from_pkey : The starting pkey. + to_pkey : The end pkey. May be -1 to mean "latest". + limit: The max number of results to return. + Returns: + A tuple containing the list of event stream data and the last pkey. + """ + raise NotImplementedError() + + def max_token(self): + """ Get the latest currently-valid token. + + Returns: + The latest token.""" + raise NotImplementedError() diff --git a/synapse/api/streams/event.py b/synapse/api/streams/event.py new file mode 100644 index 0000000000..0cc1a3e36a --- /dev/null +++ b/synapse/api/streams/event.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module contains classes for streaming from the event stream: /events. +""" +from twisted.internet import defer + +from synapse.api.errors import EventStreamError +from synapse.api.events.room import ( + RoomMemberEvent, MessageEvent, FeedbackEvent, RoomTopicEvent +) +from synapse.api.streams import PaginationStream, StreamData + +import logging + +logger = logging.getLogger(__name__) + + +class MessagesStreamData(StreamData): + EVENT_TYPE = MessageEvent.TYPE + + def __init__(self, hs, room_id=None, feedback=False): + super(MessagesStreamData, self).__init__(hs) + self.room_id = room_id + self.with_feedback = feedback + + @defer.inlineCallbacks + def get_rows(self, user_id, from_key, to_key, limit): + (data, latest_ver) = yield self.store.get_message_stream( + user_id=user_id, + from_key=from_key, + to_key=to_key, + limit=limit, + room_id=self.room_id, + with_feedback=self.with_feedback + ) + defer.returnValue((data, latest_ver)) + + @defer.inlineCallbacks + def max_token(self): + val = yield self.store.get_max_message_id() + defer.returnValue(val) + + +class RoomMemberStreamData(StreamData): + EVENT_TYPE = RoomMemberEvent.TYPE + + @defer.inlineCallbacks + def get_rows(self, user_id, from_key, to_key, limit): + (data, latest_ver) = yield self.store.get_room_member_stream( + user_id=user_id, + from_key=from_key, + to_key=to_key + ) + + defer.returnValue((data, latest_ver)) + + @defer.inlineCallbacks + def max_token(self): + val = yield self.store.get_max_room_member_id() + defer.returnValue(val) + + +class FeedbackStreamData(StreamData): + EVENT_TYPE = FeedbackEvent.TYPE + + def __init__(self, hs, room_id=None): + super(FeedbackStreamData, self).__init__(hs) + self.room_id = room_id + + @defer.inlineCallbacks + def get_rows(self, user_id, from_key, to_key, limit): + (data, latest_ver) = yield self.store.get_feedback_stream( + user_id=user_id, + from_key=from_key, + to_key=to_key, + limit=limit, + room_id=self.room_id + ) + defer.returnValue((data, latest_ver)) + + @defer.inlineCallbacks + def max_token(self): + val = yield self.store.get_max_feedback_id() + defer.returnValue(val) + + +class RoomDataStreamData(StreamData): + EVENT_TYPE = RoomTopicEvent.TYPE # TODO need multiple event types + + def __init__(self, hs, room_id=None): + super(RoomDataStreamData, self).__init__(hs) + self.room_id = room_id + + @defer.inlineCallbacks + def get_rows(self, user_id, from_key, to_key, limit): + (data, latest_ver) = yield self.store.get_room_data_stream( + user_id=user_id, + from_key=from_key, + to_key=to_key, + limit=limit, + room_id=self.room_id + ) + defer.returnValue((data, latest_ver)) + + @defer.inlineCallbacks + def max_token(self): + val = yield self.store.get_max_room_data_id() + defer.returnValue(val) + + +class EventStream(PaginationStream): + + SEPARATOR = '_' + + def __init__(self, user_id, stream_data_list): + super(EventStream, self).__init__() + self.user_id = user_id + self.stream_data = stream_data_list + + @defer.inlineCallbacks + def fix_tokens(self, pagination_config): + pagination_config.from_tok = yield self.fix_token( + pagination_config.from_tok) + pagination_config.to_tok = yield self.fix_token( + pagination_config.to_tok) + defer.returnValue(pagination_config) + + @defer.inlineCallbacks + def fix_token(self, token): + """Fixes unknown values in a token to known values. + + Args: + token (str): The token to fix up. + Returns: + The fixed-up token, which may == token. + """ + # replace TOK_START and TOK_END with 0_0_0 or -1_-1_-1 depending. + replacements = [ + (PaginationStream.TOK_START, "0"), + (PaginationStream.TOK_END, "-1") + ] + for magic_token, key in replacements: + if magic_token == token: + token = EventStream.SEPARATOR.join( + [key] * len(self.stream_data) + ) + + # replace -1 values with an actual pkey + token_segments = self._split_token(token) + for i, tok in enumerate(token_segments): + if tok == -1: + # add 1 to the max token because results are EXCLUSIVE from the + # latest version. + token_segments[i] = 1 + (yield self.stream_data[i].max_token()) + defer.returnValue(EventStream.SEPARATOR.join( + str(x) for x in token_segments + )) + + @defer.inlineCallbacks + def get_chunk(self, config=None): + # no support for limit on >1 streams, makes no sense. + if config.limit and len(self.stream_data) > 1: + raise EventStreamError( + 400, "Limit not supported on multiplexed streams." + ) + + (chunk_data, next_tok) = yield self._get_chunk_data(config.from_tok, + config.to_tok, + config.limit) + + defer.returnValue({ + "chunk": chunk_data, + "start": config.from_tok, + "end": next_tok + }) + + @defer.inlineCallbacks + def _get_chunk_data(self, from_tok, to_tok, limit): + """ Get event data between the two tokens. + + Tokens are SEPARATOR separated values representing pkey values of + certain tables, and the position determines the StreamData invoked + according to the STREAM_DATA list. + + The magic value '-1' can be used to get the latest value. + + Args: + from_tok - The token to start from. + to_tok - The token to end at. Must have values > from_tok or be -1. + Returns: + A list of event data. + Raises: + EventStreamError if something went wrong. + """ + # sanity check + if (from_tok.count(EventStream.SEPARATOR) != + to_tok.count(EventStream.SEPARATOR) or + (from_tok.count(EventStream.SEPARATOR) + 1) != + len(self.stream_data)): + raise EventStreamError(400, "Token lengths don't match.") + + chunk = [] + next_ver = [] + for i, (from_pkey, to_pkey) in enumerate(zip( + self._split_token(from_tok), + self._split_token(to_tok) + )): + if from_pkey == to_pkey: + # tokens are the same, we have nothing to do. + next_ver.append(str(to_pkey)) + continue + + (event_chunk, max_pkey) = yield self.stream_data[i].get_rows( + self.user_id, from_pkey, to_pkey, limit + ) + + chunk += event_chunk + next_ver.append(str(max_pkey)) + + defer.returnValue((chunk, EventStream.SEPARATOR.join(next_ver))) + + def _split_token(self, token): + """Splits the given token into a list of pkeys. + + Args: + token (str): The token with SEPARATOR values. + Returns: + A list of ints. + """ + segments = token.split(EventStream.SEPARATOR) + try: + int_segments = [int(x) for x in segments] + except ValueError: + raise EventStreamError(400, "Bad token: %s" % token) + return int_segments diff --git a/synapse/app/__init__.py b/synapse/app/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/app/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py new file mode 100644 index 0000000000..5708b3ad95 --- /dev/null +++ b/synapse/app/homeserver.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#!/usr/bin/env python + +from synapse.storage import read_schema + +from synapse.server import HomeServer + +from twisted.internet import reactor +from twisted.enterprise import adbapi +from twisted.python.log import PythonLoggingObserver +from synapse.http.server import TwistedHttpServer +from synapse.http.client import TwistedHttpClient + +from daemonize import Daemonize + +import argparse +import logging +import logging.config +import sqlite3 + +logger = logging.getLogger(__name__) + + +class SynapseHomeServer(HomeServer): + def build_http_server(self): + return TwistedHttpServer() + + def build_http_client(self): + return TwistedHttpClient() + + def build_db_pool(self): + """ Set up all the dbs. Since all the *.sql have IF NOT EXISTS, so we + don't have to worry about overwriting existing content. + """ + logging.info("Preparing database: %s...", self.db_name) + pool = adbapi.ConnectionPool( + 'sqlite3', self.db_name, check_same_thread=False, + cp_min=1, cp_max=1) + + schemas = [ + "transactions", + "pdu", + "users", + "profiles", + "presence", + "im", + "room_aliases", + ] + + for sql_loc in schemas: + sql_script = read_schema(sql_loc) + + with sqlite3.connect(self.db_name) as db_conn: + c = db_conn.cursor() + c.executescript(sql_script) + c.close() + db_conn.commit() + + logging.info("Database prepared in %s.", self.db_name) + + return pool + + +def setup_logging(verbosity=0, filename=None, config_path=None): + """ Sets up logging with verbosity levels. + + Args: + verbosity: The verbosity level. + filename: Log to the given file rather than to the console. + config_path: Path to a python logging config file. + """ + + if config_path is None: + log_format = ( + '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s' + ) + + if not verbosity or verbosity == 0: + level = logging.WARNING + elif verbosity == 1: + level = logging.INFO + else: + level = logging.DEBUG + + logging.basicConfig(level=level, filename=filename, format=log_format) + else: + logging.config.fileConfig(config_path) + + observer = PythonLoggingObserver() + observer.start() + + +def run(): + reactor.run() + + +def setup(): + parser = argparse.ArgumentParser() + parser.add_argument("-p", "--port", dest="port", type=int, default=8080, + help="The port to listen on.") + parser.add_argument("-d", "--database", dest="db", default="homeserver.db", + help="The database name.") + parser.add_argument("-H", "--host", dest="host", default="localhost", + help="The hostname of the server.") + parser.add_argument('-v', '--verbose', dest="verbose", action='count', + help="The verbosity level.") + parser.add_argument('-f', '--log-file', dest="log_file", default=None, + help="File to log to.") + parser.add_argument('--log-config', dest="log_config", default=None, + help="Python logging config") + parser.add_argument('-D', '--daemonize', action='store_true', + default=False, help="Daemonize the home server") + parser.add_argument('--pid-file', dest="pid", help="When running as a " + "daemon, the file to store the pid in", + default="hs.pid") + args = parser.parse_args() + + verbosity = int(args.verbose) if args.verbose else None + + setup_logging( + verbosity=verbosity, + filename=args.log_file, + config_path=args.log_config, + ) + + logger.info("Server hostname: %s", args.host) + + hs = SynapseHomeServer( + args.host, + db_name=args.db + ) + + # This object doesn't need to be saved because it's set as the handler for + # the replication layer + hs.get_federation() + + hs.register_servlets() + + hs.get_http_server().start_listening(args.port) + + hs.build_db_pool() + + if args.daemonize: + daemon = Daemonize( + app="synapse-homeserver", + pid=args.pid, + action=run, + auto_close_fds=False, + verbose=True, + logger=logger, + ) + + daemon.start() + else: + run() + + +if __name__ == '__main__': + setup() diff --git a/synapse/crypto/__init__.py b/synapse/crypto/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/crypto/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/crypto/config.py b/synapse/crypto/config.py new file mode 100644 index 0000000000..801dfd8656 --- /dev/null +++ b/synapse/crypto/config.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ConfigParser as configparser +import argparse +import socket +import sys +import os +from OpenSSL import crypto +import nacl.signing +from syutil.base64util import encode_base64 +import subprocess + + +def load_config(description, argv): + config_parser = argparse.ArgumentParser(add_help=False) + config_parser.add_argument("-c", "--config-path", metavar="CONFIG_FILE", + help="Specify config file") + config_args, remaining_args = config_parser.parse_known_args(argv) + if config_args.config_path: + config = configparser.SafeConfigParser() + config.read([config_args.config_path]) + defaults = dict(config.items("KeyServer")) + else: + defaults = {} + parser = argparse.ArgumentParser( + parents=[config_parser], + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.set_defaults(**defaults) + parser.add_argument("--server-name", default=socket.getfqdn(), + help="The name of the server") + parser.add_argument("--signing-key-path", + help="The signing key to sign responses with") + parser.add_argument("--tls-certificate-path", + help="PEM encoded X509 certificate for TLS") + parser.add_argument("--tls-private-key-path", + help="PEM encoded private key for TLS") + parser.add_argument("--tls-dh-params-path", + help="PEM encoded dh parameters for ephemeral keys") + parser.add_argument("--bind-port", type=int, + help="TCP port to listen on") + parser.add_argument("--bind-host", default="", + help="Local interface to listen on") + + args = parser.parse_args(remaining_args) + + server_config = vars(args) + del server_config["config_path"] + return server_config + + +def generate_config(argv): + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--config-path", help="Specify config file", + metavar="CONFIG_FILE", required=True) + parser.add_argument("--server-name", default=socket.getfqdn(), + help="The name of the server") + parser.add_argument("--signing-key-path", + help="The signing key to sign responses with") + parser.add_argument("--tls-certificate-path", + help="PEM encoded X509 certificate for TLS") + parser.add_argument("--tls-private-key-path", + help="PEM encoded private key for TLS") + parser.add_argument("--tls-dh-params-path", + help="PEM encoded dh parameters for ephemeral keys") + parser.add_argument("--bind-port", type=int, required=True, + help="TCP port to listen on") + parser.add_argument("--bind-host", default="", + help="Local interface to listen on") + + args = parser.parse_args(argv) + + dir_name = os.path.dirname(args.config_path) + base_key_name = os.path.join(dir_name, args.server_name) + + if args.signing_key_path is None: + args.signing_key_path = base_key_name + ".signing.key" + + if args.tls_certificate_path is None: + args.tls_certificate_path = base_key_name + ".tls.crt" + + if args.tls_private_key_path is None: + args.tls_private_key_path = base_key_name + ".tls.key" + + if args.tls_dh_params_path is None: + args.tls_dh_params_path = base_key_name + ".tls.dh" + + if not os.path.exists(args.signing_key_path): + with open(args.signing_key_path, "w") as signing_key_file: + key = nacl.signing.SigningKey.generate() + signing_key_file.write(encode_base64(key.encode())) + + if not os.path.exists(args.tls_private_key_path): + with open(args.tls_private_key_path, "w") as private_key_file: + tls_private_key = crypto.PKey() + tls_private_key.generate_key(crypto.TYPE_RSA, 2048) + private_key_pem = crypto.dump_privatekey( + crypto.FILETYPE_PEM, tls_private_key + ) + private_key_file.write(private_key_pem) + else: + with open(args.tls_private_key_path) as private_key_file: + private_key_pem = private_key_file.read() + tls_private_key = crypto.load_privatekey( + crypto.FILETYPE_PEM, private_key_pem + ) + + if not os.path.exists(args.tls_certificate_path): + with open(args.tls_certificate_path, "w") as certifcate_file: + cert = crypto.X509() + subject = cert.get_subject() + subject.CN = args.server_name + + cert.set_serial_number(1000) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(tls_private_key) + + cert.sign(tls_private_key, 'sha256') + + cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) + + certifcate_file.write(cert_pem) + + if not os.path.exists(args.tls_dh_params_path): + subprocess.check_call([ + "openssl", "dhparam", + "-outform", "PEM", + "-out", args.tls_dh_params_path, + "2048" + ]) + + config = configparser.SafeConfigParser() + config.add_section("KeyServer") + for key, value in vars(args).items(): + if key != "config_path": + config.set("KeyServer", key, str(value)) + + with open(args.config_path, "w") as config_file: + config.write(config_file) + + +if __name__ == "__main__": + generate_config(sys.argv[1:]) diff --git a/synapse/crypto/keyclient.py b/synapse/crypto/keyclient.py new file mode 100644 index 0000000000..b53d1c572b --- /dev/null +++ b/synapse/crypto/keyclient.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.web.http import HTTPClient +from twisted.internet import defer, reactor +from twisted.internet.protocol import ClientFactory +from twisted.names.srvconnect import SRVConnector +import json +import logging + + +logger = logging.getLogger(__name__) + + +@defer.inlineCallbacks +def fetch_server_key(server_name, ssl_context_factory): + """Fetch the keys for a remote server.""" + + factory = SynapseKeyClientFactory() + + SRVConnector( + reactor, "matrix", server_name, factory, + protocol="tcp", connectFuncName="connectSSL", defaultPort=443, + connectFuncKwArgs=dict(contextFactory=ssl_context_factory)).connect() + + server_key, server_certificate = yield factory.remote_key + + defer.returnValue((server_key, server_certificate)) + + +class SynapseKeyClientError(Exception): + """The key wasn't retireved from the remote server.""" + pass + + +class SynapseKeyClientProtocol(HTTPClient): + """Low level HTTPS client which retrieves an application/json response from + the server and extracts the X.509 certificate for the remote peer from the + SSL connection.""" + + def connectionMade(self): + logger.debug("Connected to %s", self.transport.getHost()) + self.sendCommand(b"GET", b"/key") + self.endHeaders() + self.timer = reactor.callLater( + self.factory.timeout_seconds, + self.on_timeout + ) + + def handleStatus(self, version, status, message): + if status != b"200": + logger.info("Non-200 response from %s: %s %s", + self.transport.getHost(), status, message) + self.transport.abortConnection() + + def handleResponse(self, response_body_bytes): + try: + json_response = json.loads(response_body_bytes) + except ValueError: + logger.info("Invalid JSON response from %s", + self.transport.getHost()) + self.transport.abortConnection() + return + + certificate = self.transport.getPeerCertificate() + self.factory.on_remote_key((json_response, certificate)) + self.transport.abortConnection() + self.timer.cancel() + + def on_timeout(self): + logger.debug("Timeout waiting for response from %s", + self.transport.getHost()) + self.transport.abortConnection() + + +class SynapseKeyClientFactory(ClientFactory): + protocol = SynapseKeyClientProtocol + max_retries = 5 + timeout_seconds = 30 + + def __init__(self): + self.succeeded = False + self.retries = 0 + self.remote_key = defer.Deferred() + + def on_remote_key(self, key): + self.succeeded = True + self.remote_key.callback(key) + + def retry_connection(self, connector): + self.retries += 1 + if self.retries < self.max_retries: + connector.connector = None + connector.connect() + else: + self.remote_key.errback( + SynapseKeyClientError("Max retries exceeded")) + + def clientConnectionFailed(self, connector, reason): + logger.info("Connection failed %s", reason) + self.retry_connection(connector) + + def clientConnectionLost(self, connector, reason): + logger.info("Connection lost %s", reason) + if not self.succeeded: + self.retry_connection(connector) diff --git a/synapse/crypto/keyserver.py b/synapse/crypto/keyserver.py new file mode 100644 index 0000000000..48bd380781 --- /dev/null +++ b/synapse/crypto/keyserver.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import reactor, ssl +from twisted.web import server +from twisted.web.resource import Resource +from twisted.python.log import PythonLoggingObserver + +from synapse.crypto.resource.key import LocalKey +from synapse.crypto.config import load_config + +from syutil.base64util import decode_base64 + +from OpenSSL import crypto, SSL + +import logging +import nacl.signing +import sys + + +class KeyServerSSLContextFactory(ssl.ContextFactory): + """Factory for PyOpenSSL SSL contexts that are used to handle incoming + connections and to make connections to remote servers.""" + + def __init__(self, key_server): + self._context = SSL.Context(SSL.SSLv23_METHOD) + self.configure_context(self._context, key_server) + + @staticmethod + def configure_context(context, key_server): + context.set_options(SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3) + context.use_certificate(key_server.tls_certificate) + context.use_privatekey(key_server.tls_private_key) + context.load_tmp_dh(key_server.tls_dh_params_path) + context.set_cipher_list("!ADH:HIGH+kEDH:!AECDH:HIGH+kEECDH") + + def getContext(self): + return self._context + + +class KeyServer(object): + """An HTTPS server serving LocalKey and RemoteKey resources.""" + + def __init__(self, server_name, tls_certificate_path, tls_private_key_path, + tls_dh_params_path, signing_key_path, bind_host, bind_port): + self.server_name = server_name + self.tls_certificate = self.read_tls_certificate(tls_certificate_path) + self.tls_private_key = self.read_tls_private_key(tls_private_key_path) + self.tls_dh_params_path = tls_dh_params_path + self.signing_key = self.read_signing_key(signing_key_path) + self.bind_host = bind_host + self.bind_port = int(bind_port) + self.ssl_context_factory = KeyServerSSLContextFactory(self) + + @staticmethod + def read_tls_certificate(cert_path): + with open(cert_path) as cert_file: + cert_pem = cert_file.read() + return crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + + @staticmethod + def read_tls_private_key(private_key_path): + with open(private_key_path) as private_key_file: + private_key_pem = private_key_file.read() + return crypto.load_privatekey(crypto.FILETYPE_PEM, private_key_pem) + + @staticmethod + def read_signing_key(signing_key_path): + with open(signing_key_path) as signing_key_file: + signing_key_b64 = signing_key_file.read() + signing_key_bytes = decode_base64(signing_key_b64) + return nacl.signing.SigningKey(signing_key_bytes) + + def run(self): + root = Resource() + root.putChild("key", LocalKey(self)) + site = server.Site(root) + reactor.listenSSL( + self.bind_port, + site, + self.ssl_context_factory, + interface=self.bind_host + ) + + logging.basicConfig(level=logging.DEBUG) + observer = PythonLoggingObserver() + observer.start() + + reactor.run() + + +def main(): + key_server = KeyServer(**load_config(__doc__, sys.argv[1:])) + key_server.run() + + +if __name__ == "__main__": + main() diff --git a/synapse/crypto/resource/__init__.py b/synapse/crypto/resource/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/crypto/resource/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/crypto/resource/key.py b/synapse/crypto/resource/key.py new file mode 100644 index 0000000000..6ce6e0b034 --- /dev/null +++ b/synapse/crypto/resource/key.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.web.resource import Resource +from twisted.web.server import NOT_DONE_YET +from twisted.internet import defer +from synapse.http.server import respond_with_json_bytes +from synapse.crypto.keyclient import fetch_server_key +from syutil.crypto.jsonsign import sign_json, verify_signed_json +from syutil.base64util import encode_base64, decode_base64 +from syutil.jsonutil import encode_canonical_json +from OpenSSL import crypto +from nacl.signing import VerifyKey +import logging + + +logger = logging.getLogger(__name__) + + +class LocalKey(Resource): + """HTTP resource containing encoding the TLS X.509 certificate and NACL + signature verification keys for this server:: + + GET /key HTTP/1.1 + + HTTP/1.1 200 OK + Content-Type: application/json + { + "server_name": "this.server.example.com" + "signature_verify_key": # base64 encoded NACL verification key. + "tls_certificate": # base64 ASN.1 DER encoded X.509 tls cert. + "signatures": { + "this.server.example.com": # NACL signature for this server. + } + } + """ + + def __init__(self, key_server): + self.key_server = key_server + self.response_body = encode_canonical_json( + self.response_json_object(key_server) + ) + Resource.__init__(self) + + @staticmethod + def response_json_object(key_server): + verify_key_bytes = key_server.signing_key.verify_key.encode() + x509_certificate_bytes = crypto.dump_certificate( + crypto.FILETYPE_ASN1, + key_server.tls_certificate + ) + json_object = { + u"server_name": key_server.server_name, + u"signature_verify_key": encode_base64(verify_key_bytes), + u"tls_certificate": encode_base64(x509_certificate_bytes) + } + signed_json = sign_json( + json_object, + key_server.server_name, + key_server.signing_key + ) + return signed_json + + def getChild(self, name, request): + logger.info("getChild %s %s", name, request) + if name == '': + return self + else: + return RemoteKey(name, self.key_server) + + def render_GET(self, request): + return respond_with_json_bytes(request, 200, self.response_body) + + +class RemoteKey(Resource): + """HTTP resource for retreiving the TLS certificate and NACL signature + verification keys for a another server. Checks that the reported X.509 TLS + certificate matches the one used in the HTTPS connection. Checks that the + NACL signature for the remote server is valid. Returns JSON signed by both + the remote server and by this server. + + GET /key/remote.server.example.com HTTP/1.1 + + HTTP/1.1 200 OK + Content-Type: application/json + { + "server_name": "remote.server.example.com" + "signature_verify_key": # base64 encoded NACL verification key. + "tls_certificate": # base64 ASN.1 DER encoded X.509 tls cert. + "signatures": { + "remote.server.example.com": # NACL signature for remote server. + "this.server.example.com": # NACL signature for this server. + } + } + """ + + isLeaf = True + + def __init__(self, server_name, key_server): + self.server_name = server_name + self.key_server = key_server + Resource.__init__(self) + + def render_GET(self, request): + self._async_render_GET(request) + return NOT_DONE_YET + + @defer.inlineCallbacks + def _async_render_GET(self, request): + try: + server_keys, certificate = yield fetch_server_key( + self.server_name, + self.key_server.ssl_context_factory + ) + + resp_server_name = server_keys[u"server_name"] + verify_key_b64 = server_keys[u"signature_verify_key"] + tls_certificate_b64 = server_keys[u"tls_certificate"] + verify_key = VerifyKey(decode_base64(verify_key_b64)) + + if resp_server_name != self.server_name: + raise ValueError("Wrong server name '%s' != '%s'" % + (resp_server_name, self.server_name)) + + x509_certificate_bytes = crypto.dump_certificate( + crypto.FILETYPE_ASN1, + certificate + ) + + if encode_base64(x509_certificate_bytes) != tls_certificate_b64: + raise ValueError("TLS certificate doesn't match") + + verify_signed_json(server_keys, self.server_name, verify_key) + + signed_json = sign_json( + server_keys, + self.key_server.server_name, + self.key_server.signing_key + ) + + json_bytes = encode_canonical_json(signed_json) + respond_with_json_bytes(request, 200, json_bytes) + + except Exception as e: + json_bytes = encode_canonical_json({ + u"error": {u"code": 502, u"message": e.message} + }) + respond_with_json_bytes(request, 502, json_bytes) diff --git a/synapse/federation/__init__.py b/synapse/federation/__init__.py new file mode 100644 index 0000000000..b4d95ed5ac --- /dev/null +++ b/synapse/federation/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" This package includes all the federation specific logic. +""" + +from .replication import ReplicationLayer +from .transport import TransportLayer + + +def initialize_http_replication(homeserver): + transport = TransportLayer( + homeserver.hostname, + server=homeserver.get_http_server(), + client=homeserver.get_http_client() + ) + + return ReplicationLayer(homeserver, transport) diff --git a/synapse/federation/handler.py b/synapse/federation/handler.py new file mode 100644 index 0000000000..31e8470b33 --- /dev/null +++ b/synapse/federation/handler.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer + +from .pdu_codec import PduCodec + +from synapse.api.errors import AuthError +from synapse.util.logutils import log_function + +import logging + + +logger = logging.getLogger(__name__) + + +class FederationEventHandler(object): + """ Responsible for: + a) handling received Pdus before handing them on as Events to the rest + of the home server (including auth and state conflict resoultion) + b) converting events that were produced by local clients that may need + to be sent to remote home servers. + """ + + def __init__(self, hs): + self.store = hs.get_datastore() + self.replication_layer = hs.get_replication_layer() + self.state_handler = hs.get_state_handler() + # self.auth_handler = gs.get_auth_handler() + self.event_handler = hs.get_handlers().federation_handler + self.server_name = hs.hostname + + self.lock_manager = hs.get_room_lock_manager() + + self.replication_layer.set_handler(self) + + self.pdu_codec = PduCodec(hs) + + @log_function + @defer.inlineCallbacks + def handle_new_event(self, event): + """ Takes in an event from the client to server side, that has already + been authed and handled by the state module, and sends it to any + remote home servers that may be interested. + + Args: + event + + Returns: + Deferred: Resolved when it has successfully been queued for + processing. + """ + yield self._fill_out_prev_events(event) + + pdu = self.pdu_codec.pdu_from_event(event) + + if not hasattr(pdu, "destinations") or not pdu.destinations: + pdu.destinations = [] + + yield self.replication_layer.send_pdu(pdu) + + @log_function + @defer.inlineCallbacks + def backfill(self, room_id, limit): + # TODO: Work out which destinations to ask for pagination + # self.replication_layer.paginate(dest, room_id, limit) + pass + + @log_function + def get_state_for_room(self, destination, room_id): + return self.replication_layer.get_state_for_context( + destination, room_id + ) + + @log_function + @defer.inlineCallbacks + def on_receive_pdu(self, pdu): + """ Called by the ReplicationLayer when we have a new pdu. We need to + do auth checks and put it throught the StateHandler. + """ + event = self.pdu_codec.event_from_pdu(pdu) + + try: + with (yield self.lock_manager.lock(pdu.context)): + if event.is_state: + is_new_state = yield self.state_handler.handle_new_state( + pdu + ) + if not is_new_state: + return + else: + is_new_state = False + + yield self.event_handler.on_receive(event, is_new_state) + + except AuthError: + # TODO: Implement something in federation that allows us to + # respond to PDU. + raise + + return + + @defer.inlineCallbacks + def _on_new_state(self, pdu, new_state_event): + # TODO: Do any store stuff here. Notifiy C2S about this new + # state. + + yield self.store.update_current_state( + pdu_id=pdu.pdu_id, + origin=pdu.origin, + context=pdu.context, + pdu_type=pdu.pdu_type, + state_key=pdu.state_key + ) + + yield self.event_handler.on_receive(new_state_event) + + @defer.inlineCallbacks + def _fill_out_prev_events(self, event): + if hasattr(event, "prev_events"): + return + + results = yield self.store.get_latest_pdus_in_context( + event.room_id + ) + + es = [ + "%s@%s" % (p_id, origin) for p_id, origin, _ in results + ] + + event.prev_events = [e for e in es if e != event.event_id] + + if results: + event.depth = max([int(v) for _, _, v in results]) + 1 + else: + event.depth = 0 diff --git a/synapse/federation/pdu_codec.py b/synapse/federation/pdu_codec.py new file mode 100644 index 0000000000..9155930e47 --- /dev/null +++ b/synapse/federation/pdu_codec.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from .units import Pdu + +import copy + + +def decode_event_id(event_id, server_name): + parts = event_id.split("@") + if len(parts) < 2: + return (event_id, server_name) + else: + return (parts[0], "".join(parts[1:])) + + +def encode_event_id(pdu_id, origin): + return "%s@%s" % (pdu_id, origin) + + +class PduCodec(object): + + def __init__(self, hs): + self.server_name = hs.hostname + self.event_factory = hs.get_event_factory() + self.clock = hs.get_clock() + + def event_from_pdu(self, pdu): + kwargs = {} + + kwargs["event_id"] = encode_event_id(pdu.pdu_id, pdu.origin) + kwargs["room_id"] = pdu.context + kwargs["etype"] = pdu.pdu_type + kwargs["prev_events"] = [ + encode_event_id(p[0], p[1]) for p in pdu.prev_pdus + ] + + if hasattr(pdu, "prev_state_id") and hasattr(pdu, "prev_state_origin"): + kwargs["prev_state"] = encode_event_id( + pdu.prev_state_id, pdu.prev_state_origin + ) + + kwargs.update({ + k: v + for k, v in pdu.get_full_dict().items() + if k not in [ + "pdu_id", + "context", + "pdu_type", + "prev_pdus", + "prev_state_id", + "prev_state_origin", + ] + }) + + return self.event_factory.create_event(**kwargs) + + def pdu_from_event(self, event): + d = event.get_full_dict() + + d["pdu_id"], d["origin"] = decode_event_id( + event.event_id, self.server_name + ) + d["context"] = event.room_id + d["pdu_type"] = event.type + + if hasattr(event, "prev_events"): + d["prev_pdus"] = [ + decode_event_id(e, self.server_name) + for e in event.prev_events + ] + + if hasattr(event, "prev_state"): + d["prev_state_id"], d["prev_state_origin"] = ( + decode_event_id(event.prev_state, self.server_name) + ) + + if hasattr(event, "state_key"): + d["is_state"] = True + + kwargs = copy.deepcopy(event.unrecognized_keys) + kwargs.update({ + k: v for k, v in d.items() + if k not in ["event_id", "room_id", "type", "prev_events"] + }) + + if "ts" not in kwargs: + kwargs["ts"] = int(self.clock.time_msec()) + + return Pdu(**kwargs) diff --git a/synapse/federation/persistence.py b/synapse/federation/persistence.py new file mode 100644 index 0000000000..ad4111c683 --- /dev/null +++ b/synapse/federation/persistence.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" This module contains all the persistence actions done by the federation +package. + +These actions are mostly only used by the :py:mod:`.replication` module. +""" + +from twisted.internet import defer + +from .units import Pdu + +from synapse.util.logutils import log_function + +import copy +import json +import logging + + +logger = logging.getLogger(__name__) + + +class PduActions(object): + """ Defines persistence actions that relate to handling PDUs. + """ + + def __init__(self, datastore): + self.store = datastore + + @log_function + def persist_received(self, pdu): + """ Persists the given `Pdu` that was received from a remote home + server. + + Returns: + Deferred + """ + return self._persist(pdu) + + @defer.inlineCallbacks + @log_function + def persist_outgoing(self, pdu): + """ Persists the given `Pdu` that this home server created. + + Returns: + Deferred + """ + ret = yield self._persist(pdu) + + defer.returnValue(ret) + + @log_function + def mark_as_processed(self, pdu): + """ Persist the fact that we have fully processed the given `Pdu` + + Returns: + Deferred + """ + return self.store.mark_pdu_as_processed(pdu.pdu_id, pdu.origin) + + @defer.inlineCallbacks + @log_function + def populate_previous_pdus(self, pdu): + """ Given an outgoing `Pdu` fill out its `prev_ids` key with the `Pdu`s + that we have received. + + Returns: + Deferred + """ + results = yield self.store.get_latest_pdus_in_context(pdu.context) + + pdu.prev_pdus = [(p_id, origin) for p_id, origin, _ in results] + + vs = [int(v) for _, _, v in results] + if vs: + pdu.depth = max(vs) + 1 + else: + pdu.depth = 0 + + @defer.inlineCallbacks + @log_function + def after_transaction(self, transaction_id, destination, origin): + """ Returns all `Pdu`s that we sent to the given remote home server + after a given transaction id. + + Returns: + Deferred: Results in a list of `Pdu`s + """ + results = yield self.store.get_pdus_after_transaction( + transaction_id, + destination + ) + + defer.returnValue([Pdu.from_pdu_tuple(p) for p in results]) + + @defer.inlineCallbacks + @log_function + def get_all_pdus_from_context(self, context): + results = yield self.store.get_all_pdus_from_context(context) + defer.returnValue([Pdu.from_pdu_tuple(p) for p in results]) + + @defer.inlineCallbacks + @log_function + def paginate(self, context, pdu_list, limit): + """ For a given list of PDU id and origins return the proceeding + `limit` `Pdu`s in the given `context`. + + Returns: + Deferred: Results in a list of `Pdu`s. + """ + results = yield self.store.get_pagination( + context, pdu_list, limit + ) + + defer.returnValue([Pdu.from_pdu_tuple(p) for p in results]) + + @log_function + def is_new(self, pdu): + """ When we receive a `Pdu` from a remote home server, we want to + figure out whether it is `new`, i.e. it is not some historic PDU that + we haven't seen simply because we haven't paginated back that far. + + Returns: + Deferred: Results in a `bool` + """ + return self.store.is_pdu_new( + pdu_id=pdu.pdu_id, + origin=pdu.origin, + context=pdu.context, + depth=pdu.depth + ) + + @defer.inlineCallbacks + @log_function + def _persist(self, pdu): + kwargs = copy.copy(pdu.__dict__) + unrec_keys = copy.copy(pdu.unrecognized_keys) + del kwargs["content"] + kwargs["content_json"] = json.dumps(pdu.content) + kwargs["unrecognized_keys"] = json.dumps(unrec_keys) + + logger.debug("Persisting: %s", repr(kwargs)) + + if pdu.is_state: + ret = yield self.store.persist_state(**kwargs) + else: + ret = yield self.store.persist_pdu(**kwargs) + + yield self.store.update_min_depth_for_context( + pdu.context, pdu.depth + ) + + defer.returnValue(ret) + + +class TransactionActions(object): + """ Defines persistence actions that relate to handling Transactions. + """ + + def __init__(self, datastore): + self.store = datastore + + @log_function + def have_responded(self, transaction): + """ Have we already responded to a transaction with the same id and + origin? + + Returns: + Deferred: Results in `None` if we have not previously responded to + this transaction or a 2-tuple of `(int, dict)` representing the + response code and response body. + """ + if not transaction.transaction_id: + raise RuntimeError("Cannot persist a transaction with no " + "transaction_id") + + return self.store.get_received_txn_response( + transaction.transaction_id, transaction.origin + ) + + @log_function + def set_response(self, transaction, code, response): + """ Persist how we responded to a transaction. + + Returns: + Deferred + """ + if not transaction.transaction_id: + raise RuntimeError("Cannot persist a transaction with no " + "transaction_id") + + return self.store.set_received_txn_response( + transaction.transaction_id, + transaction.origin, + code, + json.dumps(response) + ) + + @defer.inlineCallbacks + @log_function + def prepare_to_send(self, transaction): + """ Persists the `Transaction` we are about to send and works out the + correct value for the `prev_ids` key. + + Returns: + Deferred + """ + transaction.prev_ids = yield self.store.prep_send_transaction( + transaction.transaction_id, + transaction.destination, + transaction.ts, + [(p["pdu_id"], p["origin"]) for p in transaction.pdus] + ) + + @log_function + def delivered(self, transaction, response_code, response_dict): + """ Marks the given `Transaction` as having been successfully + delivered to the remote homeserver, and what the response was. + + Returns: + Deferred + """ + return self.store.delivered_txn( + transaction.transaction_id, + transaction.destination, + response_code, + json.dumps(response_dict) + ) diff --git a/synapse/federation/replication.py b/synapse/federation/replication.py new file mode 100644 index 0000000000..0f5b974291 --- /dev/null +++ b/synapse/federation/replication.py @@ -0,0 +1,582 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This layer is responsible for replicating with remote home servers using +a given transport. +""" + +from twisted.internet import defer + +from .units import Transaction, Pdu, Edu + +from .persistence import PduActions, TransactionActions + +from synapse.util.logutils import log_function + +import logging + + +logger = logging.getLogger(__name__) + + +class ReplicationLayer(object): + """This layer is responsible for replicating with remote home servers over + the given transport. I.e., does the sending and receiving of PDUs to + remote home servers. + + The layer communicates with the rest of the server via a registered + ReplicationHandler. + + In more detail, the layer: + * Receives incoming data and processes it into transactions and pdus. + * Fetches any PDUs it thinks it might have missed. + * Keeps the current state for contexts up to date by applying the + suitable conflict resolution. + * Sends outgoing pdus wrapped in transactions. + * Fills out the references to previous pdus/transactions appropriately + for outgoing data. + """ + + def __init__(self, hs, transport_layer): + self.server_name = hs.hostname + + self.transport_layer = transport_layer + self.transport_layer.register_received_handler(self) + self.transport_layer.register_request_handler(self) + + self.store = hs.get_datastore() + self.pdu_actions = PduActions(self.store) + self.transaction_actions = TransactionActions(self.store) + + self._transaction_queue = _TransactionQueue( + hs, self.transaction_actions, transport_layer + ) + + self.handler = None + self.edu_handlers = {} + + self._order = 0 + + self._clock = hs.get_clock() + + def set_handler(self, handler): + """Sets the handler that the replication layer will use to communicate + receipt of new PDUs from other home servers. The required methods are + documented on :py:class:`.ReplicationHandler`. + """ + self.handler = handler + + def register_edu_handler(self, edu_type, handler): + if edu_type in self.edu_handlers: + raise KeyError("Already have an EDU handler for %s" % (edu_type)) + + self.edu_handlers[edu_type] = handler + + @defer.inlineCallbacks + @log_function + def send_pdu(self, pdu): + """Informs the replication layer about a new PDU generated within the + home server that should be transmitted to others. + + This will fill out various attributes on the PDU object, e.g. the + `prev_pdus` key. + + *Note:* The home server should always call `send_pdu` even if it knows + that it does not need to be replicated to other home servers. This is + in case e.g. someone else joins via a remote home server and then + paginates. + + TODO: Figure out when we should actually resolve the deferred. + + Args: + pdu (Pdu): The new Pdu. + + Returns: + Deferred: Completes when we have successfully processed the PDU + and replicated it to any interested remote home servers. + """ + order = self._order + self._order += 1 + + logger.debug("[%s] Persisting PDU", pdu.pdu_id) + + #yield self.pdu_actions.populate_previous_pdus(pdu) + + # Save *before* trying to send + yield self.pdu_actions.persist_outgoing(pdu) + + logger.debug("[%s] Persisted PDU", pdu.pdu_id) + logger.debug("[%s] transaction_layer.enqueue_pdu... ", pdu.pdu_id) + + # TODO, add errback, etc. + self._transaction_queue.enqueue_pdu(pdu, order) + + logger.debug("[%s] transaction_layer.enqueue_pdu... done", pdu.pdu_id) + + @log_function + def send_edu(self, destination, edu_type, content): + edu = Edu( + origin=self.server_name, + destination=destination, + edu_type=edu_type, + content=content, + ) + + # TODO, add errback, etc. + self._transaction_queue.enqueue_edu(edu) + + @defer.inlineCallbacks + @log_function + def paginate(self, dest, context, limit): + """Requests some more historic PDUs for the given context from the + given destination server. + + Args: + dest (str): The remote home server to ask. + context (str): The context to paginate back on. + limit (int): The maximum number of PDUs to return. + + Returns: + Deferred: Results in the received PDUs. + """ + extremities = yield self.store.get_oldest_pdus_in_context(context) + + logger.debug("paginate extrem=%s", extremities) + + # If there are no extremeties then we've (probably) reached the start. + if not extremities: + return + + transaction_data = yield self.transport_layer.paginate( + dest, context, extremities, limit) + + logger.debug("paginate transaction_data=%s", repr(transaction_data)) + + transaction = Transaction(**transaction_data) + + pdus = [Pdu(outlier=False, **p) for p in transaction.pdus] + for pdu in pdus: + yield self._handle_new_pdu(pdu) + + defer.returnValue(pdus) + + @defer.inlineCallbacks + @log_function + def get_pdu(self, destination, pdu_origin, pdu_id, outlier=False): + """Requests the PDU with given origin and ID from the remote home + server. + + This will persist the PDU locally upon receipt. + + Args: + destination (str): Which home server to query + pdu_origin (str): The home server that originally sent the pdu. + pdu_id (str) + outlier (bool): Indicates whether the PDU is an `outlier`, i.e. if + it's from an arbitary point in the context as opposed to part + of the current block of PDUs. Defaults to `False` + + Returns: + Deferred: Results in the requested PDU. + """ + + transaction_data = yield self.transport_layer.get_pdu( + destination, pdu_origin, pdu_id) + + transaction = Transaction(**transaction_data) + + pdu_list = [Pdu(outlier=outlier, **p) for p in transaction.pdus] + + pdu = None + if pdu_list: + pdu = pdu_list[0] + yield self._handle_new_pdu(pdu) + + defer.returnValue(pdu) + + @defer.inlineCallbacks + @log_function + def get_state_for_context(self, destination, context): + """Requests all of the `current` state PDUs for a given context from + a remote home server. + + Args: + destination (str): The remote homeserver to query for the state. + context (str): The context we're interested in. + + Returns: + Deferred: Results in a list of PDUs. + """ + + transaction_data = yield self.transport_layer.get_context_state( + destination, context) + + transaction = Transaction(**transaction_data) + + pdus = [Pdu(outlier=True, **p) for p in transaction.pdus] + for pdu in pdus: + yield self._handle_new_pdu(pdu) + + defer.returnValue(pdus) + + @defer.inlineCallbacks + @log_function + def on_context_pdus_request(self, context): + pdus = yield self.pdu_actions.get_all_pdus_from_context( + context + ) + defer.returnValue((200, self._transaction_from_pdus(pdus).get_dict())) + + @defer.inlineCallbacks + @log_function + def on_paginate_request(self, context, versions, limit): + + pdus = yield self.pdu_actions.paginate(context, versions, limit) + + defer.returnValue((200, self._transaction_from_pdus(pdus).get_dict())) + + @defer.inlineCallbacks + @log_function + def on_incoming_transaction(self, transaction_data): + transaction = Transaction(**transaction_data) + + logger.debug("[%s] Got transaction", transaction.transaction_id) + + response = yield self.transaction_actions.have_responded(transaction) + + if response: + logger.debug("[%s] We've already responed to this request", + transaction.transaction_id) + defer.returnValue(response) + return + + logger.debug("[%s] Transacition is new", transaction.transaction_id) + + pdu_list = [Pdu(**p) for p in transaction.pdus] + + dl = [] + for pdu in pdu_list: + dl.append(self._handle_new_pdu(pdu)) + + if hasattr(transaction, "edus"): + for edu in [Edu(**x) for x in transaction.edus]: + self.received_edu(edu.origin, edu.edu_type, edu.content) + + results = yield defer.DeferredList(dl) + + ret = [] + for r in results: + if r[0]: + ret.append({}) + else: + logger.exception(r[1]) + ret.append({"error": str(r[1])}) + + logger.debug("Returning: %s", str(ret)) + + yield self.transaction_actions.set_response( + transaction, + 200, response + ) + defer.returnValue((200, response)) + + def received_edu(self, origin, edu_type, content): + if edu_type in self.edu_handlers: + self.edu_handlers[edu_type](origin, content) + else: + logger.warn("Received EDU of type %s with no handler", edu_type) + + @defer.inlineCallbacks + @log_function + def on_context_state_request(self, context): + results = yield self.store.get_current_state_for_context( + context + ) + + logger.debug("Context returning %d results", len(results)) + + pdus = [Pdu.from_pdu_tuple(p) for p in results] + defer.returnValue((200, self._transaction_from_pdus(pdus).get_dict())) + + @defer.inlineCallbacks + @log_function + def on_pdu_request(self, pdu_origin, pdu_id): + pdu = yield self._get_persisted_pdu(pdu_id, pdu_origin) + + if pdu: + defer.returnValue( + (200, self._transaction_from_pdus([pdu]).get_dict()) + ) + else: + defer.returnValue((404, "")) + + @defer.inlineCallbacks + @log_function + def on_pull_request(self, origin, versions): + transaction_id = max([int(v) for v in versions]) + + response = yield self.pdu_actions.after_transaction( + transaction_id, + origin, + self.server_name + ) + + if not response: + response = [] + + defer.returnValue( + (200, self._transaction_from_pdus(response).get_dict()) + ) + + @defer.inlineCallbacks + @log_function + def _get_persisted_pdu(self, pdu_id, pdu_origin): + """ Get a PDU from the database with given origin and id. + + Returns: + Deferred: Results in a `Pdu`. + """ + pdu_tuple = yield self.store.get_pdu(pdu_id, pdu_origin) + + defer.returnValue(Pdu.from_pdu_tuple(pdu_tuple)) + + def _transaction_from_pdus(self, pdu_list): + """Returns a new Transaction containing the given PDUs suitable for + transmission. + """ + return Transaction( + pdus=[p.get_dict() for p in pdu_list], + origin=self.server_name, + ts=int(self._clock.time_msec()), + destination=None, + ) + + @defer.inlineCallbacks + @log_function + def _handle_new_pdu(self, pdu): + # We reprocess pdus when we have seen them only as outliers + existing = yield self._get_persisted_pdu(pdu.pdu_id, pdu.origin) + + if existing and (not existing.outlier or pdu.outlier): + logger.debug("Already seen pdu %s %s", pdu.pdu_id, pdu.origin) + defer.returnValue({}) + return + + # Get missing pdus if necessary. + is_new = yield self.pdu_actions.is_new(pdu) + if is_new and not pdu.outlier: + # We only paginate backwards to the min depth. + min_depth = yield self.store.get_min_depth_for_context(pdu.context) + + if min_depth and pdu.depth > min_depth: + for pdu_id, origin in pdu.prev_pdus: + exists = yield self._get_persisted_pdu(pdu_id, origin) + + if not exists: + logger.debug("Requesting pdu %s %s", pdu_id, origin) + + try: + yield self.get_pdu( + pdu.origin, + pdu_id=pdu_id, + pdu_origin=origin + ) + logger.debug("Processed pdu %s %s", pdu_id, origin) + except: + # TODO(erikj): Do some more intelligent retries. + logger.exception("Failed to get PDU") + + # Persist the Pdu, but don't mark it as processed yet. + yield self.pdu_actions.persist_received(pdu) + + ret = yield self.handler.on_receive_pdu(pdu) + + yield self.pdu_actions.mark_as_processed(pdu) + + defer.returnValue(ret) + + def __str__(self): + return "" % self.server_name + + +class ReplicationHandler(object): + """This defines the methods that the :py:class:`.ReplicationLayer` will + use to communicate with the rest of the home server. + """ + def on_receive_pdu(self, pdu): + raise NotImplementedError("on_receive_pdu") + + +class _TransactionQueue(object): + """This class makes sure we only have one transaction in flight at + a time for a given destination. + + It batches pending PDUs into single transactions. + """ + + def __init__(self, hs, transaction_actions, transport_layer): + + self.server_name = hs.hostname + self.transaction_actions = transaction_actions + self.transport_layer = transport_layer + + self._clock = hs.get_clock() + + # Is a mapping from destinations -> deferreds. Used to keep track + # of which destinations have transactions in flight and when they are + # done + self.pending_transactions = {} + + # Is a mapping from destination -> list of + # tuple(pending pdus, deferred, order) + self.pending_pdus_by_dest = {} + # destination -> list of tuple(edu, deferred) + self.pending_edus_by_dest = {} + + # HACK to get unique tx id + self._next_txn_id = int(self._clock.time_msec()) + + @defer.inlineCallbacks + @log_function + def enqueue_pdu(self, pdu, order): + # We loop through all destinations to see whether we already have + # a transaction in progress. If we do, stick it in the pending_pdus + # table and we'll get back to it later. + + destinations = [ + d for d in pdu.destinations + if d != self.server_name + ] + + logger.debug("Sending to: %s", str(destinations)) + + if not destinations: + return + + deferreds = [] + + for destination in destinations: + deferred = defer.Deferred() + self.pending_pdus_by_dest.setdefault(destination, []).append( + (pdu, deferred, order) + ) + + self._attempt_new_transaction(destination) + + deferreds.append(deferred) + + yield defer.DeferredList(deferreds) + + # NO inlineCallbacks + def enqueue_edu(self, edu): + destination = edu.destination + + deferred = defer.Deferred() + self.pending_edus_by_dest.setdefault(destination, []).append( + (edu, deferred) + ) + + def eb(failure): + deferred.errback(failure) + self._attempt_new_transaction(destination).addErrback(eb) + + return deferred + + @defer.inlineCallbacks + @log_function + def _attempt_new_transaction(self, destination): + if destination in self.pending_transactions: + return + + # list of (pending_pdu, deferred, order) + pending_pdus = self.pending_pdus_by_dest.pop(destination, []) + pending_edus = self.pending_edus_by_dest.pop(destination, []) + + if not pending_pdus and not pending_edus: + return + + logger.debug("TX [%s] Attempting new transaction", destination) + + # Sort based on the order field + pending_pdus.sort(key=lambda t: t[2]) + + pdus = [x[0] for x in pending_pdus] + edus = [x[0] for x in pending_edus] + deferreds = [x[1] for x in pending_pdus + pending_edus] + + try: + self.pending_transactions[destination] = 1 + + logger.debug("TX [%s] Persisting transaction...", destination) + + transaction = Transaction.create_new( + ts=self._clock.time_msec(), + transaction_id=self._next_txn_id, + origin=self.server_name, + destination=destination, + pdus=pdus, + edus=edus, + ) + + self._next_txn_id += 1 + + yield self.transaction_actions.prepare_to_send(transaction) + + logger.debug("TX [%s] Persisted transaction", destination) + logger.debug("TX [%s] Sending transaction...", destination) + + # Actually send the transaction + code, response = yield self.transport_layer.send_transaction( + transaction + ) + + logger.debug("TX [%s] Sent transaction", destination) + logger.debug("TX [%s] Marking as delivered...", destination) + + yield self.transaction_actions.delivered( + transaction, code, response + ) + + logger.debug("TX [%s] Marked as delivered", destination) + logger.debug("TX [%s] Yielding to callbacks...", destination) + + for deferred in deferreds: + if code == 200: + deferred.callback(None) + else: + deferred.errback(RuntimeError("Got status %d" % code)) + + # Ensures we don't continue until all callbacks on that + # deferred have fired + yield deferred + + logger.debug("TX [%s] Yielded to callbacks", destination) + + except Exception as e: + logger.error("TX Problem in _attempt_transaction") + + # We capture this here as there as nothing actually listens + # for this finishing functions deferred. + logger.exception(e) + + for deferred in deferreds: + deferred.errback(e) + yield deferred + + finally: + # We want to be *very* sure we delete this after we stop processing + self.pending_transactions.pop(destination, None) + + # Check to see if there is anything else to send. + self._attempt_new_transaction(destination) diff --git a/synapse/federation/transport.py b/synapse/federation/transport.py new file mode 100644 index 0000000000..2136adf8d7 --- /dev/null +++ b/synapse/federation/transport.py @@ -0,0 +1,454 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The transport layer is responsible for both sending transactions to remote +home servers and receiving a variety of requests from other home servers. + +Typically, this is done over HTTP (and all home servers are required to +support HTTP), however individual pairings of servers may decide to communicate +over a different (albeit still reliable) protocol. +""" + +from twisted.internet import defer + +from synapse.util.logutils import log_function + +import logging +import json +import re + + +logger = logging.getLogger(__name__) + + +class TransportLayer(object): + """This is a basic implementation of the transport layer that translates + transactions and other requests to/from HTTP. + + Attributes: + server_name (str): Local home server host + + server (synapse.http.server.HttpServer): the http server to + register listeners on + + client (synapse.http.client.HttpClient): the http client used to + send requests + + request_handler (TransportRequestHandler): The handler to fire when we + receive requests for data. + + received_handler (TransportReceivedHandler): The handler to fire when + we receive data. + """ + + def __init__(self, server_name, server, client): + """ + Args: + server_name (str): Local home server host + server (synapse.protocol.http.HttpServer): the http server to + register listeners on + client (synapse.protocol.http.HttpClient): the http client used to + send requests + """ + self.server_name = server_name + self.server = server + self.client = client + self.request_handler = None + self.received_handler = None + + @log_function + def get_context_state(self, destination, context): + """ Requests all state for a given context (i.e. room) from the + given server. + + Args: + destination (str): The host name of the remote home server we want + to get the state from. + context (str): The name of the context we want the state of + + Returns: + Deferred: Results in a dict received from the remote homeserver. + """ + logger.debug("get_context_state dest=%s, context=%s", + destination, context) + + path = "/state/%s/" % context + + return self._do_request_for_transaction(destination, path) + + @log_function + def get_pdu(self, destination, pdu_origin, pdu_id): + """ Requests the pdu with give id and origin from the given server. + + Args: + destination (str): The host name of the remote home server we want + to get the state from. + pdu_origin (str): The home server which created the PDU. + pdu_id (str): The id of the PDU being requested. + + Returns: + Deferred: Results in a dict received from the remote homeserver. + """ + logger.debug("get_pdu dest=%s, pdu_origin=%s, pdu_id=%s", + destination, pdu_origin, pdu_id) + + path = "/pdu/%s/%s/" % (pdu_origin, pdu_id) + + return self._do_request_for_transaction(destination, path) + + @log_function + def paginate(self, dest, context, pdu_tuples, limit): + """ Requests `limit` previous PDUs in a given context before list of + PDUs. + + Args: + dest (str) + context (str) + pdu_tuples (list) + limt (int) + + Returns: + Deferred: Results in a dict received from the remote homeserver. + """ + logger.debug( + "paginate dest=%s, context=%s, pdu_tuples=%s, limit=%s", + dest, context, repr(pdu_tuples), str(limit) + ) + + if not pdu_tuples: + return + + path = "/paginate/%s/" % context + + args = {"v": ["%s,%s" % (i, o) for i, o in pdu_tuples]} + args["limit"] = limit + + return self._do_request_for_transaction( + dest, + path, + args=args, + ) + + @defer.inlineCallbacks + @log_function + def send_transaction(self, transaction): + """ Sends the given Transaction to it's destination + + Args: + transaction (Transaction) + + Returns: + Deferred: Results of the deferred is a tuple in the form of + (response_code, response_body) where the response_body is a + python dict decoded from json + """ + logger.debug( + "send_data dest=%s, txid=%s", + transaction.destination, transaction.transaction_id + ) + + if transaction.destination == self.server_name: + raise RuntimeError("Transport layer cannot send to itself!") + + data = transaction.get_dict() + + code, response = yield self.client.put_json( + transaction.destination, + path="/send/%s/" % transaction.transaction_id, + data=data + ) + + logger.debug( + "send_data dest=%s, txid=%s, got response: %d", + transaction.destination, transaction.transaction_id, code + ) + + defer.returnValue((code, response)) + + @log_function + def register_received_handler(self, handler): + """ Register a handler that will be fired when we receive data. + + Args: + handler (TransportReceivedHandler) + """ + self.received_handler = handler + + # This is when someone is trying to send us a bunch of data. + self.server.register_path( + "PUT", + re.compile("^/send/([^/]*)/$"), + self._on_send_request + ) + + @log_function + def register_request_handler(self, handler): + """ Register a handler that will be fired when we get asked for data. + + Args: + handler (TransportRequestHandler) + """ + self.request_handler = handler + + # TODO(markjh): Namespace the federation URI paths + + # This is for when someone asks us for everything since version X + self.server.register_path( + "GET", + re.compile("^/pull/$"), + lambda request: handler.on_pull_request( + request.args["origin"][0], + request.args["v"] + ) + ) + + # This is when someone asks for a data item for a given server + # data_id pair. + self.server.register_path( + "GET", + re.compile("^/pdu/([^/]*)/([^/]*)/$"), + lambda request, pdu_origin, pdu_id: handler.on_pdu_request( + pdu_origin, pdu_id + ) + ) + + # This is when someone asks for all data for a given context. + self.server.register_path( + "GET", + re.compile("^/state/([^/]*)/$"), + lambda request, context: handler.on_context_state_request( + context + ) + ) + + self.server.register_path( + "GET", + re.compile("^/paginate/([^/]*)/$"), + lambda request, context: self._on_paginate_request( + context, request.args["v"], + request.args["limit"] + ) + ) + + self.server.register_path( + "GET", + re.compile("^/context/([^/]*)/$"), + lambda request, context: handler.on_context_pdus_request(context) + ) + + @defer.inlineCallbacks + @log_function + def _on_send_request(self, request, transaction_id): + """ Called on PUT /send// + + Args: + request (twisted.web.http.Request): The HTTP request. + transaction_id (str): The transaction_id associated with this + request. This is *not* None. + + Returns: + Deferred: Results in a tuple of `(code, response)`, where + `response` is a python dict to be converted into JSON that is + used as the response body. + """ + # Parse the request + try: + data = request.content.read() + + l = data[:20].encode("string_escape") + logger.debug("Got data: \"%s\"", l) + + transaction_data = json.loads(data) + + logger.debug( + "Decoded %s: %s", + transaction_id, str(transaction_data) + ) + + # We should ideally be getting this from the security layer. + # origin = body["origin"] + + # Add some extra data to the transaction dict that isn't included + # in the request body. + transaction_data.update( + transaction_id=transaction_id, + destination=self.server_name + ) + + except Exception as e: + logger.exception(e) + defer.returnValue((400, {"error": "Invalid transaction"})) + return + + code, response = yield self.received_handler.on_incoming_transaction( + transaction_data + ) + + defer.returnValue((code, response)) + + @defer.inlineCallbacks + @log_function + def _do_request_for_transaction(self, destination, path, args={}): + """ + Args: + destination (str) + path (str) + args (dict): This is parsed directly to the HttpClient. + + Returns: + Deferred: Results in a dict. + """ + + data = yield self.client.get_json( + destination, + path=path, + args=args, + ) + + # Add certain keys to the JSON, ready for decoding as a Transaction + data.update( + origin=destination, + destination=self.server_name, + transaction_id=None + ) + + defer.returnValue(data) + + @log_function + def _on_paginate_request(self, context, v_list, limits): + if not limits: + return defer.succeed( + (400, {"error": "Did not include limit param"}) + ) + + limit = int(limits[-1]) + + versions = [v.split(",", 1) for v in v_list] + + return self.request_handler.on_paginate_request( + context, versions, limit) + + +class TransportReceivedHandler(object): + """ Callbacks used when we receive a transaction + """ + def on_incoming_transaction(self, transaction): + """ Called on PUT /send/, or on response to a request + that we sent (e.g. a pagination request) + + Args: + transaction (synapse.transaction.Transaction): The transaction that + was sent to us. + + Returns: + twisted.internet.defer.Deferred: A deferred that get's fired when + the transaction has finished being processed. + + The result should be a tuple in the form of + `(response_code, respond_body)`, where `response_body` is a python + dict that will get serialized to JSON. + + On errors, the dict should have an `error` key with a brief message + of what went wrong. + """ + pass + + +class TransportRequestHandler(object): + """ Handlers used when someone want's data from us + """ + def on_pull_request(self, versions): + """ Called on GET /pull/?v=... + + This is hit when a remote home server wants to get all data + after a given transaction. Mainly used when a home server comes back + online and wants to get everything it has missed. + + Args: + versions (list): A list of transaction_ids that should be used to + determine what PDUs the remote side have not yet seen. + + Returns: + Deferred: Resultsin a tuple in the form of + `(response_code, respond_body)`, where `response_body` is a python + dict that will get serialized to JSON. + + On errors, the dict should have an `error` key with a brief message + of what went wrong. + """ + pass + + def on_pdu_request(self, pdu_origin, pdu_id): + """ Called on GET /pdu/// + + Someone wants a particular PDU. This PDU may or may not have originated + from us. + + Args: + pdu_origin (str) + pdu_id (str) + + Returns: + Deferred: Resultsin a tuple in the form of + `(response_code, respond_body)`, where `response_body` is a python + dict that will get serialized to JSON. + + On errors, the dict should have an `error` key with a brief message + of what went wrong. + """ + pass + + def on_context_state_request(self, context): + """ Called on GET /state// + + Get's hit when someone wants all the *current* state for a given + contexts. + + Args: + context (str): The name of the context that we're interested in. + + Returns: + twisted.internet.defer.Deferred: A deferred that get's fired when + the transaction has finished being processed. + + The result should be a tuple in the form of + `(response_code, respond_body)`, where `response_body` is a python + dict that will get serialized to JSON. + + On errors, the dict should have an `error` key with a brief message + of what went wrong. + """ + pass + + def on_paginate_request(self, context, versions, limit): + """ Called on GET /paginate//?v=...&limit=... + + Get's hit when we want to paginate backwards on a given context from + the given point. + + Args: + context (str): The context to paginate on + versions (list): A list of 2-tuple's representing where to paginate + from, in the form `(pdu_id, origin)` + limit (int): How many pdus to return. + + Returns: + Deferred: Resultsin a tuple in the form of + `(response_code, respond_body)`, where `response_body` is a python + dict that will get serialized to JSON. + + On errors, the dict should have an `error` key with a brief message + of what went wrong. + """ + pass diff --git a/synapse/federation/units.py b/synapse/federation/units.py new file mode 100644 index 0000000000..0efea7b768 --- /dev/null +++ b/synapse/federation/units.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" Defines the JSON structure of the protocol units used by the server to +server protocol. +""" + +from synapse.util.jsonobject import JsonEncodedObject + +import logging +import json +import copy + + +logger = logging.getLogger(__name__) + + +class Pdu(JsonEncodedObject): + """ A Pdu represents a piece of data sent from a server and is associated + with a context. + + A Pdu can be classified as "state". For a given context, we can efficiently + retrieve all state pdu's that haven't been clobbered. Clobbering is done + via a unique constraint on the tuple (context, pdu_type, state_key). A pdu + is a state pdu if `is_state` is True. + + Example pdu:: + + { + "pdu_id": "78c", + "ts": 1404835423000, + "origin": "bar", + "prev_ids": [ + ["23b", "foo"], + ["56a", "bar"], + ], + "content": { ... }, + } + + """ + + valid_keys = [ + "pdu_id", + "context", + "origin", + "ts", + "pdu_type", + "destinations", + "transaction_id", + "prev_pdus", + "depth", + "content", + "outlier", + "is_state", # Below this are keys valid only for State Pdus. + "state_key", + "power_level", + "prev_state_id", + "prev_state_origin", + ] + + internal_keys = [ + "destinations", + "transaction_id", + "outlier", + ] + + required_keys = [ + "pdu_id", + "context", + "origin", + "ts", + "pdu_type", + "content", + ] + + # TODO: We need to make this properly load content rather than + # just leaving it as a dict. (OR DO WE?!) + + def __init__(self, destinations=[], is_state=False, prev_pdus=[], + outlier=False, **kwargs): + if is_state: + for required_key in ["state_key"]: + if required_key not in kwargs: + raise RuntimeError("Key %s is required" % required_key) + + super(Pdu, self).__init__( + destinations=destinations, + is_state=is_state, + prev_pdus=prev_pdus, + outlier=outlier, + **kwargs + ) + + @classmethod + def from_pdu_tuple(cls, pdu_tuple): + """ Converts a PduTuple to a Pdu + + Args: + pdu_tuple (synapse.persistence.transactions.PduTuple): The tuple to + convert + + Returns: + Pdu + """ + if pdu_tuple: + d = copy.copy(pdu_tuple.pdu_entry._asdict()) + + d["content"] = json.loads(d["content_json"]) + del d["content_json"] + + args = {f: d[f] for f in cls.valid_keys if f in d} + if "unrecognized_keys" in d and d["unrecognized_keys"]: + args.update(json.loads(d["unrecognized_keys"])) + + return Pdu( + prev_pdus=pdu_tuple.prev_pdu_list, + **args + ) + else: + return None + + def __str__(self): + return "(%s, %s)" % (self.__class__.__name__, repr(self.__dict__)) + + def __repr__(self): + return "<%s, %s>" % (self.__class__.__name__, repr(self.__dict__)) + + +class Edu(JsonEncodedObject): + """ An Edu represents a piece of data sent from one homeserver to another. + + In comparison to Pdus, Edus are not persisted for a long time on disk, are + not meaningful beyond a given pair of homeservers, and don't have an + internal ID or previous references graph. + """ + + valid_keys = [ + "origin", + "destination", + "edu_type", + "content", + ] + + required_keys = [ + "origin", + "destination", + "edu_type", + ] + + +class Transaction(JsonEncodedObject): + """ A transaction is a list of Pdus and Edus to be sent to a remote home + server with some extra metadata. + + Example transaction:: + + { + "origin": "foo", + "prev_ids": ["abc", "def"], + "pdus": [ + ... + ], + } + + """ + + valid_keys = [ + "transaction_id", + "origin", + "destination", + "ts", + "previous_ids", + "pdus", + "edus", + ] + + internal_keys = [ + "transaction_id", + "destination", + ] + + required_keys = [ + "transaction_id", + "origin", + "destination", + "ts", + "pdus", + ] + + def __init__(self, transaction_id=None, pdus=[], **kwargs): + """ If we include a list of pdus then we decode then as PDU's + automatically. + """ + + # If there's no EDUs then remove the arg + if "edus" in kwargs and not kwargs["edus"]: + del kwargs["edus"] + + super(Transaction, self).__init__( + transaction_id=transaction_id, + pdus=pdus, + **kwargs + ) + + @staticmethod + def create_new(pdus, **kwargs): + """ Used to create a new transaction. Will auto fill out + transaction_id and ts keys. + """ + if "ts" not in kwargs: + raise KeyError("Require 'ts' to construct a Transaction") + if "transaction_id" not in kwargs: + raise KeyError( + "Require 'transaction_id' to construct a Transaction" + ) + + for p in pdus: + p.transaction_id = kwargs["transaction_id"] + + kwargs["pdus"] = [p.get_dict() for p in pdus] + + return Transaction(**kwargs) + + + diff --git a/synapse/handlers/__init__.py b/synapse/handlers/__init__.py new file mode 100644 index 0000000000..5688b68e49 --- /dev/null +++ b/synapse/handlers/__init__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from .register import RegistrationHandler +from .room import ( + MessageHandler, RoomCreationHandler, RoomMemberHandler, RoomListHandler +) +from .events import EventStreamHandler +from .federation import FederationHandler +from .login import LoginHandler +from .profile import ProfileHandler +from .presence import PresenceHandler +from .directory import DirectoryHandler + + +class Handlers(object): + + """ A collection of all the event handlers. + + There's no need to lazily create these; we'll just make them all eagerly + at construction time. + """ + + def __init__(self, hs): + self.registration_handler = RegistrationHandler(hs) + self.message_handler = MessageHandler(hs) + self.room_creation_handler = RoomCreationHandler(hs) + self.room_member_handler = RoomMemberHandler(hs) + self.event_stream_handler = EventStreamHandler(hs) + self.federation_handler = FederationHandler(hs) + self.profile_handler = ProfileHandler(hs) + self.presence_handler = PresenceHandler(hs) + self.room_list_handler = RoomListHandler(hs) + self.login_handler = LoginHandler(hs) + self.directory_handler = DirectoryHandler(hs) diff --git a/synapse/handlers/_base.py b/synapse/handlers/_base.py new file mode 100644 index 0000000000..87a392dd77 --- /dev/null +++ b/synapse/handlers/_base.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class BaseHandler(object): + + def __init__(self, hs): + self.store = hs.get_datastore() + self.event_factory = hs.get_event_factory() + self.auth = hs.get_auth() + self.notifier = hs.get_notifier() + self.room_lock = hs.get_room_lock_manager() + self.state_handler = hs.get_state_handler() + self.hs = hs diff --git a/synapse/handlers/directory.py b/synapse/handlers/directory.py new file mode 100644 index 0000000000..456007c71d --- /dev/null +++ b/synapse/handlers/directory.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer +from ._base import BaseHandler + +from synapse.api.errors import SynapseError + +import logging +import json +import urllib + + +logger = logging.getLogger(__name__) + + +# TODO(erikj): This needs to be factored out somewere +PREFIX = "/matrix/client/api/v1" + + +class DirectoryHandler(BaseHandler): + + def __init__(self, hs): + super(DirectoryHandler, self).__init__(hs) + self.hs = hs + self.http_client = hs.get_http_client() + self.clock = hs.get_clock() + + @defer.inlineCallbacks + def create_association(self, room_alias, room_id, servers): + # TODO(erikj): Do auth. + + if not room_alias.is_mine: + raise SynapseError(400, "Room alias must be local") + # TODO(erikj): Change this. + + # TODO(erikj): Add transactions. + + # TODO(erikj): Check if there is a current association. + + yield self.store.create_room_alias_association( + room_alias, + room_id, + servers + ) + + @defer.inlineCallbacks + def get_association(self, room_alias, local_only=False): + # TODO(erikj): Do auth + + room_id = None + if room_alias.is_mine: + result = yield self.store.get_association_from_room_alias( + room_alias + ) + + if result: + room_id = result.room_id + servers = result.servers + elif not local_only: + path = "%s/ds/room/%s?local_only=1" % ( + PREFIX, + urllib.quote(room_alias.to_string()) + ) + + result = None + try: + result = yield self.http_client.get_json( + destination=room_alias.domain, + path=path, + ) + except: + # TODO(erikj): Handle this better? + logger.exception("Failed to get remote room alias") + + if result and "room_id" in result and "servers" in result: + room_id = result["room_id"] + servers = result["servers"] + + if not room_id: + defer.returnValue({}) + return + + defer.returnValue({ + "room_id": room_id, + "servers": servers, + }) + return diff --git a/synapse/handlers/events.py b/synapse/handlers/events.py new file mode 100644 index 0000000000..79742a4e1c --- /dev/null +++ b/synapse/handlers/events.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from ._base import BaseHandler +from synapse.api.streams.event import ( + EventStream, MessagesStreamData, RoomMemberStreamData, FeedbackStreamData, + RoomDataStreamData +) +from synapse.handlers.presence import PresenceStreamData + + +class EventStreamHandler(BaseHandler): + + stream_data_classes = [ + MessagesStreamData, + RoomMemberStreamData, + FeedbackStreamData, + RoomDataStreamData, + PresenceStreamData, + ] + + def __init__(self, hs): + super(EventStreamHandler, self).__init__(hs) + + # Count of active streams per user + self._streams_per_user = {} + # Grace timers per user to delay the "stopped" signal + self._stop_timer_per_user = {} + + self.distributor = hs.get_distributor() + self.distributor.declare("started_user_eventstream") + self.distributor.declare("stopped_user_eventstream") + + self.clock = hs.get_clock() + + def get_event_stream_token(self, stream_type, store_id, start_token): + """Return the next token after this event. + + Args: + stream_type (str): The StreamData.EVENT_TYPE + store_id (int): The new storage ID assigned from the data store. + start_token (str): The token the user started with. + Returns: + str: The end token. + """ + for i, stream_cls in enumerate(EventStreamHandler.stream_data_classes): + if stream_cls.EVENT_TYPE == stream_type: + # this is the stream for this event, so replace this part of + # the token + store_ids = start_token.split(EventStream.SEPARATOR) + store_ids[i] = str(store_id) + return EventStream.SEPARATOR.join(store_ids) + raise RuntimeError("Didn't find a stream type %s" % stream_type) + + @defer.inlineCallbacks + def get_stream(self, auth_user_id, pagin_config, timeout=0): + """Gets events as an event stream for this user. + + This function looks for interesting *events* for this user. This is + different from the notifier, which looks for interested *users* who may + want to know about a single event. + + Args: + auth_user_id (str): The user requesting their event stream. + pagin_config (synapse.api.streams.PaginationConfig): The config to + use when obtaining the stream. + timeout (int): The max time to wait for an incoming event in ms. + Returns: + A pagination stream API dict + """ + auth_user = self.hs.parse_userid(auth_user_id) + + stream_id = object() + + try: + if auth_user not in self._streams_per_user: + self._streams_per_user[auth_user] = 0 + if auth_user in self._stop_timer_per_user: + self.clock.cancel_call_later( + self._stop_timer_per_user.pop(auth_user)) + else: + self.distributor.fire( + "started_user_eventstream", auth_user + ) + self._streams_per_user[auth_user] += 1 + + # construct an event stream with the correct data ordering + stream_data_list = [] + for stream_class in EventStreamHandler.stream_data_classes: + stream_data_list.append(stream_class(self.hs)) + event_stream = EventStream(auth_user_id, stream_data_list) + + # fix unknown tokens to known tokens + pagin_config = yield event_stream.fix_tokens(pagin_config) + + # register interest in receiving new events + self.notifier.store_events_for(user_id=auth_user_id, + stream_id=stream_id, + from_tok=pagin_config.from_tok) + + # see if we can grab a chunk now + data_chunk = yield event_stream.get_chunk(config=pagin_config) + + # if there are previous events, return those. If not, wait on the + # new events for 'timeout' seconds. + if len(data_chunk["chunk"]) == 0 and timeout != 0: + results = yield defer.maybeDeferred( + self.notifier.get_events_for, + user_id=auth_user_id, + stream_id=stream_id, + timeout=timeout + ) + if results: + defer.returnValue(results) + + defer.returnValue(data_chunk) + finally: + # cleanup + self.notifier.purge_events_for(user_id=auth_user_id, + stream_id=stream_id) + + self._streams_per_user[auth_user] -= 1 + if not self._streams_per_user[auth_user]: + del self._streams_per_user[auth_user] + + # 10 seconds of grace to allow the client to reconnect again + # before we think they're gone + def _later(): + self.distributor.fire( + "stopped_user_eventstream", auth_user + ) + del self._stop_timer_per_user[auth_user] + + self._stop_timer_per_user[auth_user] = ( + self.clock.call_later(5, _later) + ) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py new file mode 100644 index 0000000000..12e7afca4c --- /dev/null +++ b/synapse/handlers/federation.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Contains handlers for federation events.""" + +from ._base import BaseHandler + +from synapse.api.events.room import InviteJoinEvent, RoomMemberEvent +from synapse.api.constants import Membership +from synapse.util.logutils import log_function + +from twisted.internet import defer + +import logging + + +logger = logging.getLogger(__name__) + + +class FederationHandler(BaseHandler): + + """Handles events that originated from federation.""" + + @log_function + @defer.inlineCallbacks + def on_receive(self, event, is_new_state): + if hasattr(event, "state_key") and not is_new_state: + logger.debug("Ignoring old state.") + return + + target_is_mine = False + if hasattr(event, "target_host"): + target_is_mine = event.target_host == self.hs.hostname + + if event.type == InviteJoinEvent.TYPE: + if not target_is_mine: + logger.debug("Ignoring invite/join event %s", event) + return + + # If we receive an invite/join event then we need to join the + # sender to the given room. + # TODO: We should probably auth this or some such + content = event.content + content.update({"membership": Membership.JOIN}) + new_event = self.event_factory.create_event( + etype=RoomMemberEvent.TYPE, + target_user_id=event.user_id, + room_id=event.room_id, + user_id=event.user_id, + membership=Membership.JOIN, + content=content + ) + + yield self.hs.get_handlers().room_member_handler.change_membership( + new_event, + True + ) + + else: + with (yield self.room_lock.lock(event.room_id)): + store_id = yield self.store.persist_event(event) + + yield self.notifier.on_new_room_event(event, store_id) diff --git a/synapse/handlers/login.py b/synapse/handlers/login.py new file mode 100644 index 0000000000..5a1acd7102 --- /dev/null +++ b/synapse/handlers/login.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from ._base import BaseHandler +from synapse.api.errors import LoginError + +import bcrypt +import logging + +logger = logging.getLogger(__name__) + + +class LoginHandler(BaseHandler): + + def __init__(self, hs): + super(LoginHandler, self).__init__(hs) + self.hs = hs + + @defer.inlineCallbacks + def login(self, user, password): + """Login as the specified user with the specified password. + + Args: + user (str): The user ID. + password (str): The password. + Returns: + The newly allocated access token. + Raises: + StoreError if there was a problem storing the token. + LoginError if there was an authentication problem. + """ + # TODO do this better, it can't go in __init__ else it cyclic loops + if not hasattr(self, "reg_handler"): + self.reg_handler = self.hs.get_handlers().registration_handler + + # pull out the hash for this user if they exist + user_info = yield self.store.get_user_by_id(user_id=user) + if not user_info: + logger.warn("Attempted to login as %s but they do not exist.", user) + raise LoginError(403, "") + + stored_hash = user_info[0]["password_hash"] + if bcrypt.checkpw(password, stored_hash): + # generate an access token and store it. + token = self.reg_handler._generate_token(user) + logger.info("Adding token %s for user %s", token, user) + yield self.store.add_access_token_to_user(user, token) + defer.returnValue(token) + else: + logger.warn("Failed password login for user %s", user) + raise LoginError(403, "") \ No newline at end of file diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py new file mode 100644 index 0000000000..38db4b1d67 --- /dev/null +++ b/synapse/handlers/presence.py @@ -0,0 +1,697 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from synapse.api.errors import SynapseError, AuthError +from synapse.api.constants import PresenceState +from synapse.api.streams import StreamData + +from ._base import BaseHandler + +import logging + + +logger = logging.getLogger(__name__) + + +# TODO(paul): Maybe there's one of these I can steal from somewhere +def partition(l, func): + """Partition the list by the result of func applied to each element.""" + ret = {} + + for x in l: + key = func(x) + if key not in ret: + ret[key] = [] + ret[key].append(x) + + return ret + + +def partitionbool(l, func): + def boolfunc(x): + return bool(func(x)) + + ret = partition(l, boolfunc) + return ret.get(True, []), ret.get(False, []) + + +class PresenceHandler(BaseHandler): + + def __init__(self, hs): + super(PresenceHandler, self).__init__(hs) + + self.homeserver = hs + + distributor = hs.get_distributor() + distributor.observe("registered_user", self.registered_user) + + distributor.observe( + "started_user_eventstream", self.started_user_eventstream + ) + distributor.observe( + "stopped_user_eventstream", self.stopped_user_eventstream + ) + + distributor.observe("user_joined_room", + self.user_joined_room + ) + + distributor.declare("collect_presencelike_data") + + distributor.declare("changed_presencelike_data") + distributor.observe( + "changed_presencelike_data", self.changed_presencelike_data + ) + + self.distributor = distributor + + self.federation = hs.get_replication_layer() + + self.federation.register_edu_handler( + "m.presence", self.incoming_presence + ) + self.federation.register_edu_handler( + "m.presence_invite", + lambda origin, content: self.invite_presence( + observed_user=hs.parse_userid(content["observed_user"]), + observer_user=hs.parse_userid(content["observer_user"]), + ) + ) + self.federation.register_edu_handler( + "m.presence_accept", + lambda origin, content: self.accept_presence( + observed_user=hs.parse_userid(content["observed_user"]), + observer_user=hs.parse_userid(content["observer_user"]), + ) + ) + self.federation.register_edu_handler( + "m.presence_deny", + lambda origin, content: self.deny_presence( + observed_user=hs.parse_userid(content["observed_user"]), + observer_user=hs.parse_userid(content["observer_user"]), + ) + ) + + # IN-MEMORY store, mapping local userparts to sets of local users to + # be informed of state changes. + self._local_pushmap = {} + # map local users to sets of remote /domain names/ who are interested + # in them + self._remote_sendmap = {} + # map remote users to sets of local users who're interested in them + self._remote_recvmap = {} + + # map any user to a UserPresenceCache + self._user_cachemap = {} + self._user_cachemap_latest_serial = 0 + + def _get_or_make_usercache(self, user): + """If the cache entry doesn't exist, initialise a new one.""" + if user not in self._user_cachemap: + self._user_cachemap[user] = UserPresenceCache() + return self._user_cachemap[user] + + def _get_or_offline_usercache(self, user): + """If the cache entry doesn't exist, return an OFFLINE one but do not + store it into the cache.""" + if user in self._user_cachemap: + return self._user_cachemap[user] + else: + statuscache = UserPresenceCache() + statuscache.update({"state": PresenceState.OFFLINE}, user) + return statuscache + + def registered_user(self, user): + self.store.create_presence(user.localpart) + + @defer.inlineCallbacks + def is_presence_visible(self, observer_user, observed_user): + assert(observed_user.is_mine) + + if observer_user == observed_user: + defer.returnValue(True) + + allowed_by_subscription = yield self.store.is_presence_visible( + observed_localpart=observed_user.localpart, + observer_userid=observer_user.to_string(), + ) + + if allowed_by_subscription: + defer.returnValue(True) + + # TODO(paul): Check same channel + + defer.returnValue(False) + + @defer.inlineCallbacks + def get_state(self, target_user, auth_user): + if target_user.is_mine: + visible = yield self.is_presence_visible(observer_user=auth_user, + observed_user=target_user + ) + + if visible: + state = yield self.store.get_presence_state( + target_user.localpart + ) + defer.returnValue(state) + else: + raise SynapseError(404, "Presence information not visible") + else: + # TODO(paul): Have remote server send us permissions set + defer.returnValue( + self._get_or_offline_usercache(target_user).get_state() + ) + + @defer.inlineCallbacks + def set_state(self, target_user, auth_user, state): + if not target_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + if target_user != auth_user: + raise AuthError(400, "Cannot set another user's displayname") + + # TODO(paul): Sanity-check 'state' + if "status_msg" not in state: + state["status_msg"] = None + + for k in state.keys(): + if k not in ("state", "status_msg"): + raise SynapseError( + 400, "Unexpected presence state key '%s'" % (k,) + ) + + logger.debug("Updating presence state of %s to %s", + target_user.localpart, state["state"]) + + state_to_store = dict(state) + + yield defer.DeferredList([ + self.store.set_presence_state( + target_user.localpart, state_to_store + ), + self.distributor.fire( + "collect_presencelike_data", target_user, state + ), + ]) + + now_online = state["state"] != PresenceState.OFFLINE + was_polling = target_user in self._user_cachemap + + if now_online and not was_polling: + self.start_polling_presence(target_user, state=state) + elif not now_online and was_polling: + self.stop_polling_presence(target_user) + + # TODO(paul): perform a presence push as part of start/stop poll so + # we don't have to do this all the time + self.changed_presencelike_data(target_user, state) + + if not now_online: + del self._user_cachemap[target_user] + + def changed_presencelike_data(self, user, state): + statuscache = self._get_or_make_usercache(user) + + self._user_cachemap_latest_serial += 1 + statuscache.update(state, serial=self._user_cachemap_latest_serial) + + self.push_presence(user, statuscache=statuscache) + + def started_user_eventstream(self, user): + # TODO(paul): Use "last online" state + self.set_state(user, user, {"state": PresenceState.ONLINE}) + + def stopped_user_eventstream(self, user): + # TODO(paul): Save current state as "last online" state + self.set_state(user, user, {"state": PresenceState.OFFLINE}) + + @defer.inlineCallbacks + def user_joined_room(self, user, room_id): + localusers = set() + remotedomains = set() + + rm_handler = self.homeserver.get_handlers().room_member_handler + yield rm_handler.fetch_room_distributions_into(room_id, + localusers=localusers, remotedomains=remotedomains, + ignore_user=user) + + if user.is_mine: + yield self._send_presence_to_distribution(srcuser=user, + localusers=localusers, remotedomains=remotedomains, + statuscache=self._get_or_offline_usercache(user), + ) + + for srcuser in localusers: + yield self._send_presence(srcuser=srcuser, destuser=user, + statuscache=self._get_or_offline_usercache(srcuser), + ) + + @defer.inlineCallbacks + def send_invite(self, observer_user, observed_user): + if not observer_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + yield self.store.add_presence_list_pending( + observer_user.localpart, observed_user.to_string() + ) + + if observed_user.is_mine: + yield self.invite_presence(observed_user, observer_user) + else: + yield self.federation.send_edu( + destination=observed_user.domain, + edu_type="m.presence_invite", + content={ + "observed_user": observed_user.to_string(), + "observer_user": observer_user.to_string(), + } + ) + + @defer.inlineCallbacks + def _should_accept_invite(self, observed_user, observer_user): + if not observed_user.is_mine: + defer.returnValue(False) + + row = yield self.store.has_presence_state(observed_user.localpart) + if not row: + defer.returnValue(False) + + # TODO(paul): Eventually we'll ask the user's permission for this + # before accepting. For now just accept any invite request + defer.returnValue(True) + + @defer.inlineCallbacks + def invite_presence(self, observed_user, observer_user): + accept = yield self._should_accept_invite(observed_user, observer_user) + + if accept: + yield self.store.allow_presence_visible( + observed_user.localpart, observer_user.to_string() + ) + + if observer_user.is_mine: + if accept: + yield self.accept_presence(observed_user, observer_user) + else: + yield self.deny_presence(observed_user, observer_user) + else: + edu_type = "m.presence_accept" if accept else "m.presence_deny" + + yield self.federation.send_edu( + destination=observer_user.domain, + edu_type=edu_type, + content={ + "observed_user": observed_user.to_string(), + "observer_user": observer_user.to_string(), + } + ) + + @defer.inlineCallbacks + def accept_presence(self, observed_user, observer_user): + yield self.store.set_presence_list_accepted( + observer_user.localpart, observed_user.to_string() + ) + + self.start_polling_presence(observer_user, target_user=observed_user) + + @defer.inlineCallbacks + def deny_presence(self, observed_user, observer_user): + yield self.store.del_presence_list( + observer_user.localpart, observed_user.to_string() + ) + + # TODO(paul): Inform the user somehow? + + @defer.inlineCallbacks + def drop(self, observed_user, observer_user): + if not observer_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + yield self.store.del_presence_list( + observer_user.localpart, observed_user.to_string() + ) + + self.stop_polling_presence(observer_user, target_user=observed_user) + + @defer.inlineCallbacks + def get_presence_list(self, observer_user, accepted=None): + if not observer_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + presence = yield self.store.get_presence_list( + observer_user.localpart, accepted=accepted + ) + + for p in presence: + observed_user = self.hs.parse_userid(p.pop("observed_user_id")) + p["observed_user"] = observed_user + p.update(self._get_or_offline_usercache(observed_user).get_state()) + + defer.returnValue(presence) + + @defer.inlineCallbacks + def start_polling_presence(self, user, target_user=None, state=None): + logger.debug("Start polling for presence from %s", user) + + if target_user: + target_users = [target_user] + else: + presence = yield self.store.get_presence_list( + user.localpart, accepted=True + ) + target_users = [ + self.hs.parse_userid(x["observed_user_id"]) for x in presence + ] + + if state is None: + state = yield self.store.get_presence_state(user.localpart) + + localusers, remoteusers = partitionbool( + target_users, + lambda u: u.is_mine + ) + + for target_user in localusers: + self._start_polling_local(user, target_user) + + deferreds = [] + remoteusers_by_domain = partition(remoteusers, lambda u: u.domain) + for domain in remoteusers_by_domain: + remoteusers = remoteusers_by_domain[domain] + + deferreds.append(self._start_polling_remote( + user, domain, remoteusers + )) + + yield defer.DeferredList(deferreds) + + def _start_polling_local(self, user, target_user): + target_localpart = target_user.localpart + + if not self.is_presence_visible(observer_user=user, + observed_user=target_user): + return + + if target_localpart not in self._local_pushmap: + self._local_pushmap[target_localpart] = set() + + self._local_pushmap[target_localpart].add(user) + + self.push_update_to_clients( + observer_user=user, + observed_user=target_user, + statuscache=self._get_or_offline_usercache(target_user), + ) + + def _start_polling_remote(self, user, domain, remoteusers): + for u in remoteusers: + if u not in self._remote_recvmap: + self._remote_recvmap[u] = set() + + self._remote_recvmap[u].add(user) + + return self.federation.send_edu( + destination=domain, + edu_type="m.presence", + content={"poll": [u.to_string() for u in remoteusers]} + ) + + def stop_polling_presence(self, user, target_user=None): + logger.debug("Stop polling for presence from %s", user) + + if not target_user or target_user.is_mine: + self._stop_polling_local(user, target_user=target_user) + + deferreds = [] + + if target_user: + raise NotImplementedError("TODO: remove one user") + + remoteusers = [u for u in self._remote_recvmap + if user in self._remote_recvmap[u]] + remoteusers_by_domain = partition(remoteusers, lambda u: u.domain) + + for domain in remoteusers_by_domain: + remoteusers = remoteusers_by_domain[domain] + + deferreds.append( + self._stop_polling_remote(user, domain, remoteusers) + ) + + return defer.DeferredList(deferreds) + + def _stop_polling_local(self, user, target_user): + for localpart in self._local_pushmap.keys(): + if target_user and localpart != target_user.localpart: + continue + + if user in self._local_pushmap[localpart]: + self._local_pushmap[localpart].remove(user) + + if not self._local_pushmap[localpart]: + del self._local_pushmap[localpart] + + def _stop_polling_remote(self, user, domain, remoteusers): + for u in remoteusers: + self._remote_recvmap[u].remove(user) + + if not self._remote_recvmap[u]: + del self._remote_recvmap[u] + + return self.federation.send_edu( + destination=domain, + edu_type="m.presence", + content={"unpoll": [u.to_string() for u in remoteusers]} + ) + + @defer.inlineCallbacks + def push_presence(self, user, statuscache): + assert(user.is_mine) + + logger.debug("Pushing presence update from %s", user) + + localusers = set(self._local_pushmap.get(user.localpart, set())) + remotedomains = set(self._remote_sendmap.get(user.localpart, set())) + + # Reflect users' status changes back to themselves, so UIs look nice + # and also user is informed of server-forced pushes + localusers.add(user) + + rm_handler = self.homeserver.get_handlers().room_member_handler + room_ids = yield rm_handler.get_rooms_for_user(user) + + for room_id in room_ids: + yield rm_handler.fetch_room_distributions_into( + room_id, localusers=localusers, remotedomains=remotedomains, + ignore_user=user, + ) + + if not localusers and not remotedomains: + defer.returnValue(None) + + yield self._send_presence_to_distribution(user, + localusers=localusers, remotedomains=remotedomains, + statuscache=statuscache + ) + + def _send_presence(self, srcuser, destuser, statuscache): + if destuser.is_mine: + self.push_update_to_clients( + observer_user=destuser, + observed_user=srcuser, + statuscache=statuscache) + return defer.succeed(None) + else: + return self._push_presence_remote(srcuser, destuser.domain, + state=statuscache.get_state() + ) + + @defer.inlineCallbacks + def _send_presence_to_distribution(self, srcuser, localusers=set(), + remotedomains=set(), statuscache=None): + + for u in localusers: + logger.debug(" | push to local user %s", u) + self.push_update_to_clients( + observer_user=u, + observed_user=srcuser, + statuscache=statuscache, + ) + + deferreds = [] + for domain in remotedomains: + logger.debug(" | push to remote domain %s", domain) + deferreds.append(self._push_presence_remote(srcuser, domain, + state=statuscache.get_state()) + ) + + yield defer.DeferredList(deferreds) + + @defer.inlineCallbacks + def _push_presence_remote(self, user, destination, state=None): + if state is None: + state = yield self.store.get_presence_state(user.localpart) + yield self.distributor.fire( + "collect_presencelike_data", user, state + ) + + yield self.federation.send_edu( + destination=destination, + edu_type="m.presence", + content={ + "push": [ + dict(user_id=user.to_string(), **state), + ], + } + ) + + @defer.inlineCallbacks + def incoming_presence(self, origin, content): + deferreds = [] + + for push in content.get("push", []): + user = self.hs.parse_userid(push["user_id"]) + + logger.debug("Incoming presence update from %s", user) + + observers = set(self._remote_recvmap.get(user, set())) + + rm_handler = self.homeserver.get_handlers().room_member_handler + room_ids = yield rm_handler.get_rooms_for_user(user) + + for room_id in room_ids: + yield rm_handler.fetch_room_distributions_into( + room_id, localusers=observers, ignore_user=user + ) + + if not observers: + break + + state = dict(push) + del state["user_id"] + + statuscache = self._get_or_make_usercache(user) + + self._user_cachemap_latest_serial += 1 + statuscache.update(state, serial=self._user_cachemap_latest_serial) + + for observer_user in observers: + self.push_update_to_clients( + observer_user=observer_user, + observed_user=user, + statuscache=statuscache, + ) + + if state["state"] == PresenceState.OFFLINE: + del self._user_cachemap[user] + + for poll in content.get("poll", []): + user = self.hs.parse_userid(poll) + + if not user.is_mine: + continue + + # TODO(paul) permissions checks + + if not user in self._remote_sendmap: + self._remote_sendmap[user] = set() + + self._remote_sendmap[user].add(origin) + + deferreds.append(self._push_presence_remote(user, origin)) + + for unpoll in content.get("unpoll", []): + user = self.hs.parse_userid(unpoll) + + if not user.is_mine: + continue + + if user in self._remote_sendmap: + self._remote_sendmap[user].remove(origin) + + if not self._remote_sendmap[user]: + del self._remote_sendmap[user] + + yield defer.DeferredList(deferreds) + + def push_update_to_clients(self, observer_user, observed_user, + statuscache): + self.notifier.on_new_user_event( + observer_user.to_string(), + event_data=statuscache.make_event(user=observed_user), + stream_type=PresenceStreamData, + store_id=statuscache.serial + ) + + +class PresenceStreamData(StreamData): + def __init__(self, hs): + super(PresenceStreamData, self).__init__(hs) + self.presence = hs.get_handlers().presence_handler + + def get_rows(self, user_id, from_key, to_key, limit): + cachemap = self.presence._user_cachemap + + # TODO(paul): limit, and filter by visibility + updates = [(k, cachemap[k]) for k in cachemap + if from_key < cachemap[k].serial <= to_key] + + if updates: + latest_serial = max([x[1].serial for x in updates]) + data = [x[1].make_event(user=x[0]) for x in updates] + return ((data, latest_serial)) + else: + return (([], self.presence._user_cachemap_latest_serial)) + + def max_token(self): + return self.presence._user_cachemap_latest_serial + +PresenceStreamData.EVENT_TYPE = PresenceStreamData + + +class UserPresenceCache(object): + """Store an observed user's state and status message. + + Includes the update timestamp. + """ + def __init__(self): + self.state = {} + self.serial = None + + def update(self, state, serial): + self.state.update(state) + # Delete keys that are now 'None' + for k in self.state.keys(): + if self.state[k] is None: + del self.state[k] + + self.serial = serial + + if "status_msg" in state: + self.status_msg = state["status_msg"] + else: + self.status_msg = None + + def get_state(self): + # clone it so caller can't break our cache + return dict(self.state) + + def make_event(self, user): + content = self.get_state() + content["user_id"] = user.to_string() + + return {"type": "m.presence", "content": content} diff --git a/synapse/handlers/profile.py b/synapse/handlers/profile.py new file mode 100644 index 0000000000..a27206b002 --- /dev/null +++ b/synapse/handlers/profile.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from synapse.api.errors import SynapseError, AuthError + +from synapse.api.errors import CodeMessageException + +from ._base import BaseHandler + +import logging + + +logger = logging.getLogger(__name__) + +PREFIX = "/matrix/client/api/v1" + + +class ProfileHandler(BaseHandler): + + def __init__(self, hs): + super(ProfileHandler, self).__init__(hs) + + self.client = hs.get_http_client() + + distributor = hs.get_distributor() + self.distributor = distributor + + distributor.observe("registered_user", self.registered_user) + + distributor.observe( + "collect_presencelike_data", self.collect_presencelike_data + ) + + def registered_user(self, user): + self.store.create_profile(user.localpart) + + @defer.inlineCallbacks + def get_displayname(self, target_user, local_only=False): + if target_user.is_mine: + displayname = yield self.store.get_profile_displayname( + target_user.localpart + ) + + defer.returnValue(displayname) + elif not local_only: + # TODO(paul): This should use the server-server API to ask another + # HS. For now we'll just have it use the http client to talk to the + # other HS's REST client API + path = PREFIX + "/profile/%s/displayname?local_only=1" % ( + target_user.to_string() + ) + + try: + result = yield self.client.get_json( + destination=target_user.domain, + path=path + ) + except CodeMessageException as e: + if e.code != 404: + logger.exception("Failed to get displayname") + + raise + except: + logger.exception("Failed to get displayname") + + defer.returnValue(result["displayname"]) + else: + raise SynapseError(400, "User is not hosted on this Home Server") + + @defer.inlineCallbacks + def set_displayname(self, target_user, auth_user, new_displayname): + """target_user is the user whose displayname is to be changed; + auth_user is the user attempting to make this change.""" + if not target_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + if target_user != auth_user: + raise AuthError(400, "Cannot set another user's displayname") + + yield self.store.set_profile_displayname( + target_user.localpart, new_displayname + ) + + yield self.distributor.fire( + "changed_presencelike_data", target_user, { + "displayname": new_displayname, + } + ) + + @defer.inlineCallbacks + def get_avatar_url(self, target_user, local_only=False): + if target_user.is_mine: + avatar_url = yield self.store.get_profile_avatar_url( + target_user.localpart + ) + + defer.returnValue(avatar_url) + elif not local_only: + # TODO(paul): This should use the server-server API to ask another + # HS. For now we'll just have it use the http client to talk to the + # other HS's REST client API + destination = target_user.domain + path = PREFIX + "/profile/%s/avatar_url?local_only=1" % ( + target_user.to_string(), + ) + + try: + result = yield self.client.get_json( + destination=destination, + path=path + ) + except CodeMessageException as e: + if e.code != 404: + logger.exception("Failed to get avatar_url") + raise + except: + logger.exception("Failed to get avatar_url") + + defer.returnValue(result["avatar_url"]) + else: + raise SynapseError(400, "User is not hosted on this Home Server") + + @defer.inlineCallbacks + def set_avatar_url(self, target_user, auth_user, new_avatar_url): + """target_user is the user whose avatar_url is to be changed; + auth_user is the user attempting to make this change.""" + if not target_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + if target_user != auth_user: + raise AuthError(400, "Cannot set another user's avatar_url") + + yield self.store.set_profile_avatar_url( + target_user.localpart, new_avatar_url + ) + + yield self.distributor.fire( + "changed_presencelike_data", target_user, { + "avatar_url": new_avatar_url, + } + ) + + @defer.inlineCallbacks + def collect_presencelike_data(self, user, state): + if not user.is_mine: + defer.returnValue(None) + + (displayname, avatar_url) = yield defer.gatherResults([ + self.store.get_profile_displayname(user.localpart), + self.store.get_profile_avatar_url(user.localpart), + ]) + + state["displayname"] = displayname + state["avatar_url"] = avatar_url + + defer.returnValue(None) diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py new file mode 100644 index 0000000000..246c1f6530 --- /dev/null +++ b/synapse/handlers/register.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Contains functions for registering clients.""" +from twisted.internet import defer + +from synapse.types import UserID +from synapse.api.errors import SynapseError, RegistrationError +from ._base import BaseHandler +import synapse.util.stringutils as stringutils + +import base64 +import bcrypt + + +class RegistrationHandler(BaseHandler): + + def __init__(self, hs): + super(RegistrationHandler, self).__init__(hs) + + self.distributor = hs.get_distributor() + self.distributor.declare("registered_user") + + @defer.inlineCallbacks + def register(self, localpart=None, password=None): + """Registers a new client on the server. + + Args: + localpart : The local part of the user ID to register. If None, + one will be randomly generated. + password (str) : The password to assign to this user so they can + login again. + Returns: + A tuple of (user_id, access_token). + Raises: + RegistrationError if there was a problem registering. + """ + password_hash = None + if password: + password_hash = bcrypt.hashpw(password, bcrypt.gensalt()) + + if localpart: + user = UserID(localpart, self.hs.hostname, True) + user_id = user.to_string() + + token = self._generate_token(user_id) + yield self.store.register(user_id=user_id, + token=token, + password_hash=password_hash) + + self.distributor.fire("registered_user", user) + defer.returnValue((user_id, token)) + else: + # autogen a random user ID + attempts = 0 + user_id = None + token = None + while not user_id and not token: + try: + localpart = self._generate_user_id() + user = UserID(localpart, self.hs.hostname, True) + user_id = user.to_string() + + token = self._generate_token(user_id) + yield self.store.register( + user_id=user_id, + token=token, + password_hash=password_hash) + + self.distributor.fire("registered_user", user) + defer.returnValue((user_id, token)) + except SynapseError: + # if user id is taken, just generate another + user_id = None + token = None + attempts += 1 + if attempts > 5: + raise RegistrationError( + 500, "Cannot generate user ID.") + + def _generate_token(self, user_id): + # urlsafe variant uses _ and - so use . as the separator and replace + # all =s with .s so http clients don't quote =s when it is used as + # query params. + return (base64.urlsafe_b64encode(user_id).replace('=', '.') + '.' + + stringutils.random_string(18)) + + def _generate_user_id(self): + return "-" + stringutils.random_string(18) diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py new file mode 100644 index 0000000000..4d82b33993 --- /dev/null +++ b/synapse/handlers/room.py @@ -0,0 +1,808 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Contains functions for performing events on rooms.""" +from twisted.internet import defer + +from synapse.types import UserID, RoomAlias, RoomID +from synapse.api.constants import Membership +from synapse.api.errors import RoomError, StoreError, SynapseError +from synapse.api.events.room import ( + RoomTopicEvent, MessageEvent, InviteJoinEvent, RoomMemberEvent, + RoomConfigEvent +) +from synapse.api.streams.event import EventStream, MessagesStreamData +from synapse.util import stringutils +from ._base import BaseHandler + +import logging +import json + +logger = logging.getLogger(__name__) + + +class MessageHandler(BaseHandler): + + def __init__(self, hs): + super(MessageHandler, self).__init__(hs) + self.hs = hs + self.clock = hs.get_clock() + self.event_factory = hs.get_event_factory() + + @defer.inlineCallbacks + def get_message(self, msg_id=None, room_id=None, sender_id=None, + user_id=None): + """ Retrieve a message. + + Args: + msg_id (str): The message ID to obtain. + room_id (str): The room where the message resides. + sender_id (str): The user ID of the user who sent the message. + user_id (str): The user ID of the user making this request. + Returns: + The message, or None if no message exists. + Raises: + SynapseError if something went wrong. + """ + yield self.auth.check_joined_room(room_id, user_id) + + # Pull out the message from the db + msg = yield self.store.get_message(room_id=room_id, + msg_id=msg_id, + user_id=sender_id) + + if msg: + defer.returnValue(msg) + defer.returnValue(None) + + @defer.inlineCallbacks + def send_message(self, event=None, suppress_auth=False, stamp_event=True): + """ Send a message. + + Args: + event : The message event to store. + suppress_auth (bool) : True to suppress auth for this message. This + is primarily so the home server can inject messages into rooms at + will. + stamp_event (bool) : True to stamp event content with server keys. + Raises: + SynapseError if something went wrong. + """ + if stamp_event: + event.content["hsob_ts"] = int(self.clock.time_msec()) + + with (yield self.room_lock.lock(event.room_id)): + if not suppress_auth: + yield self.auth.check(event, raises=True) + + # store message in db + store_id = yield self.store.persist_event(event) + + event.destinations = yield self.store.get_joined_hosts_for_room( + event.room_id + ) + + yield self.hs.get_federation().handle_new_event(event) + + self.notifier.on_new_room_event(event, store_id) + + @defer.inlineCallbacks + def get_messages(self, user_id=None, room_id=None, pagin_config=None, + feedback=False): + """Get messages in a room. + + Args: + user_id (str): The user requesting messages. + room_id (str): The room they want messages from. + pagin_config (synapse.api.streams.PaginationConfig): The pagination + config rules to apply, if any. + feedback (bool): True to get compressed feedback with the messages + Returns: + dict: Pagination API results + """ + yield self.auth.check_joined_room(room_id, user_id) + + data_source = [MessagesStreamData(self.hs, room_id=room_id, + feedback=feedback)] + event_stream = EventStream(user_id, data_source) + pagin_config = yield event_stream.fix_tokens(pagin_config) + data_chunk = yield event_stream.get_chunk(config=pagin_config) + defer.returnValue(data_chunk) + + @defer.inlineCallbacks + def store_room_data(self, event=None, stamp_event=True): + """ Stores data for a room. + + Args: + event : The room path event + stamp_event (bool) : True to stamp event content with server keys. + Raises: + SynapseError if something went wrong. + """ + + with (yield self.room_lock.lock(event.room_id)): + yield self.auth.check(event, raises=True) + + if stamp_event: + event.content["hsob_ts"] = int(self.clock.time_msec()) + + yield self.state_handler.handle_new_event(event) + + # store in db + store_id = yield self.store.store_room_data( + room_id=event.room_id, + etype=event.type, + state_key=event.state_key, + content=json.dumps(event.content) + ) + + event.destinations = yield self.store.get_joined_hosts_for_room( + event.room_id + ) + self.notifier.on_new_room_event(event, store_id) + + yield self.hs.get_federation().handle_new_event(event) + + @defer.inlineCallbacks + def get_room_data(self, user_id=None, room_id=None, + event_type=None, state_key="", + public_room_rules=[], + private_room_rules=["join"]): + """ Get data from a room. + + Args: + event : The room path event + public_room_rules : A list of membership states the user can be in, + in order to read this data IN A PUBLIC ROOM. An empty list means + 'any state'. + private_room_rules : A list of membership states the user can be + in, in order to read this data IN A PRIVATE ROOM. An empty list + means 'any state'. + Returns: + The path data content. + Raises: + SynapseError if something went wrong. + """ + if event_type == RoomTopicEvent.TYPE: + # anyone invited/joined can read the topic + private_room_rules = ["invite", "join"] + + # does this room exist + room = yield self.store.get_room(room_id) + if not room: + raise RoomError(403, "Room does not exist.") + + # does this user exist in this room + member = yield self.store.get_room_member( + room_id=room_id, + user_id="" if not user_id else user_id) + + member_state = member.membership if member else None + + if room.is_public and public_room_rules: + # make sure the user meets public room rules + if member_state not in public_room_rules: + raise RoomError(403, "Member does not meet public room rules.") + elif not room.is_public and private_room_rules: + # make sure the user meets private room rules + if member_state not in private_room_rules: + raise RoomError( + 403, "Member does not meet private room rules.") + + data = yield self.store.get_room_data(room_id, event_type, state_key) + defer.returnValue(data) + + @defer.inlineCallbacks + def get_feedback(self, room_id=None, msg_sender_id=None, msg_id=None, + user_id=None, fb_sender_id=None, fb_type=None): + yield self.auth.check_joined_room(room_id, user_id) + + # Pull out the feedback from the db + fb = yield self.store.get_feedback( + room_id=room_id, msg_id=msg_id, msg_sender_id=msg_sender_id, + fb_sender_id=fb_sender_id, fb_type=fb_type + ) + + if fb: + defer.returnValue(fb) + defer.returnValue(None) + + @defer.inlineCallbacks + def send_feedback(self, event, stamp_event=True): + if stamp_event: + event.content["hsob_ts"] = int(self.clock.time_msec()) + + with (yield self.room_lock.lock(event.room_id)): + yield self.auth.check(event, raises=True) + + # store message in db + store_id = yield self.store.persist_event(event) + + event.destinations = yield self.store.get_joined_hosts_for_room( + event.room_id + ) + yield self.hs.get_federation().handle_new_event(event) + + self.notifier.on_new_room_event(event, store_id) + + @defer.inlineCallbacks + def snapshot_all_rooms(self, user_id=None, pagin_config=None, + feedback=False): + """Retrieve a snapshot of all rooms the user is invited or has joined. + + This snapshot may include messages for all rooms where the user is + joined, depending on the pagination config. + + Args: + user_id (str): The ID of the user making the request. + pagin_config (synapse.api.streams.PaginationConfig): The pagination + config used to determine how many messages *PER ROOM* to return. + feedback (bool): True to get feedback along with these messages. + Returns: + A list of dicts with "room_id" and "membership" keys for all rooms + the user is currently invited or joined in on. Rooms where the user + is joined on, may return a "messages" key with messages, depending + on the specified PaginationConfig. + """ + room_list = yield self.store.get_rooms_for_user_where_membership_is( + user_id=user_id, + membership_list=[Membership.INVITE, Membership.JOIN] + ) + for room_info in room_list: + if room_info["membership"] != Membership.JOIN: + continue + try: + event_chunk = yield self.get_messages( + user_id=user_id, + pagin_config=pagin_config, + feedback=feedback, + room_id=room_info["room_id"] + ) + room_info["messages"] = event_chunk + except: + pass + defer.returnValue(room_list) + + +class RoomCreationHandler(BaseHandler): + + @defer.inlineCallbacks + def create_room(self, user_id, room_id, config): + """ Creates a new room. + + Args: + user_id (str): The ID of the user creating the new room. + room_id (str): The proposed ID for the new room. Can be None, in + which case one will be created for you. + config (dict) : A dict of configuration options. + Returns: + The new room ID. + Raises: + SynapseError if the room ID was taken, couldn't be stored, or + something went horribly wrong. + """ + + if "room_alias_name" in config: + room_alias = RoomAlias.create_local( + config["room_alias_name"], + self.hs + ) + mapping = yield self.store.get_association_from_room_alias( + room_alias + ) + + if mapping: + raise SynapseError(400, "Room alias already taken") + else: + room_alias = None + + if room_id: + # Ensure room_id is the correct type + room_id_obj = RoomID.from_string(room_id, self.hs) + if not room_id_obj.is_mine: + raise SynapseError(400, "Room id must be local") + + yield self.store.store_room( + room_id=room_id, + room_creator_user_id=user_id, + is_public=config["visibility"] == "public" + ) + else: + # autogen room IDs and try to create it. We may clash, so just + # try a few times till one goes through, giving up eventually. + attempts = 0 + room_id = None + while attempts < 5: + try: + random_string = stringutils.random_string(18) + gen_room_id = RoomID.create_local(random_string, self.hs) + yield self.store.store_room( + room_id=gen_room_id.to_string(), + room_creator_user_id=user_id, + is_public=config["visibility"] == "public" + ) + room_id = gen_room_id.to_string() + break + except StoreError: + attempts += 1 + if not room_id: + raise StoreError(500, "Couldn't generate a room ID.") + + config_event = self.event_factory.create_event( + etype=RoomConfigEvent.TYPE, + room_id=room_id, + user_id=user_id, + content=config, + ) + + if room_alias: + yield self.store.create_room_alias_association( + room_id=room_id, + room_alias=room_alias, + servers=[self.hs.hostname], + ) + + yield self.state_handler.handle_new_event(config_event) + # store_id = persist... + + yield self.hs.get_federation().handle_new_event(config_event) + # self.notifier.on_new_room_event(event, store_id) + + content = {"membership": Membership.JOIN} + join_event = self.event_factory.create_event( + etype=RoomMemberEvent.TYPE, + target_user_id=user_id, + room_id=room_id, + user_id=user_id, + membership=Membership.JOIN, + content=content + ) + + yield self.hs.get_handlers().room_member_handler.change_membership( + join_event, + broadcast_msg=True, + do_auth=False + ) + + result = {"room_id": room_id} + if room_alias: + result["room_alias"] = room_alias.to_string() + + defer.returnValue(result) + + +class RoomMemberHandler(BaseHandler): + # TODO(paul): This handler currently contains a messy conflation of + # low-level API that works on UserID objects and so on, and REST-level + # API that takes ID strings and returns pagination chunks. These concerns + # ought to be separated out a lot better. + + def __init__(self, hs): + super(RoomMemberHandler, self).__init__(hs) + + self.clock = hs.get_clock() + + self.distributor = hs.get_distributor() + self.distributor.declare("user_joined_room") + + @defer.inlineCallbacks + def get_room_members(self, room_id, membership=Membership.JOIN): + hs = self.hs + + memberships = yield self.store.get_room_members( + room_id=room_id, membership=membership + ) + + defer.returnValue([hs.parse_userid(m.user_id) for m in memberships]) + + @defer.inlineCallbacks + def fetch_room_distributions_into(self, room_id, localusers=None, + remotedomains=None, ignore_user=None): + """Fetch the distribution of a room, adding elements to either + 'localusers' or 'remotedomains', which should be a set() if supplied. + If ignore_user is set, ignore that user. + + This function returns nothing; its result is performed by the + side-effect on the two passed sets. This allows easy accumulation of + member lists of multiple rooms at once if required. + """ + members = yield self.get_room_members(room_id) + for member in members: + if ignore_user is not None and member == ignore_user: + continue + + if member.is_mine: + if localusers is not None: + localusers.add(member) + else: + if remotedomains is not None: + remotedomains.add(member.domain) + + @defer.inlineCallbacks + def get_room_members_as_pagination_chunk(self, room_id=None, user_id=None, + limit=0, start_tok=None, + end_tok=None): + """Retrieve a list of room members in the room. + + Args: + room_id (str): The room to get the member list for. + user_id (str): The ID of the user making the request. + limit (int): The max number of members to return. + start_tok (str): Optional. The start token if known. + end_tok (str): Optional. The end token if known. + Returns: + dict: A Pagination streamable dict. + Raises: + SynapseError if something goes wrong. + """ + yield self.auth.check_joined_room(room_id, user_id) + + member_list = yield self.store.get_room_members(room_id=room_id) + event_list = [ + entry.as_event(self.event_factory).get_dict() + for entry in member_list + ] + chunk_data = { + "start": "START", + "end": "END", + "chunk": event_list + } + # TODO honor Pagination stream params + # TODO snapshot this list to return on subsequent requests when + # paginating + defer.returnValue(chunk_data) + + @defer.inlineCallbacks + def get_room_member(self, room_id, member_user_id, auth_user_id): + """Retrieve a room member from a room. + + Args: + room_id : The room the member is in. + member_user_id : The member's user ID + auth_user_id : The user ID of the user making this request. + Returns: + The room member, or None if this member does not exist. + Raises: + SynapseError if something goes wrong. + """ + yield self.auth.check_joined_room(room_id, auth_user_id) + + member = yield self.store.get_room_member(user_id=member_user_id, + room_id=room_id) + defer.returnValue(member) + + @defer.inlineCallbacks + def change_membership(self, event=None, broadcast_msg=False, do_auth=True): + """ Change the membership status of a user in a room. + + Args: + event (SynapseEvent): The membership event + broadcast_msg (bool): True to inject a membership message into this + room on success. + Raises: + SynapseError if there was a problem changing the membership. + """ + + #broadcast_msg = False + + prev_state = yield self.store.get_room_member( + event.target_user_id, event.room_id + ) + + if prev_state and prev_state.membership == event.membership: + # treat this event as a NOOP. + if do_auth: # This is mainly to fix a unit test. + yield self.auth.check(event, raises=True) + defer.returnValue({}) + return + + room_id = event.room_id + + # If we're trying to join a room then we have to do this differently + # if this HS is not currently in the room, i.e. we have to do the + # invite/join dance. + if event.membership == Membership.JOIN: + yield self._do_join( + event, do_auth=do_auth, broadcast_msg=broadcast_msg + ) + else: + # This is not a JOIN, so we can handle it normally. + if do_auth: + yield self.auth.check(event, raises=True) + + prev_state = yield self.store.get_room_member( + event.target_user_id, event.room_id + ) + if prev_state and prev_state.membership == event.membership: + # double same action, treat this event as a NOOP. + defer.returnValue({}) + return + + yield self.state_handler.handle_new_event(event) + yield self._do_local_membership_update( + event, + membership=event.content["membership"], + broadcast_msg=broadcast_msg, + ) + + defer.returnValue({"room_id": room_id}) + + @defer.inlineCallbacks + def join_room_alias(self, joinee, room_alias, do_auth=True, content={}): + directory_handler = self.hs.get_handlers().directory_handler + mapping = yield directory_handler.get_association(room_alias) + + if not mapping: + raise SynapseError(404, "No such room alias") + + room_id = mapping["room_id"] + hosts = mapping["servers"] + if not hosts: + raise SynapseError(404, "No known servers") + + host = hosts[0] + + content.update({"membership": Membership.JOIN}) + new_event = self.event_factory.create_event( + etype=RoomMemberEvent.TYPE, + target_user_id=joinee.to_string(), + room_id=room_id, + user_id=joinee.to_string(), + membership=Membership.JOIN, + content=content, + ) + + yield self._do_join(new_event, room_host=host, do_auth=True) + + defer.returnValue({"room_id": room_id}) + + @defer.inlineCallbacks + def _do_join(self, event, room_host=None, do_auth=True, broadcast_msg=True): + joinee = self.hs.parse_userid(event.target_user_id) + # room_id = RoomID.from_string(event.room_id, self.hs) + room_id = event.room_id + + # If event doesn't include a display name, add one. + yield self._fill_out_join_content( + joinee, event.content + ) + + # XXX: We don't do an auth check if we are doing an invite + # join dance for now, since we're kinda implicitly checking + # that we are allowed to join when we decide whether or not we + # need to do the invite/join dance. + + room = yield self.store.get_room(room_id) + + if room: + should_do_dance = False + elif room_host: + should_do_dance = True + else: + prev_state = yield self.store.get_room_member( + joinee.to_string(), room_id + ) + + if prev_state and prev_state.membership == Membership.INVITE: + room = yield self.store.get_room(room_id) + inviter = UserID.from_string( + prev_state.sender, self.hs + ) + + should_do_dance = not inviter.is_mine and not room + room_host = inviter.domain + else: + should_do_dance = False + + # We want to do the _do_update inside the room lock. + if not should_do_dance: + logger.debug("Doing normal join") + + if do_auth: + yield self.auth.check(event, raises=True) + + yield self.state_handler.handle_new_event(event) + yield self._do_local_membership_update( + event, + membership=event.content["membership"], + broadcast_msg=broadcast_msg, + ) + + + if should_do_dance: + yield self._do_invite_join_dance( + room_id=room_id, + joinee=event.user_id, + target_host=room_host, + content=event.content, + ) + + user = self.hs.parse_userid(event.user_id) + self.distributor.fire( + "user_joined_room", user=user, room_id=room_id + ) + + @defer.inlineCallbacks + def _fill_out_join_content(self, user_id, content): + # If event doesn't include a display name, add one. + profile_handler = self.hs.get_handlers().profile_handler + if "displayname" not in content: + try: + display_name = yield profile_handler.get_displayname( + user_id + ) + + if display_name: + content["displayname"] = display_name + except: + logger.exception("Failed to set display_name") + + if "avatar_url" not in content: + try: + avatar_url = yield profile_handler.get_avatar_url( + user_id + ) + + if avatar_url: + content["avatar_url"] = avatar_url + except: + logger.exception("Failed to set display_name") + + @defer.inlineCallbacks + def _should_invite_join(self, room_id, prev_state, do_auth): + logger.debug("_should_invite_join: room_id: %s", room_id) + + # XXX: We don't do an auth check if we are doing an invite + # join dance for now, since we're kinda implicitly checking + # that we are allowed to join when we decide whether or not we + # need to do the invite/join dance. + + # Only do an invite join dance if a) we were invited, + # b) the person inviting was from a differnt HS and c) we are + # not currently in the room + room_host = None + if prev_state and prev_state.membership == Membership.INVITE: + room = yield self.store.get_room(room_id) + inviter = UserID.from_string( + prev_state.sender, self.hs + ) + + is_remote_invite_join = not inviter.is_mine and not room + room_host = inviter.domain + else: + is_remote_invite_join = False + + defer.returnValue((is_remote_invite_join, room_host)) + + @defer.inlineCallbacks + def get_rooms_for_user(self, user, membership_list=[Membership.JOIN]): + """Returns a list of roomids that the user has any of the given + membership states in.""" + rooms = yield self.store.get_rooms_for_user_where_membership_is( + user_id=user.to_string(), membership_list=membership_list + ) + + defer.returnValue([r["room_id"] for r in rooms]) + + @defer.inlineCallbacks + def _do_local_membership_update(self, event, membership, broadcast_msg): + # store membership + store_id = yield self.store.store_room_member( + user_id=event.target_user_id, + sender=event.user_id, + room_id=event.room_id, + content=event.content, + membership=membership + ) + + # Send a PDU to all hosts who have joined the room. + destinations = yield self.store.get_joined_hosts_for_room( + event.room_id + ) + + # If we're inviting someone, then we should also send it to that + # HS. + if membership == Membership.INVITE: + host = UserID.from_string( + event.target_user_id, self.hs + ).domain + destinations.append(host) + + # If we are joining a remote HS, include that. + if membership == Membership.JOIN: + host = UserID.from_string( + event.target_user_id, self.hs + ).domain + destinations.append(host) + + event.destinations = list(set(destinations)) + + yield self.hs.get_federation().handle_new_event(event) + self.notifier.on_new_room_event(event, store_id) + + if broadcast_msg: + yield self._inject_membership_msg( + source=event.user_id, + target=event.target_user_id, + room_id=event.room_id, + membership=event.content["membership"] + ) + + @defer.inlineCallbacks + def _do_invite_join_dance(self, room_id, joinee, target_host, content): + logger.debug("Doing remote join dance") + + # do invite join dance + federation = self.hs.get_federation() + new_event = self.event_factory.create_event( + etype=InviteJoinEvent.TYPE, + target_host=target_host, + room_id=room_id, + user_id=joinee, + content=content + ) + + new_event.destinations = [target_host] + + yield self.store.store_room( + room_id, "", is_public=False + ) + + #yield self.state_handler.handle_new_event(event) + yield federation.handle_new_event(new_event) + yield federation.get_state_for_room( + target_host, room_id + ) + + @defer.inlineCallbacks + def _inject_membership_msg(self, room_id=None, source=None, target=None, + membership=None): + # TODO this should be a different type of message, not m.text + if membership == Membership.INVITE: + body = "%s invited %s to the room." % (source, target) + elif membership == Membership.JOIN: + body = "%s joined the room." % (target) + elif membership == Membership.LEAVE: + body = "%s left the room." % (target) + else: + raise RoomError(500, "Unknown membership value %s" % membership) + + membership_json = { + "msgtype": u"m.text", + "body": body, + "membership_source": source, + "membership_target": target, + "membership": membership, + } + + msg_id = "m%s" % int(self.clock.time_msec()) + + event = self.event_factory.create_event( + etype=MessageEvent.TYPE, + room_id=room_id, + user_id="_homeserver_", + msg_id=msg_id, + content=membership_json + ) + + handler = self.hs.get_handlers().message_handler + yield handler.send_message(event, suppress_auth=True) + + +class RoomListHandler(BaseHandler): + + @defer.inlineCallbacks + def get_public_room_list(self): + chunk = yield self.store.get_rooms(is_public=True, with_topics=True) + defer.returnValue({"start": "START", "end": "END", "chunk": chunk}) diff --git a/synapse/http/__init__.py b/synapse/http/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/http/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/http/client.py b/synapse/http/client.py new file mode 100644 index 0000000000..bb22b0ee9a --- /dev/null +++ b/synapse/http/client.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer, reactor +from twisted.web.client import _AgentBase, _URI, readBody +from twisted.web.http_headers import Headers + +from synapse.http.endpoint import matrix_endpoint +from synapse.util.async import sleep + +from syutil.jsonutil import encode_canonical_json + +from synapse.api.errors import CodeMessageException + +import json +import logging +import urllib + + +logger = logging.getLogger(__name__) + + +_destination_mappings = { + "red": "localhost:8080", + "blue": "localhost:8081", + "green": "localhost:8082", +} + + +class HttpClient(object): + """ Interface for talking json over http + """ + + def put_json(self, destination, path, data): + """ Sends the specifed json data using PUT + + Args: + destination (str): The remote server to send the HTTP request + to. + path (str): The HTTP path. + data (dict): A dict containing the data that will be used as + the request body. This will be encoded as JSON. + + Returns: + Deferred: Succeeds when we get *any* HTTP response. + + The result of the deferred is a tuple of `(code, response)`, + where `response` is a dict representing the decoded JSON body. + """ + pass + + def get_json(self, destination, path, args=None): + """ Get's some json from the given host homeserver and path + + Args: + destination (str): The remote server to send the HTTP request + to. + path (str): The HTTP path. + args (dict): A dictionary used to create query strings, defaults to + None. + **Note**: The value of each key is assumed to be an iterable + and *not* a string. + + Returns: + Deferred: Succeeds when we get *any* HTTP response. + + The result of the deferred is a tuple of `(code, response)`, + where `response` is a dict representing the decoded JSON body. + """ + pass + + +class MatrixHttpAgent(_AgentBase): + + def __init__(self, reactor, pool=None): + _AgentBase.__init__(self, reactor, pool) + + def request(self, destination, endpoint, method, path, params, query, + headers, body_producer): + + host = b"" + port = 0 + fragment = b"" + + parsed_URI = _URI(b"http", destination, host, port, path, params, + query, fragment) + + # Set the connection pool key to be the destination. + key = destination + + return self._requestWithEndpoint(key, endpoint, method, parsed_URI, + headers, body_producer, + parsed_URI.originForm) + + +class TwistedHttpClient(HttpClient): + """ Wrapper around the twisted HTTP client api. + + Attributes: + agent (twisted.web.client.Agent): The twisted Agent used to send the + requests. + """ + + def __init__(self): + self.agent = MatrixHttpAgent(reactor) + + @defer.inlineCallbacks + def put_json(self, destination, path, data): + if destination in _destination_mappings: + destination = _destination_mappings[destination] + + response = yield self._create_request( + destination.encode("ascii"), + "PUT", + path.encode("ascii"), + producer=_JsonProducer(data), + headers_dict={"Content-Type": ["application/json"]} + ) + + logger.debug("Getting resp body") + body = yield readBody(response) + logger.debug("Got resp body") + + defer.returnValue((response.code, body)) + + @defer.inlineCallbacks + def get_json(self, destination, path, args={}): + if destination in _destination_mappings: + destination = _destination_mappings[destination] + + logger.debug("get_json args: %s", args) + query_bytes = urllib.urlencode(args, True) + + response = yield self._create_request( + destination.encode("ascii"), + "GET", + path.encode("ascii"), + query_bytes + ) + + body = yield readBody(response) + + defer.returnValue(json.loads(body)) + + @defer.inlineCallbacks + def _create_request(self, destination, method, path_bytes, param_bytes=b"", + query_bytes=b"", producer=None, headers_dict={}): + """ Creates and sends a request to the given url + """ + headers_dict[b"User-Agent"] = [b"Synapse"] + headers_dict[b"Host"] = [destination] + + logger.debug("Sending request to %s: %s %s;%s?%s", + destination, method, path_bytes, param_bytes, query_bytes) + + logger.debug( + "Types: %s", + [ + type(destination), type(method), type(path_bytes), + type(param_bytes), + type(query_bytes) + ] + ) + + retries_left = 5 + + # TODO: setup and pass in an ssl_context to enable TLS + endpoint = matrix_endpoint(reactor, destination, timeout=10) + + while True: + try: + response = yield self.agent.request( + destination, + endpoint, + method, + path_bytes, + param_bytes, + query_bytes, + Headers(headers_dict), + producer + ) + + logger.debug("Got response to %s", method) + break + except Exception as e: + logger.exception("Got error in _create_request") + _print_ex(e) + + if retries_left: + yield sleep(2 ** (5 - retries_left)) + retries_left -= 1 + else: + raise + + if 200 <= response.code < 300: + # We need to update the transactions table to say it was sent? + pass + else: + # :'( + # Update transactions table? + logger.error( + "Got response %d %s", response.code, response.phrase + ) + raise CodeMessageException( + response.code, response.phrase + ) + + defer.returnValue(response) + + +def _print_ex(e): + if hasattr(e, "reasons") and e.reasons: + for ex in e.reasons: + _print_ex(ex) + else: + logger.exception(e) + + +class _JsonProducer(object): + """ Used by the twisted http client to create the HTTP body from json + """ + def __init__(self, jsn): + self.body = encode_canonical_json(jsn) + self.length = len(self.body) + + def startProducing(self, consumer): + consumer.write(self.body) + return defer.succeed(None) + + def pauseProducing(self): + pass + + def stopProducing(self): + pass diff --git a/synapse/http/endpoint.py b/synapse/http/endpoint.py new file mode 100644 index 0000000000..c4e6e63a80 --- /dev/null +++ b/synapse/http/endpoint.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet.endpoints import SSL4ClientEndpoint, TCP4ClientEndpoint +from twisted.internet import defer +from twisted.internet.error import ConnectError +from twisted.names import client, dns +from twisted.names.error import DNSNameError + +import collections +import logging +import random + + +logger = logging.getLogger(__name__) + + +def matrix_endpoint(reactor, destination, ssl_context_factory=None, + timeout=None): + """Construct an endpoint for the given matrix destination. + + Args: + reactor: Twisted reactor. + destination (bytes): The name of the server to connect to. + ssl_context_factory (twisted.internet.ssl.ContextFactory): Factory + which generates SSL contexts to use for TLS. + timeout (int): connection timeout in seconds + """ + + domain_port = destination.split(":") + domain = domain_port[0] + port = int(domain_port[1]) if domain_port[1:] else None + + endpoint_kw_args = {} + + if timeout is not None: + endpoint_kw_args.update(timeout=timeout) + + if ssl_context_factory is None: + transport_endpoint = TCP4ClientEndpoint + default_port = 8080 + else: + transport_endpoint = SSL4ClientEndpoint + endpoint_kw_args.update(ssl_context_factory=ssl_context_factory) + default_port = 443 + + if port is None: + return SRVClientEndpoint( + reactor, "matrix", domain, protocol="tcp", + default_port=default_port, endpoint=transport_endpoint, + endpoint_kw_args=endpoint_kw_args + ) + else: + return transport_endpoint(reactor, domain, port, **endpoint_kw_args) + + +class SRVClientEndpoint(object): + """An endpoint which looks up SRV records for a service. + Cycles through the list of servers starting with each call to connect + picking the next server. + Implements twisted.internet.interfaces.IStreamClientEndpoint. + """ + + _Server = collections.namedtuple( + "_Server", "priority weight host port" + ) + + def __init__(self, reactor, service, domain, protocol="tcp", + default_port=None, endpoint=TCP4ClientEndpoint, + endpoint_kw_args={}): + self.reactor = reactor + self.service_name = "_%s._%s.%s" % (service, protocol, domain) + + if default_port is not None: + self.default_server = self._Server( + host=domain, + port=default_port, + priority=0, + weight=0 + ) + else: + self.default_server = None + + self.endpoint = endpoint + self.endpoint_kw_args = endpoint_kw_args + + self.servers = None + self.used_servers = None + + @defer.inlineCallbacks + def fetch_servers(self): + try: + answers, auth, add = yield client.lookupService(self.service_name) + except DNSNameError: + answers = [] + + if (len(answers) == 1 + and answers[0].type == dns.SRV + and answers[0].payload + and answers[0].payload.target == dns.Name('.')): + raise ConnectError("Service %s unavailable", self.service_name) + + self.servers = [] + self.used_servers = [] + + for answer in answers: + if answer.type != dns.SRV or not answer.payload: + continue + payload = answer.payload + self.servers.append(self._Server( + host=str(payload.target), + port=int(payload.port), + priority=int(payload.priority), + weight=int(payload.weight) + )) + + self.servers.sort() + + def pick_server(self): + if not self.servers: + if self.used_servers: + self.servers = self.used_servers + self.used_servers = [] + self.servers.sort() + elif self.default_server: + return self.default_server + else: + raise ConnectError( + "Not server available for %s", self.service_name + ) + + min_priority = self.servers[0].priority + weight_indexes = list( + (index, server.weight + 1) + for index, server in enumerate(self.servers) + if server.priority == min_priority + ) + + total_weight = sum(weight for index, weight in weight_indexes) + target_weight = random.randint(0, total_weight) + + for index, weight in weight_indexes: + target_weight -= weight + if target_weight <= 0: + server = self.servers[index] + del self.servers[index] + self.used_servers.append(server) + return server + + @defer.inlineCallbacks + def connect(self, protocolFactory): + if self.servers is None: + yield self.fetch_servers() + server = self.pick_server() + logger.info("Connecting to %s:%s", server.host, server.port) + endpoint = self.endpoint( + self.reactor, server.host, server.port, **self.endpoint_kw_args + ) + connection = yield endpoint.connect(protocolFactory) + defer.returnValue(connection) diff --git a/synapse/http/server.py b/synapse/http/server.py new file mode 100644 index 0000000000..8823aade78 --- /dev/null +++ b/synapse/http/server.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from syutil.jsonutil import ( + encode_canonical_json, encode_pretty_printed_json +) +from synapse.api.errors import cs_exception, CodeMessageException + +from twisted.internet import defer, reactor +from twisted.web import server, resource +from twisted.web.server import NOT_DONE_YET + +import collections +import logging + + +logger = logging.getLogger(__name__) + + +class HttpServer(object): + """ Interface for registering callbacks on a HTTP server + """ + + def register_path(self, method, path_pattern, callback): + """ Register a callback that get's fired if we receive a http request + with the given method for a path that matches the given regex. + + If the regex contains groups these get's passed to the calback via + an unpacked tuple. + + Args: + method (str): The method to listen to. + path_pattern (str): The regex used to match requests. + callback (function): The function to fire if we receive a matched + request. The first argument will be the request object and + subsequent arguments will be any matched groups from the regex. + This should return a tuple of (code, response). + """ + pass + + +# The actual HTTP server impl, using twisted http server +class TwistedHttpServer(HttpServer, resource.Resource): + """ This wraps the twisted HTTP server, and triggers the correct callbacks + on the transport_layer. + + Register callbacks via register_path() + """ + + isLeaf = True + + _PathEntry = collections.namedtuple("_PathEntry", ["pattern", "callback"]) + + def __init__(self): + resource.Resource.__init__(self) + + self.path_regexs = {} + + def register_path(self, method, path_pattern, callback): + self.path_regexs.setdefault(method, []).append( + self._PathEntry(path_pattern, callback) + ) + + def start_listening(self, port): + """ Registers the http server with the twisted reactor. + + Args: + port (int): The port to listen on. + + """ + reactor.listenTCP(port, server.Site(self)) + + # Gets called by twisted + def render(self, request): + """ This get's called by twisted every time someone sends us a request. + """ + self._async_render(request) + return server.NOT_DONE_YET + + @defer.inlineCallbacks + def _async_render(self, request): + """ This get's called by twisted every time someone sends us a request. + This checks if anyone has registered a callback for that method and + path. + """ + try: + # Loop through all the registered callbacks to check if the method + # and path regex match + for path_entry in self.path_regexs.get(request.method, []): + m = path_entry.pattern.match(request.path) + if m: + # We found a match! Trigger callback and then return the + # returned response. We pass both the request and any + # matched groups from the regex to the callback. + code, response = yield path_entry.callback( + request, + *m.groups() + ) + + self._send_response(request, code, response) + return + + # Huh. No one wanted to handle that? Fiiiiiine. Send 400. + self._send_response( + request, + 400, + {"error": "Unrecognized request"} + ) + except CodeMessageException as e: + logger.exception(e) + self._send_response( + request, + e.code, + cs_exception(e) + ) + except Exception as e: + logger.exception(e) + self._send_response( + request, + 500, + {"error": "Internal server error"} + ) + + def _send_response(self, request, code, response_json_object): + + if not self._request_user_agent_is_curl(request): + json_bytes = encode_canonical_json(response_json_object) + else: + json_bytes = encode_pretty_printed_json(response_json_object) + + # TODO: Only enable CORS for the requests that need it. + respond_with_json_bytes(request, code, json_bytes, send_cors=True) + + @staticmethod + def _request_user_agent_is_curl(request): + user_agents = request.requestHeaders.getRawHeaders( + "User-Agent", default=[] + ) + for user_agent in user_agents: + if "curl" in user_agent: + return True + return False + + +def respond_with_json_bytes(request, code, json_bytes, send_cors=False): + """Sends encoded JSON in response to the given request. + + Args: + request (twisted.web.http.Request): The http request to respond to. + code (int): The HTTP response code. + json_bytes (bytes): The json bytes to use as the response body. + send_cors (bool): Whether to send Cross-Origin Resource Sharing headers + http://www.w3.org/TR/cors/ + Returns: + twisted.web.server.NOT_DONE_YET""" + + request.setResponseCode(code) + request.setHeader(b"Content-Type", b"application/json") + + if send_cors: + request.setHeader("Access-Control-Allow-Origin", "*") + request.setHeader("Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS") + request.setHeader("Access-Control-Allow-Headers", + "Origin, X-Requested-With, Content-Type, Accept") + + request.write(json_bytes) + request.finish() + return NOT_DONE_YET diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py new file mode 100644 index 0000000000..5598295793 --- /dev/null +++ b/synapse/rest/__init__.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import ( + room, events, register, profile, public, presence, im, directory +) + +class RestServletFactory(object): + + """ A factory for creating REST servlets. + + These REST servlets represent the entire client-server REST API. Generally + speaking, they serve as wrappers around events and the handlers that + process them. + + See synapse.api.events for information on synapse events. + """ + + def __init__(self, hs): + http_server = hs.get_http_server() + + # TODO(erikj): There *must* be a better way of doing this. + room.register_servlets(hs, http_server) + events.register_servlets(hs, http_server) + register.register_servlets(hs, http_server) + profile.register_servlets(hs, http_server) + public.register_servlets(hs, http_server) + presence.register_servlets(hs, http_server) + im.register_servlets(hs, http_server) + directory.register_servlets(hs, http_server) + + diff --git a/synapse/rest/base.py b/synapse/rest/base.py new file mode 100644 index 0000000000..d90ac611fe --- /dev/null +++ b/synapse/rest/base.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" This module contains base REST classes for constructing REST servlets. """ +import re + + +def client_path_pattern(path_regex): + """Creates a regex compiled client path with the correct client path + prefix. + + Args: + path_regex (str): The regex string to match. This should NOT have a ^ + as this will be prefixed. + Returns: + SRE_Pattern + """ + return re.compile("^/matrix/client/api/v1" + path_regex) + + +class RestServletFactory(object): + + """ A factory for creating REST servlets. + + These REST servlets represent the entire client-server REST API. Generally + speaking, they serve as wrappers around events and the handlers that + process them. + + See synapse.api.events for information on synapse events. + """ + + def __init__(self, hs): + http_server = hs.get_http_server() + + # You get import errors if you try to import before the classes in this + # file are defined, hence importing here instead. + + import room + room.register_servlets(hs, http_server) + + import events + events.register_servlets(hs, http_server) + + import register + register.register_servlets(hs, http_server) + + import profile + profile.register_servlets(hs, http_server) + + import public + public.register_servlets(hs, http_server) + + import presence + presence.register_servlets(hs, http_server) + + import im + im.register_servlets(hs, http_server) + + import login + login.register_servlets(hs, http_server) + + +class RestServlet(object): + + """ A Synapse REST Servlet. + + An implementing class can either provide its own custom 'register' method, + or use the automatic pattern handling provided by the base class. + + To use this latter, the implementing class instead provides a `PATTERN` + class attribute containing a pre-compiled regular expression. The automatic + register method will then use this method to register any of the following + instance methods associated with the corresponding HTTP method: + + on_GET + on_PUT + on_POST + on_DELETE + on_OPTIONS + + Automatically handles turning CodeMessageExceptions thrown by these methods + into the appropriate HTTP response. + """ + + def __init__(self, hs): + self.hs = hs + + self.handlers = hs.get_handlers() + self.event_factory = hs.get_event_factory() + self.auth = hs.get_auth() + + def register(self, http_server): + """ Register this servlet with the given HTTP server. """ + if hasattr(self, "PATTERN"): + pattern = self.PATTERN + + for method in ("GET", "PUT", "POST", "OPTIONS", "DELETE"): + if hasattr(self, "on_%s" % (method)): + method_handler = getattr(self, "on_%s" % (method)) + http_server.register_path(method, pattern, method_handler) + else: + raise NotImplementedError("RestServlet must register something.") diff --git a/synapse/rest/directory.py b/synapse/rest/directory.py new file mode 100644 index 0000000000..a426003a38 --- /dev/null +++ b/synapse/rest/directory.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer + +from synapse.types import RoomAlias, RoomID +from base import RestServlet, client_path_pattern + +import json +import logging +import urllib + + +logger = logging.getLogger(__name__) + + +def register_servlets(hs, http_server): + ClientDirectoryServer(hs).register(http_server) + + +class ClientDirectoryServer(RestServlet): + PATTERN = client_path_pattern("/ds/room/(?P[^/]*)$") + + @defer.inlineCallbacks + def on_GET(self, request, room_alias): + # TODO(erikj): Handle request + local_only = "local_only" in request.args + + room_alias = urllib.unquote(room_alias) + room_alias_obj = RoomAlias.from_string(room_alias, self.hs) + + dir_handler = self.handlers.directory_handler + res = yield dir_handler.get_association( + room_alias_obj, + local_only=local_only + ) + + defer.returnValue((200, res)) + + @defer.inlineCallbacks + def on_PUT(self, request, room_alias): + # TODO(erikj): Exceptions + content = json.loads(request.content.read()) + + logger.debug("Got content: %s", content) + + room_alias = urllib.unquote(room_alias) + room_alias_obj = RoomAlias.from_string(room_alias, self.hs) + + logger.debug("Got room name: %s", room_alias_obj.to_string()) + + room_id = content["room_id"] + servers = content["servers"] + + logger.debug("Got room_id: %s", room_id) + logger.debug("Got servers: %s", servers) + + # TODO(erikj): Check types. + # TODO(erikj): Check that room exists + + dir_handler = self.handlers.directory_handler + + try: + yield dir_handler.create_association( + room_alias_obj, room_id, servers + ) + except: + logger.exception("Failed to create association") + + defer.returnValue((200, {})) diff --git a/synapse/rest/events.py b/synapse/rest/events.py new file mode 100644 index 0000000000..147257a940 --- /dev/null +++ b/synapse/rest/events.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module contains REST servlets to do with event streaming, /events.""" +from twisted.internet import defer + +from synapse.api.errors import SynapseError +from synapse.api.streams import PaginationConfig +from synapse.rest.base import RestServlet, client_path_pattern + + +class EventStreamRestServlet(RestServlet): + PATTERN = client_path_pattern("/events$") + + DEFAULT_LONGPOLL_TIME_MS = 5000 + + @defer.inlineCallbacks + def on_GET(self, request): + auth_user = yield self.auth.get_user_by_req(request) + + handler = self.handlers.event_stream_handler + pagin_config = PaginationConfig.from_request(request) + timeout = EventStreamRestServlet.DEFAULT_LONGPOLL_TIME_MS + if "timeout" in request.args: + try: + timeout = int(request.args["timeout"][0]) + except ValueError: + raise SynapseError(400, "timeout must be in milliseconds.") + + chunk = yield handler.get_stream(auth_user.to_string(), pagin_config, + timeout=timeout) + defer.returnValue((200, chunk)) + + def on_OPTIONS(self, request): + return (200, {}) + + +def register_servlets(hs, http_server): + EventStreamRestServlet(hs).register(http_server) diff --git a/synapse/rest/im.py b/synapse/rest/im.py new file mode 100644 index 0000000000..39f2dbd749 --- /dev/null +++ b/synapse/rest/im.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from synapse.api.streams import PaginationConfig +from base import RestServlet, client_path_pattern + + +class ImSyncRestServlet(RestServlet): + PATTERN = client_path_pattern("/im/sync$") + + @defer.inlineCallbacks + def on_GET(self, request): + user = yield self.auth.get_user_by_req(request) + with_feedback = "feedback" in request.args + pagination_config = PaginationConfig.from_request(request) + handler = self.handlers.message_handler + content = yield handler.snapshot_all_rooms( + user_id=user.to_string(), + pagin_config=pagination_config, + feedback=with_feedback) + + defer.returnValue((200, content)) + + +def register_servlets(hs, http_server): + ImSyncRestServlet(hs).register(http_server) diff --git a/synapse/rest/login.py b/synapse/rest/login.py new file mode 100644 index 0000000000..0284e125b4 --- /dev/null +++ b/synapse/rest/login.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from synapse.api.errors import SynapseError +from base import RestServlet, client_path_pattern + +import json + + +class LoginRestServlet(RestServlet): + PATTERN = client_path_pattern("/login$") + PASS_TYPE = "m.login.password" + + def on_GET(self, request): + return (200, {"type": LoginRestServlet.PASS_TYPE}) + + def on_OPTIONS(self, request): + return (200, {}) + + @defer.inlineCallbacks + def on_POST(self, request): + login_submission = _parse_json(request) + try: + if login_submission["type"] == LoginRestServlet.PASS_TYPE: + result = yield self.do_password_login(login_submission) + defer.returnValue(result) + else: + raise SynapseError(400, "Bad login type.") + except KeyError: + raise SynapseError(400, "Missing JSON keys.") + + @defer.inlineCallbacks + def do_password_login(self, login_submission): + handler = self.handlers.login_handler + token = yield handler.login( + user=login_submission["user"], + password=login_submission["password"]) + + result = { + "access_token": token, + "home_server": self.hs.hostname, + } + + defer.returnValue((200, result)) + + +class LoginFallbackRestServlet(RestServlet): + PATTERN = client_path_pattern("/login/fallback$") + + def on_GET(self, request): + # TODO(kegan): This should be returning some HTML which is capable of + # hitting LoginRestServlet + return (200, "") + + +def _parse_json(request): + try: + content = json.loads(request.content.read()) + if type(content) != dict: + raise SynapseError(400, "Content must be a JSON object.") + return content + except ValueError: + raise SynapseError(400, "Content not JSON.") + + +def register_servlets(hs, http_server): + LoginRestServlet(hs).register(http_server) diff --git a/synapse/rest/presence.py b/synapse/rest/presence.py new file mode 100644 index 0000000000..e4925c20a5 --- /dev/null +++ b/synapse/rest/presence.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" This module contains REST servlets to do with presence: /presence/ +""" +from twisted.internet import defer + +from base import RestServlet, client_path_pattern + +import json +import logging + + +logger = logging.getLogger(__name__) + + +class PresenceStatusRestServlet(RestServlet): + PATTERN = client_path_pattern("/presence/(?P[^/]*)/status") + + @defer.inlineCallbacks + def on_GET(self, request, user_id): + auth_user = yield self.auth.get_user_by_req(request) + user = self.hs.parse_userid(user_id) + + state = yield self.handlers.presence_handler.get_state( + target_user=user, auth_user=auth_user) + + defer.returnValue((200, state)) + + @defer.inlineCallbacks + def on_PUT(self, request, user_id): + auth_user = yield self.auth.get_user_by_req(request) + user = self.hs.parse_userid(user_id) + + state = {} + try: + content = json.loads(request.content.read()) + + state["state"] = content.pop("state") + + if "status_msg" in content: + state["status_msg"] = content.pop("status_msg") + + if content: + raise KeyError() + except: + defer.returnValue((400, "Unable to parse state")) + + yield self.handlers.presence_handler.set_state( + target_user=user, auth_user=auth_user, state=state) + + defer.returnValue((200, "")) + + def on_OPTIONS(self, request): + return (200, {}) + + +class PresenceListRestServlet(RestServlet): + PATTERN = client_path_pattern("/presence_list/(?P[^/]*)") + + @defer.inlineCallbacks + def on_GET(self, request, user_id): + auth_user = yield self.auth.get_user_by_req(request) + user = self.hs.parse_userid(user_id) + + if not user.is_mine: + defer.returnValue((400, "User not hosted on this Home Server")) + + if auth_user != user: + defer.returnValue((400, "Cannot get another user's presence list")) + + presence = yield self.handlers.presence_handler.get_presence_list( + observer_user=user, accepted=True) + + for p in presence: + observed_user = p.pop("observed_user") + p["user_id"] = observed_user.to_string() + + defer.returnValue((200, presence)) + + @defer.inlineCallbacks + def on_POST(self, request, user_id): + auth_user = yield self.auth.get_user_by_req(request) + user = self.hs.parse_userid(user_id) + + if not user.is_mine: + defer.returnValue((400, "User not hosted on this Home Server")) + + if auth_user != user: + defer.returnValue(( + 400, "Cannot modify another user's presence list")) + + try: + content = json.loads(request.content.read()) + except: + logger.exception("JSON parse error") + defer.returnValue((400, "Unable to parse content")) + + deferreds = [] + + if "invite" in content: + for u in content["invite"]: + invited_user = self.hs.parse_userid(u) + deferreds.append(self.handlers.presence_handler.send_invite( + observer_user=user, observed_user=invited_user)) + + if "drop" in content: + for u in content["drop"]: + dropped_user = self.hs.parse_userid(u) + deferreds.append(self.handlers.presence_handler.drop( + observer_user=user, observed_user=dropped_user)) + + yield defer.DeferredList(deferreds) + + defer.returnValue((200, "")) + + def on_OPTIONS(self, request): + return (200, {}) + + +def register_servlets(hs, http_server): + PresenceStatusRestServlet(hs).register(http_server) + PresenceListRestServlet(hs).register(http_server) diff --git a/synapse/rest/profile.py b/synapse/rest/profile.py new file mode 100644 index 0000000000..f384227c29 --- /dev/null +++ b/synapse/rest/profile.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" This module contains REST servlets to do with profile: /profile/ """ +from twisted.internet import defer + +from base import RestServlet, client_path_pattern + +import json + + +class ProfileDisplaynameRestServlet(RestServlet): + PATTERN = client_path_pattern("/profile/(?P[^/]*)/displayname") + + @defer.inlineCallbacks + def on_GET(self, request, user_id): + user = self.hs.parse_userid(user_id) + + displayname = yield self.handlers.profile_handler.get_displayname( + user, + local_only="local_only" in request.args + ) + + defer.returnValue((200, {"displayname": displayname})) + + @defer.inlineCallbacks + def on_PUT(self, request, user_id): + auth_user = yield self.auth.get_user_by_req(request) + user = self.hs.parse_userid(user_id) + + try: + content = json.loads(request.content.read()) + new_name = content["displayname"] + except: + defer.returnValue((400, "Unable to parse name")) + + yield self.handlers.profile_handler.set_displayname( + user, auth_user, new_name) + + defer.returnValue((200, "")) + + def on_OPTIONS(self, request, user_id): + return (200, {}) + + +class ProfileAvatarURLRestServlet(RestServlet): + PATTERN = client_path_pattern("/profile/(?P[^/]*)/avatar_url") + + @defer.inlineCallbacks + def on_GET(self, request, user_id): + user = self.hs.parse_userid(user_id) + + avatar_url = yield self.handlers.profile_handler.get_avatar_url( + user, + local_only="local_only" in request.args + ) + + defer.returnValue((200, {"avatar_url": avatar_url})) + + @defer.inlineCallbacks + def on_PUT(self, request, user_id): + auth_user = yield self.auth.get_user_by_req(request) + user = self.hs.parse_userid(user_id) + + try: + content = json.loads(request.content.read()) + new_name = content["avatar_url"] + except: + defer.returnValue((400, "Unable to parse name")) + + yield self.handlers.profile_handler.set_avatar_url( + user, auth_user, new_name) + + defer.returnValue((200, "")) + + def on_OPTIONS(self, request, user_id): + return (200, {}) + + +def register_servlets(hs, http_server): + ProfileDisplaynameRestServlet(hs).register(http_server) + ProfileAvatarURLRestServlet(hs).register(http_server) diff --git a/synapse/rest/public.py b/synapse/rest/public.py new file mode 100644 index 0000000000..6fd1731a61 --- /dev/null +++ b/synapse/rest/public.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module contains REST servlets to do with public paths: /public""" +from twisted.internet import defer + +from base import RestServlet, client_path_pattern + + +class PublicRoomListRestServlet(RestServlet): + PATTERN = client_path_pattern("/public/rooms$") + + @defer.inlineCallbacks + def on_GET(self, request): + handler = self.handlers.room_list_handler + data = yield handler.get_public_room_list() + defer.returnValue((200, data)) + + +def register_servlets(hs, http_server): + PublicRoomListRestServlet(hs).register(http_server) diff --git a/synapse/rest/register.py b/synapse/rest/register.py new file mode 100644 index 0000000000..f1cbce5c67 --- /dev/null +++ b/synapse/rest/register.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module contains REST servlets to do with registration: /register""" +from twisted.internet import defer + +from synapse.api.errors import SynapseError +from base import RestServlet, client_path_pattern + +import json +import urllib + + +class RegisterRestServlet(RestServlet): + PATTERN = client_path_pattern("/register$") + + @defer.inlineCallbacks + def on_POST(self, request): + desired_user_id = None + password = None + try: + register_json = json.loads(request.content.read()) + if "password" in register_json: + password = register_json["password"] + + if type(register_json["user_id"]) == unicode: + desired_user_id = register_json["user_id"] + if urllib.quote(desired_user_id) != desired_user_id: + raise SynapseError( + 400, + "User ID must only contain characters which do not " + + "require URL encoding.") + except ValueError: + defer.returnValue((400, "No JSON object.")) + except KeyError: + pass # user_id is optional + + handler = self.handlers.registration_handler + (user_id, token) = yield handler.register( + localpart=desired_user_id, + password=password) + + result = { + "user_id": user_id, + "access_token": token, + "home_server": self.hs.hostname, + } + defer.returnValue( + (200, result) + ) + + def on_OPTIONS(self, request): + return (200, {}) + + +def register_servlets(hs, http_server): + RegisterRestServlet(hs).register(http_server) diff --git a/synapse/rest/room.py b/synapse/rest/room.py new file mode 100644 index 0000000000..c96de5e65d --- /dev/null +++ b/synapse/rest/room.py @@ -0,0 +1,394 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" This module contains REST servlets to do with rooms: /rooms/ """ +from twisted.internet import defer + +from base import RestServlet, client_path_pattern +from synapse.api.errors import SynapseError, Codes +from synapse.api.events.room import (RoomTopicEvent, MessageEvent, + RoomMemberEvent, FeedbackEvent) +from synapse.api.constants import Feedback, Membership +from synapse.api.streams import PaginationConfig +from synapse.types import RoomAlias + +import json +import logging +import urllib + + +logger = logging.getLogger(__name__) + + +class RoomCreateRestServlet(RestServlet): + # No PATTERN; we have custom dispatch rules here + + def register(self, http_server): + # /rooms OR /rooms/ + http_server.register_path("POST", + client_path_pattern("/rooms$"), + self.on_POST) + http_server.register_path("PUT", + client_path_pattern( + "/rooms/(?P[^/]*)$"), + self.on_PUT) + # define CORS for all of /rooms in RoomCreateRestServlet for simplicity + http_server.register_path("OPTIONS", + client_path_pattern("/rooms(?:/.*)?$"), + self.on_OPTIONS) + + @defer.inlineCallbacks + def on_PUT(self, request, room_id): + room_id = urllib.unquote(room_id) + auth_user = yield self.auth.get_user_by_req(request) + + if not room_id: + raise SynapseError(400, "PUT must specify a room ID") + + room_config = self.get_room_config(request) + info = yield self.make_room(room_config, auth_user, room_id) + room_config.update(info) + defer.returnValue((200, info)) + + @defer.inlineCallbacks + def on_POST(self, request): + auth_user = yield self.auth.get_user_by_req(request) + + room_config = self.get_room_config(request) + info = yield self.make_room(room_config, auth_user, None) + room_config.update(info) + defer.returnValue((200, info)) + + @defer.inlineCallbacks + def make_room(self, room_config, auth_user, room_id): + handler = self.handlers.room_creation_handler + info = yield handler.create_room( + user_id=auth_user.to_string(), + room_id=room_id, + config=room_config + ) + defer.returnValue(info) + + def get_room_config(self, request): + try: + user_supplied_config = json.loads(request.content.read()) + if "visibility" not in user_supplied_config: + # default visibility + user_supplied_config["visibility"] = "public" + return user_supplied_config + except (ValueError, TypeError): + raise SynapseError(400, "Body must be JSON.", + errcode=Codes.BAD_JSON) + + def on_OPTIONS(self, request): + return (200, {}) + + +class RoomTopicRestServlet(RestServlet): + PATTERN = client_path_pattern("/rooms/(?P[^/]*)/topic$") + + def get_event_type(self): + return RoomTopicEvent.TYPE + + @defer.inlineCallbacks + def on_GET(self, request, room_id): + user = yield self.auth.get_user_by_req(request) + + msg_handler = self.handlers.message_handler + data = yield msg_handler.get_room_data( + user_id=user.to_string(), + room_id=urllib.unquote(room_id), + event_type=RoomTopicEvent.TYPE, + state_key="", + ) + + if not data: + raise SynapseError(404, "Topic not found.", errcode=Codes.NOT_FOUND) + defer.returnValue((200, json.loads(data.content))) + + @defer.inlineCallbacks + def on_PUT(self, request, room_id): + user = yield self.auth.get_user_by_req(request) + + content = _parse_json(request) + + event = self.event_factory.create_event( + etype=self.get_event_type(), + content=content, + room_id=urllib.unquote(room_id), + user_id=user.to_string(), + ) + + msg_handler = self.handlers.message_handler + yield msg_handler.store_room_data( + event=event + ) + defer.returnValue((200, "")) + + +class JoinRoomAliasServlet(RestServlet): + PATTERN = client_path_pattern("/join/(?P[^/]+)$") + + @defer.inlineCallbacks + def on_PUT(self, request, room_alias): + user = yield self.auth.get_user_by_req(request) + + if not user: + defer.returnValue((403, "Unrecognized user")) + + logger.debug("room_alias: %s", room_alias) + + room_alias = RoomAlias.from_string( + urllib.unquote(room_alias), + self.hs + ) + + handler = self.handlers.room_member_handler + ret_dict = yield handler.join_room_alias(user, room_alias) + + defer.returnValue((200, ret_dict)) + + +class RoomMemberRestServlet(RestServlet): + PATTERN = client_path_pattern("/rooms/(?P[^/]*)/members/" + + "(?P[^/]*)/state$") + + def get_event_type(self): + return RoomMemberEvent.TYPE + + @defer.inlineCallbacks + def on_GET(self, request, room_id, target_user_id): + room_id = urllib.unquote(room_id) + user = yield self.auth.get_user_by_req(request) + + handler = self.handlers.room_member_handler + member = yield handler.get_room_member(room_id, target_user_id, + user.to_string()) + if not member: + raise SynapseError(404, "Member not found.", + errcode=Codes.NOT_FOUND) + defer.returnValue((200, json.loads(member.content))) + + @defer.inlineCallbacks + def on_DELETE(self, request, roomid, target_user_id): + user = yield self.auth.get_user_by_req(request) + + event = self.event_factory.create_event( + etype=self.get_event_type(), + target_user_id=target_user_id, + room_id=urllib.unquote(roomid), + user_id=user.to_string(), + membership=Membership.LEAVE, + content={"membership": Membership.LEAVE} + ) + + handler = self.handlers.room_member_handler + yield handler.change_membership(event, broadcast_msg=True) + defer.returnValue((200, "")) + + @defer.inlineCallbacks + def on_PUT(self, request, roomid, target_user_id): + user = yield self.auth.get_user_by_req(request) + + content = _parse_json(request) + if "membership" not in content: + raise SynapseError(400, "No membership key.", + errcode=Codes.BAD_JSON) + + valid_membership_values = [Membership.JOIN, Membership.INVITE] + if (content["membership"] not in valid_membership_values): + raise SynapseError(400, "Membership value must be %s." % ( + valid_membership_values,), errcode=Codes.BAD_JSON) + + event = self.event_factory.create_event( + etype=self.get_event_type(), + target_user_id=target_user_id, + room_id=urllib.unquote(roomid), + user_id=user.to_string(), + membership=content["membership"], + content=content + ) + + handler = self.handlers.room_member_handler + result = yield handler.change_membership(event, broadcast_msg=True) + defer.returnValue((200, result)) + + +class MessageRestServlet(RestServlet): + PATTERN = client_path_pattern("/rooms/(?P[^/]*)/messages/" + + "(?P[^/]*)/(?P[^/]*)$") + + def get_event_type(self): + return MessageEvent.TYPE + + @defer.inlineCallbacks + def on_GET(self, request, room_id, sender_id, msg_id): + user = yield self.auth.get_user_by_req(request) + + msg_handler = self.handlers.message_handler + msg = yield msg_handler.get_message(room_id=urllib.unquote(room_id), + sender_id=sender_id, + msg_id=msg_id, + user_id=user.to_string(), + ) + + if not msg: + raise SynapseError(404, "Message not found.", + errcode=Codes.NOT_FOUND) + + defer.returnValue((200, json.loads(msg.content))) + + @defer.inlineCallbacks + def on_PUT(self, request, room_id, sender_id, msg_id): + user = yield self.auth.get_user_by_req(request) + + if user.to_string() != sender_id: + raise SynapseError(403, "Must send messages as yourself.", + errcode=Codes.FORBIDDEN) + + content = _parse_json(request) + + event = self.event_factory.create_event( + etype=self.get_event_type(), + room_id=urllib.unquote(room_id), + user_id=user.to_string(), + msg_id=msg_id, + content=content + ) + + msg_handler = self.handlers.message_handler + yield msg_handler.send_message(event) + + defer.returnValue((200, "")) + + +class FeedbackRestServlet(RestServlet): + PATTERN = client_path_pattern( + "/rooms/(?P[^/]*)/messages/" + + "(?P[^/]*)/(?P[^/]*)/feedback/" + + "(?P[^/]*)/(?P[^/]*)$" + ) + + def get_event_type(self): + return FeedbackEvent.TYPE + + @defer.inlineCallbacks + def on_GET(self, request, room_id, msg_sender_id, msg_id, fb_sender_id, + feedback_type): + user = yield (self.auth.get_user_by_req(request)) + + if feedback_type not in Feedback.LIST: + raise SynapseError(400, "Bad feedback type.", + errcode=Codes.BAD_JSON) + + msg_handler = self.handlers.message_handler + feedback = yield msg_handler.get_feedback( + room_id=urllib.unquote(room_id), + msg_sender_id=msg_sender_id, + msg_id=msg_id, + user_id=user.to_string(), + fb_sender_id=fb_sender_id, + fb_type=feedback_type + ) + + if not feedback: + raise SynapseError(404, "Feedback not found.", + errcode=Codes.NOT_FOUND) + + defer.returnValue((200, json.loads(feedback.content))) + + @defer.inlineCallbacks + def on_PUT(self, request, room_id, sender_id, msg_id, fb_sender_id, + feedback_type): + user = yield (self.auth.get_user_by_req(request)) + + if user.to_string() != fb_sender_id: + raise SynapseError(403, "Must send feedback as yourself.", + errcode=Codes.FORBIDDEN) + + if feedback_type not in Feedback.LIST: + raise SynapseError(400, "Bad feedback type.", + errcode=Codes.BAD_JSON) + + content = _parse_json(request) + + event = self.event_factory.create_event( + etype=self.get_event_type(), + room_id=urllib.unquote(room_id), + msg_sender_id=sender_id, + msg_id=msg_id, + user_id=user.to_string(), # user sending the feedback + feedback_type=feedback_type, + content=content + ) + + msg_handler = self.handlers.message_handler + yield msg_handler.send_feedback(event) + + defer.returnValue((200, "")) + + +class RoomMemberListRestServlet(RestServlet): + PATTERN = client_path_pattern("/rooms/(?P[^/]*)/members/list$") + + @defer.inlineCallbacks + def on_GET(self, request, room_id): + # TODO support Pagination stream API (limit/tokens) + user = yield self.auth.get_user_by_req(request) + handler = self.handlers.room_member_handler + members = yield handler.get_room_members_as_pagination_chunk( + room_id=urllib.unquote(room_id), + user_id=user.to_string()) + + defer.returnValue((200, members)) + + +class RoomMessageListRestServlet(RestServlet): + PATTERN = client_path_pattern("/rooms/(?P[^/]*)/messages/list$") + + @defer.inlineCallbacks + def on_GET(self, request, room_id): + user = yield self.auth.get_user_by_req(request) + pagination_config = PaginationConfig.from_request(request) + with_feedback = "feedback" in request.args + handler = self.handlers.message_handler + msgs = yield handler.get_messages( + room_id=urllib.unquote(room_id), + user_id=user.to_string(), + pagin_config=pagination_config, + feedback=with_feedback) + + defer.returnValue((200, msgs)) + + +def _parse_json(request): + try: + content = json.loads(request.content.read()) + if type(content) != dict: + raise SynapseError(400, "Content must be a JSON object.", + errcode=Codes.NOT_JSON) + return content + except ValueError: + raise SynapseError(400, "Content not JSON.", errcode=Codes.NOT_JSON) + + +def register_servlets(hs, http_server): + RoomTopicRestServlet(hs).register(http_server) + RoomMemberRestServlet(hs).register(http_server) + MessageRestServlet(hs).register(http_server) + FeedbackRestServlet(hs).register(http_server) + RoomCreateRestServlet(hs).register(http_server) + RoomMemberListRestServlet(hs).register(http_server) + RoomMessageListRestServlet(hs).register(http_server) + JoinRoomAliasServlet(hs).register(http_server) diff --git a/synapse/server.py b/synapse/server.py new file mode 100644 index 0000000000..0aff75f399 --- /dev/null +++ b/synapse/server.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file provides some classes for setting up (partially-populated) +# homeservers; either as a full homeserver as a real application, or a small +# partial one for unit test mocking. + +# Imports required for the default HomeServer() implementation +from synapse.federation import initialize_http_replication +from synapse.federation.handler import FederationEventHandler +from synapse.api.events.factory import EventFactory +from synapse.api.notifier import Notifier +from synapse.api.auth import Auth +from synapse.handlers import Handlers +from synapse.rest import RestServletFactory +from synapse.state import StateHandler +from synapse.storage import DataStore +from synapse.types import UserID +from synapse.util import Clock +from synapse.util.distributor import Distributor +from synapse.util.lockutils import LockManager + + +class BaseHomeServer(object): + """A basic homeserver object without lazy component builders. + + This will need all of the components it requires to either be passed as + constructor arguments, or the relevant methods overriding to create them. + Typically this would only be used for unit tests. + + For every dependency in the DEPENDENCIES list below, this class creates one + method, + def get_DEPENDENCY(self) + which returns the value of that dependency. If no value has yet been set + nor was provided to the constructor, it will attempt to call a lazy builder + method called + def build_DEPENDENCY(self) + which must be implemented by the subclass. This code may call any of the + required "get" methods on the instance to obtain the sub-dependencies that + one requires. + """ + + DEPENDENCIES = [ + 'clock', + 'http_server', + 'http_client', + 'db_pool', + 'persistence_service', + 'federation', + 'replication_layer', + 'datastore', + 'event_factory', + 'handlers', + 'auth', + 'rest_servlet_factory', + 'state_handler', + 'room_lock_manager', + 'notifier', + 'distributor', + ] + + def __init__(self, hostname, **kwargs): + """ + Args: + hostname : The hostname for the server. + """ + self.hostname = hostname + self._building = {} + + # Other kwargs are explicit dependencies + for depname in kwargs: + setattr(self, depname, kwargs[depname]) + + @classmethod + def _make_dependency_method(cls, depname): + def _get(self): + if hasattr(self, depname): + return getattr(self, depname) + + if hasattr(self, "build_%s" % (depname)): + # Prevent cyclic dependencies from deadlocking + if depname in self._building: + raise ValueError("Cyclic dependency while building %s" % ( + depname, + )) + self._building[depname] = 1 + + builder = getattr(self, "build_%s" % (depname)) + dep = builder() + setattr(self, depname, dep) + + del self._building[depname] + + return dep + + raise NotImplementedError( + "%s has no %s nor a builder for it" % ( + type(self).__name__, depname, + ) + ) + + setattr(BaseHomeServer, "get_%s" % (depname), _get) + + # Other utility methods + def parse_userid(self, s): + """Parse the string given by 's' as a User ID and return a UserID + object.""" + return UserID.from_string(s, hs=self) + +# Build magic accessors for every dependency +for depname in BaseHomeServer.DEPENDENCIES: + BaseHomeServer._make_dependency_method(depname) + + +class HomeServer(BaseHomeServer): + """A homeserver object that will construct most of its dependencies as + required. + + It still requires the following to be specified by the caller: + http_server + http_client + db_pool + """ + + def build_clock(self): + return Clock() + + def build_replication_layer(self): + return initialize_http_replication(self) + + def build_federation(self): + return FederationEventHandler(self) + + def build_datastore(self): + return DataStore(self) + + def build_event_factory(self): + return EventFactory() + + def build_handlers(self): + return Handlers(self) + + def build_notifier(self): + return Notifier(self) + + def build_auth(self): + return Auth(self) + + def build_rest_servlet_factory(self): + return RestServletFactory(self) + + def build_state_handler(self): + return StateHandler(self) + + def build_room_lock_manager(self): + return LockManager() + + def build_distributor(self): + return Distributor() + + def register_servlets(self): + """Simply building the ServletFactory is sufficient to have it + register.""" + self.get_rest_servlet_factory() diff --git a/synapse/state.py b/synapse/state.py new file mode 100644 index 0000000000..439c0b519a --- /dev/null +++ b/synapse/state.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer + +from synapse.federation.pdu_codec import encode_event_id +from synapse.util.logutils import log_function + +from collections import namedtuple + +import logging +import hashlib + +logger = logging.getLogger(__name__) + + +def _get_state_key_from_event(event): + return event.state_key + + +KeyStateTuple = namedtuple("KeyStateTuple", ("context", "type", "state_key")) + + +class StateHandler(object): + """ Repsonsible for doing state conflict resolution. + """ + + def __init__(self, hs): + self.store = hs.get_datastore() + self._replication = hs.get_replication_layer() + self.server_name = hs.hostname + + @defer.inlineCallbacks + @log_function + def handle_new_event(self, event): + """ Given an event this works out if a) we have sufficient power level + to update the state and b) works out what the prev_state should be. + + Returns: + Deferred: Resolved with a boolean indicating if we succesfully + updated the state. + + Raised: + AuthError + """ + # This needs to be done in a transaction. + + if not hasattr(event, "state_key"): + return + + key = KeyStateTuple( + event.room_id, + event.type, + _get_state_key_from_event(event) + ) + + # Now I need to fill out the prev state and work out if it has auth + # (w.r.t. to power levels) + + results = yield self.store.get_latest_pdus_in_context( + event.room_id + ) + + event.prev_events = [ + encode_event_id(p_id, origin) for p_id, origin, _ in results + ] + event.prev_events = [ + e for e in event.prev_events if e != event.event_id + ] + + if results: + event.depth = max([int(v) for _, _, v in results]) + 1 + else: + event.depth = 0 + + current_state = yield self.store.get_current_state( + key.context, key.type, key.state_key + ) + + if current_state: + event.prev_state = encode_event_id( + current_state.pdu_id, current_state.origin + ) + + # TODO check current_state to see if the min power level is less + # than the power level of the user + # power_level = self._get_power_level_for_event(event) + + yield self.store.update_current_state( + pdu_id=event.event_id, + origin=self.server_name, + context=key.context, + pdu_type=key.type, + state_key=key.state_key + ) + + defer.returnValue(True) + + @defer.inlineCallbacks + @log_function + def handle_new_state(self, new_pdu): + """ Apply conflict resolution to `new_pdu`. + + This should be called on every new state pdu, regardless of whether or + not there is a conflict. + + This function is safe against the race of it getting called with two + `PDU`s trying to update the same state. + """ + + # This needs to be done in a transaction. + + is_new = yield self._handle_new_state(new_pdu) + + if is_new: + yield self.store.update_current_state( + pdu_id=new_pdu.pdu_id, + origin=new_pdu.origin, + context=new_pdu.context, + pdu_type=new_pdu.pdu_type, + state_key=new_pdu.state_key + ) + + defer.returnValue(is_new) + + def _get_power_level_for_event(self, event): + # return self._persistence.get_power_level_for_user(event.room_id, + # event.sender) + return event.power_level + + @defer.inlineCallbacks + @log_function + def _handle_new_state(self, new_pdu): + tree = yield self.store.get_unresolved_state_tree(new_pdu) + new_branch, current_branch = tree + + logger.debug( + "_handle_new_state new=%s, current=%s", + new_branch, current_branch + ) + + if not current_branch: + # There is no current state + defer.returnValue(True) + return + + if new_branch[-1] == current_branch[-1]: + # We have all the PDUs we need, so we can just do the conflict + # resolution. + + if len(current_branch) == 1: + # This is a direct clobber so we can just... + defer.returnValue(True) + + conflict_res = [ + self._do_power_level_conflict_res, + self._do_chain_length_conflict_res, + self._do_hash_conflict_res, + ] + + for algo in conflict_res: + new_res, curr_res = algo(new_branch, current_branch) + + if new_res < curr_res: + defer.returnValue(False) + elif new_res > curr_res: + defer.returnValue(True) + + raise Exception("Conflict resolution failed.") + + else: + # We need to ask for PDUs. + missing_prev = max( + new_branch[-1], current_branch[-1], + key=lambda x: x.depth + ) + + yield self._replication.get_pdu( + destination=missing_prev.origin, + pdu_origin=missing_prev.prev_state_origin, + pdu_id=missing_prev.prev_state_id, + outlier=True + ) + + updated_current = yield self._handle_new_state(new_pdu) + defer.returnValue(updated_current) + + def _do_power_level_conflict_res(self, new_branch, current_branch): + max_power_new = max( + new_branch[:-1], + key=lambda t: t.power_level + ).power_level + + max_power_current = max( + current_branch[:-1], + key=lambda t: t.power_level + ).power_level + + return (max_power_new, max_power_current) + + def _do_chain_length_conflict_res(self, new_branch, current_branch): + return (len(new_branch), len(current_branch)) + + def _do_hash_conflict_res(self, new_branch, current_branch): + new_str = "".join([p.pdu_id + p.origin for p in new_branch]) + c_str = "".join([p.pdu_id + p.origin for p in current_branch]) + + return ( + hashlib.sha1(new_str).hexdigest(), + hashlib.sha1(c_str).hexdigest() + ) diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py new file mode 100644 index 0000000000..ec93f9f8a7 --- /dev/null +++ b/synapse/storage/__init__.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from synapse.api.events.room import ( + RoomMemberEvent, MessageEvent, RoomTopicEvent, FeedbackEvent, + RoomConfigEvent +) + +from .directory import DirectoryStore +from .feedback import FeedbackStore +from .message import MessageStore +from .presence import PresenceStore +from .profile import ProfileStore +from .registration import RegistrationStore +from .room import RoomStore +from .roommember import RoomMemberStore +from .roomdata import RoomDataStore +from .stream import StreamStore +from .pdu import StatePduStore, PduStore +from .transactions import TransactionStore + +import json +import os + + +class DataStore(RoomDataStore, RoomMemberStore, MessageStore, RoomStore, + RegistrationStore, StreamStore, ProfileStore, FeedbackStore, + PresenceStore, PduStore, StatePduStore, TransactionStore, + DirectoryStore): + + def __init__(self, hs): + super(DataStore, self).__init__(hs) + self.event_factory = hs.get_event_factory() + self.hs = hs + + def persist_event(self, event): + if event.type == MessageEvent.TYPE: + return self.store_message( + user_id=event.user_id, + room_id=event.room_id, + msg_id=event.msg_id, + content=json.dumps(event.content) + ) + elif event.type == RoomMemberEvent.TYPE: + return self.store_room_member( + user_id=event.target_user_id, + sender=event.user_id, + room_id=event.room_id, + content=event.content, + membership=event.content["membership"] + ) + elif event.type == FeedbackEvent.TYPE: + return self.store_feedback( + room_id=event.room_id, + msg_id=event.msg_id, + msg_sender_id=event.msg_sender_id, + fb_sender_id=event.user_id, + fb_type=event.feedback_type, + content=json.dumps(event.content) + ) + elif event.type == RoomTopicEvent.TYPE: + return self.store_room_data( + room_id=event.room_id, + etype=event.type, + state_key=event.state_key, + content=json.dumps(event.content) + ) + elif event.type == RoomConfigEvent.TYPE: + if "visibility" in event.content: + visibility = event.content["visibility"] + return self.store_room_config( + room_id=event.room_id, + visibility=visibility + ) + + else: + raise NotImplementedError( + "Don't know how to persist type=%s" % event.type + ) + + +def schema_path(schema): + """ Get a filesystem path for the named database schema + + Args: + schema: Name of the database schema. + Returns: + A filesystem path pointing at a ".sql" file. + + """ + dir_path = os.path.dirname(__file__) + schemaPath = os.path.join(dir_path, "schema", schema + ".sql") + return schemaPath + + +def read_schema(schema): + """ Read the named database schema. + + Args: + schema: Name of the datbase schema. + Returns: + A string containing the database schema. + """ + with open(schema_path(schema)) as schema_file: + return schema_file.read() diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py new file mode 100644 index 0000000000..4d98a6fd0d --- /dev/null +++ b/synapse/storage/_base.py @@ -0,0 +1,405 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import logging + +from twisted.internet import defer + +from synapse.api.errors import StoreError + +import collections + +logger = logging.getLogger(__name__) + + +class SQLBaseStore(object): + + def __init__(self, hs): + self._db_pool = hs.get_db_pool() + + def cursor_to_dict(self, cursor): + """Converts a SQL cursor into an list of dicts. + + Args: + cursor : The DBAPI cursor which has executed a query. + Returns: + A list of dicts where the key is the column header. + """ + col_headers = list(column[0] for column in cursor.description) + results = list( + dict(zip(col_headers, row)) for row in cursor.fetchall() + ) + return results + + def _execute(self, decoder, query, *args): + """Runs a single query for a result set. + + Args: + decoder - The function which can resolve the cursor results to + something meaningful. + query - The query string to execute + *args - Query args. + Returns: + The result of decoder(results) + """ + logger.debug( + "[SQL] %s Args=%s Func=%s", query, args, decoder.__name__ + ) + + def interaction(txn): + cursor = txn.execute(query, args) + return decoder(cursor) + return self._db_pool.runInteraction(interaction) + + # "Simple" SQL API methods that operate on a single table with no JOINs, + # no complex WHERE clauses, just a dict of values for columns. + + def _simple_insert(self, table, values): + """Executes an INSERT query on the named table. + + Args: + table : string giving the table name + values : dict of new column names and values for them + """ + sql = "INSERT INTO %s (%s) VALUES(%s)" % ( + table, + ", ".join(k for k in values), + ", ".join("?" for k in values) + ) + + def func(txn): + txn.execute(sql, values.values()) + return txn.lastrowid + return self._db_pool.runInteraction(func) + + def _simple_select_one(self, table, keyvalues, retcols, + allow_none=False): + """Executes a SELECT query on the named table, which is expected to + return a single row, returning a single column from it. + + Args: + table : string giving the table name + keyvalues : dict of column names and values to select the row with + retcols : list of strings giving the names of the columns to return + + allow_none : If true, return None instead of failing if the SELECT + statement returns no rows + """ + return self._simple_selectupdate_one( + table, keyvalues, retcols=retcols, allow_none=allow_none + ) + + @defer.inlineCallbacks + def _simple_select_one_onecol(self, table, keyvalues, retcol, + allow_none=False): + """Executes a SELECT query on the named table, which is expected to + return a single row, returning a single column from it." + + Args: + table : string giving the table name + keyvalues : dict of column names and values to select the row with + retcol : string giving the name of the column to return + """ + ret = yield self._simple_select_one( + table=table, + keyvalues=keyvalues, + retcols=[retcol], + allow_none=allow_none + ) + + if ret: + defer.returnValue(ret[retcol]) + else: + defer.returnValue(None) + + @defer.inlineCallbacks + def _simple_select_onecol(self, table, keyvalues, retcol): + """Executes a SELECT query on the named table, which returns a list + comprising of the values of the named column from the selected rows. + + Args: + table (str): table name + keyvalues (dict): column names and values to select the rows with + retcol (str): column whos value we wish to retrieve. + + Returns: + Deferred: Results in a list + """ + sql = "SELECT %(retcol)s FROM %(table)s WHERE %(where)s" % { + "retcol": retcol, + "table": table, + "where": " AND ".join("%s = ?" % k for k in keyvalues.keys()), + } + + def func(txn): + txn.execute(sql, keyvalues.values()) + return txn.fetchall() + + res = yield self._db_pool.runInteraction(func) + + defer.returnValue([r[0] for r in res]) + + def _simple_select_list(self, table, keyvalues, retcols): + """Executes a SELECT query on the named table, which may return zero or + more rows, returning the result as a list of dicts. + + Args: + table : string giving the table name + keyvalues : dict of column names and values to select the rows with + retcols : list of strings giving the names of the columns to return + """ + sql = "SELECT %s FROM %s WHERE %s" % ( + ", ".join(retcols), + table, + " AND ".join("%s = ?" % (k) for k in keyvalues) + ) + + def func(txn): + txn.execute(sql, keyvalues.values()) + return self.cursor_to_dict(txn) + + return self._db_pool.runInteraction(func) + + def _simple_update_one(self, table, keyvalues, updatevalues, + retcols=None): + """Executes an UPDATE query on the named table, setting new values for + columns in a row matching the key values. + + Args: + table : string giving the table name + keyvalues : dict of column names and values to select the row with + updatevalues : dict giving column names and values to update + retcols : optional list of column names to return + + If present, retcols gives a list of column names on which to perform + a SELECT statement *before* performing the UPDATE statement. The values + of these will be returned in a dict. + + These are performed within the same transaction, allowing an atomic + get-and-set. This can be used to implement compare-and-set by putting + the update column in the 'keyvalues' dict as well. + """ + return self._simple_selectupdate_one(table, keyvalues, updatevalues, + retcols=retcols) + + def _simple_selectupdate_one(self, table, keyvalues, updatevalues=None, + retcols=None, allow_none=False): + """ Combined SELECT then UPDATE.""" + if retcols: + select_sql = "SELECT %s FROM %s WHERE %s" % ( + ", ".join(retcols), + table, + " AND ".join("%s = ?" % (k) for k in keyvalues) + ) + + if updatevalues: + update_sql = "UPDATE %s SET %s WHERE %s" % ( + table, + ", ".join("%s = ?" % (k) for k in updatevalues), + " AND ".join("%s = ?" % (k) for k in keyvalues) + ) + + def func(txn): + ret = None + if retcols: + txn.execute(select_sql, keyvalues.values()) + + row = txn.fetchone() + if not row: + if allow_none: + return None + raise StoreError(404, "No row found") + if txn.rowcount > 1: + raise StoreError(500, "More than one row matched") + + ret = dict(zip(retcols, row)) + + if updatevalues: + txn.execute( + update_sql, + updatevalues.values() + keyvalues.values() + ) + + if txn.rowcount == 0: + raise StoreError(404, "No row found") + if txn.rowcount > 1: + raise StoreError(500, "More than one row matched") + + return ret + return self._db_pool.runInteraction(func) + + def _simple_delete_one(self, table, keyvalues): + """Executes a DELETE query on the named table, expecting to delete a + single row. + + Args: + table : string giving the table name + keyvalues : dict of column names and values to select the row with + """ + sql = "DELETE FROM %s WHERE %s" % ( + table, + " AND ".join("%s = ?" % (k) for k in keyvalues) + ) + + def func(txn): + txn.execute(sql, keyvalues.values()) + if txn.rowcount == 0: + raise StoreError(404, "No row found") + if txn.rowcount > 1: + raise StoreError(500, "more than one row matched") + return self._db_pool.runInteraction(func) + + def _simple_max_id(self, table): + """Executes a SELECT query on the named table, expecting to return the + max value for the column "id". + + Args: + table : string giving the table name + """ + sql = "SELECT MAX(id) AS id FROM %s" % table + + def func(txn): + txn.execute(sql) + max_id = self.cursor_to_dict(txn)[0]["id"] + if max_id is None: + return 0 + return max_id + + return self._db_pool.runInteraction(func) + + +class Table(object): + """ A base class used to store information about a particular table. + """ + + table_name = None + """ str: The name of the table """ + + fields = None + """ list: The field names """ + + EntryType = None + """ Type: A tuple type used to decode the results """ + + _select_where_clause = "SELECT %s FROM %s WHERE %s" + _select_clause = "SELECT %s FROM %s" + _insert_clause = "INSERT OR REPLACE INTO %s (%s) VALUES (%s)" + + @classmethod + def select_statement(cls, where_clause=None): + """ + Args: + where_clause (str): The WHERE clause to use. + + Returns: + str: An SQL statement to select rows from the table with the given + WHERE clause. + """ + if where_clause: + return cls._select_where_clause % ( + ", ".join(cls.fields), + cls.table_name, + where_clause + ) + else: + return cls._select_clause % ( + ", ".join(cls.fields), + cls.table_name, + ) + + @classmethod + def insert_statement(cls): + return cls._insert_clause % ( + cls.table_name, + ", ".join(cls.fields), + ", ".join(["?"] * len(cls.fields)), + ) + + @classmethod + def decode_single_result(cls, results): + """ Given an iterable of tuples, return a single instance of + `EntryType` or None if the iterable is empty + Args: + results (list): The results list to convert to `EntryType` + Returns: + EntryType: An instance of `EntryType` + """ + results = list(results) + if results: + return cls.EntryType(*results[0]) + else: + return None + + @classmethod + def decode_results(cls, results): + """ Given an iterable of tuples, return a list of `EntryType` + Args: + results (list): The results list to convert to `EntryType` + + Returns: + list: A list of `EntryType` + """ + return [cls.EntryType(*row) for row in results] + + @classmethod + def get_fields_string(cls, prefix=None): + if prefix: + to_join = ("%s.%s" % (prefix, f) for f in cls.fields) + else: + to_join = cls.fields + + return ", ".join(to_join) + + +class JoinHelper(object): + """ Used to help do joins on tables by looking at the tables' fields and + creating a list of unique fields to use with SELECTs and a namedtuple + to dump the results into. + + Attributes: + taples (list): List of `Table` classes + EntryType (type) + """ + + def __init__(self, *tables): + self.tables = tables + + res = [] + for table in self.tables: + res += [f for f in table.fields if f not in res] + + self.EntryType = collections.namedtuple("JoinHelperEntry", res) + + def get_fields(self, **prefixes): + """Get a string representing a list of fields for use in SELECT + statements with the given prefixes applied to each. + + For example:: + + JoinHelper(PdusTable, StateTable).get_fields( + PdusTable="pdus", + StateTable="state" + ) + """ + res = [] + for field in self.EntryType._fields: + for table in self.tables: + if field in table.fields: + res.append("%s.%s" % (prefixes[table.__name__], field)) + break + + return ", ".join(res) + + def decode_results(self, rows): + return [self.EntryType(*row) for row in rows] diff --git a/synapse/storage/directory.py b/synapse/storage/directory.py new file mode 100644 index 0000000000..71fa9d9c9c --- /dev/null +++ b/synapse/storage/directory.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore +from twisted.internet import defer + +from collections import namedtuple + + +RoomAliasMapping = namedtuple( + "RoomAliasMapping", + ("room_id", "room_alias", "servers",) +) + + +class DirectoryStore(SQLBaseStore): + + @defer.inlineCallbacks + def get_association_from_room_alias(self, room_alias): + """ Get's the room_id and server list for a given room_alias + + Args: + room_alias (RoomAlias) + + Returns: + Deferred: results in namedtuple with keys "room_id" and + "servers" or None if no association can be found + """ + room_id = yield self._simple_select_one_onecol( + "room_aliases", + {"room_alias": room_alias.to_string()}, + "room_id", + allow_none=True, + ) + + if not room_id: + defer.returnValue(None) + return + + servers = yield self._simple_select_onecol( + "room_alias_servers", + {"room_alias": room_alias.to_string()}, + "server", + ) + + if not servers: + defer.returnValue(None) + return + + defer.returnValue( + RoomAliasMapping(room_id, room_alias.to_string(), servers) + ) + + @defer.inlineCallbacks + def create_room_alias_association(self, room_alias, room_id, servers): + """ Creates an associatin between a room alias and room_id/servers + + Args: + room_alias (RoomAlias) + room_id (str) + servers (list) + + Returns: + Deferred + """ + yield self._simple_insert( + "room_aliases", + { + "room_alias": room_alias.to_string(), + "room_id": room_id, + }, + ) + + for server in servers: + # TODO(erikj): Fix this to bulk insert + yield self._simple_insert( + "room_alias_servers", + { + "room_alias": room_alias.to_string(), + "server": server, + } + ) diff --git a/synapse/storage/feedback.py b/synapse/storage/feedback.py new file mode 100644 index 0000000000..2b421e3342 --- /dev/null +++ b/synapse/storage/feedback.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore, Table +from synapse.api.events.room import FeedbackEvent + +import collections +import json + + +class FeedbackStore(SQLBaseStore): + + def store_feedback(self, room_id, msg_id, msg_sender_id, + fb_sender_id, fb_type, content): + return self._simple_insert(FeedbackTable.table_name, dict( + room_id=room_id, + msg_id=msg_id, + msg_sender_id=msg_sender_id, + fb_sender_id=fb_sender_id, + fb_type=fb_type, + content=content, + )) + + def get_feedback(self, room_id=None, msg_id=None, msg_sender_id=None, + fb_sender_id=None, fb_type=None): + query = FeedbackTable.select_statement( + "msg_sender_id = ? AND room_id = ? AND msg_id = ? " + + "AND fb_sender_id = ? AND feedback_type = ? " + + "ORDER BY id DESC LIMIT 1") + return self._execute( + FeedbackTable.decode_single_result, + query, msg_sender_id, room_id, msg_id, fb_sender_id, fb_type, + ) + + def get_max_feedback_id(self): + return self._simple_max_id(FeedbackTable.table_name) + + +class FeedbackTable(Table): + table_name = "feedback" + + fields = [ + "id", + "content", + "feedback_type", + "fb_sender_id", + "msg_id", + "room_id", + "msg_sender_id" + ] + + class EntryType(collections.namedtuple("FeedbackEntry", fields)): + + def as_event(self, event_factory): + return event_factory.create_event( + etype=FeedbackEvent.TYPE, + room_id=self.room_id, + msg_id=self.msg_id, + msg_sender_id=self.msg_sender_id, + user_id=self.fb_sender_id, + feedback_type=self.feedback_type, + content=json.loads(self.content), + ) diff --git a/synapse/storage/message.py b/synapse/storage/message.py new file mode 100644 index 0000000000..4822fa709d --- /dev/null +++ b/synapse/storage/message.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore, Table +from synapse.api.events.room import MessageEvent + +import collections +import json + + +class MessageStore(SQLBaseStore): + + def get_message(self, user_id, room_id, msg_id): + """Get a message from the store. + + Args: + user_id (str): The ID of the user who sent the message. + room_id (str): The room the message was sent in. + msg_id (str): The unique ID for this user/room combo. + """ + query = MessagesTable.select_statement( + "user_id = ? AND room_id = ? AND msg_id = ? " + + "ORDER BY id DESC LIMIT 1") + return self._execute( + MessagesTable.decode_single_result, + query, user_id, room_id, msg_id, + ) + + def store_message(self, user_id, room_id, msg_id, content): + """Store a message in the store. + + Args: + user_id (str): The ID of the user who sent the message. + room_id (str): The room the message was sent in. + msg_id (str): The unique ID for this user/room combo. + content (str): The content of the message (JSON) + """ + return self._simple_insert(MessagesTable.table_name, dict( + user_id=user_id, + room_id=room_id, + msg_id=msg_id, + content=content, + )) + + def get_max_message_id(self): + return self._simple_max_id(MessagesTable.table_name) + + +class MessagesTable(Table): + table_name = "messages" + + fields = [ + "id", + "user_id", + "room_id", + "msg_id", + "content" + ] + + class EntryType(collections.namedtuple("MessageEntry", fields)): + + def as_event(self, event_factory): + return event_factory.create_event( + etype=MessageEvent.TYPE, + room_id=self.room_id, + user_id=self.user_id, + msg_id=self.msg_id, + content=json.loads(self.content), + ) diff --git a/synapse/storage/pdu.py b/synapse/storage/pdu.py new file mode 100644 index 0000000000..a1cdde0a3b --- /dev/null +++ b/synapse/storage/pdu.py @@ -0,0 +1,993 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore, Table, JoinHelper + +from synapse.util.logutils import log_function + +from collections import namedtuple + +import logging + +logger = logging.getLogger(__name__) + + +class PduStore(SQLBaseStore): + """A collection of queries for handling PDUs. + """ + + def get_pdu(self, pdu_id, origin): + """Given a pdu_id and origin, get a PDU. + + Args: + txn + pdu_id (str) + origin (str) + + Returns: + PduTuple: If the pdu does not exist in the database, returns None + """ + + return self._db_pool.runInteraction( + self._get_pdu_tuple, pdu_id, origin + ) + + def _get_pdu_tuple(self, txn, pdu_id, origin): + res = self._get_pdu_tuples(txn, [(pdu_id, origin)]) + return res[0] if res else None + + def _get_pdu_tuples(self, txn, pdu_id_tuples): + results = [] + for pdu_id, origin in pdu_id_tuples: + txn.execute( + PduEdgesTable.select_statement("pdu_id = ? AND origin = ?"), + (pdu_id, origin) + ) + + edges = [ + (r.prev_pdu_id, r.prev_origin) + for r in PduEdgesTable.decode_results(txn.fetchall()) + ] + + query = ( + "SELECT %(fields)s FROM %(pdus)s as p " + "LEFT JOIN %(state)s as s " + "ON p.pdu_id = s.pdu_id AND p.origin = s.origin " + "WHERE p.pdu_id = ? AND p.origin = ? " + ) % { + "fields": _pdu_state_joiner.get_fields( + PdusTable="p", StatePdusTable="s"), + "pdus": PdusTable.table_name, + "state": StatePdusTable.table_name, + } + + txn.execute(query, (pdu_id, origin)) + + row = txn.fetchone() + if row: + results.append(PduTuple(PduEntry(*row), edges)) + + return results + + def get_current_state_for_context(self, context): + """Get a list of PDUs that represent the current state for a given + context + + Args: + context (str) + + Returns: + list: A list of PduTuples + """ + + return self._db_pool.runInteraction( + self._get_current_state_for_context, + context + ) + + def _get_current_state_for_context(self, txn, context): + query = ( + "SELECT pdu_id, origin FROM %s WHERE context = ?" + % CurrentStateTable.table_name + ) + + logger.debug("get_current_state %s, Args=%s", query, context) + txn.execute(query, (context,)) + + res = txn.fetchall() + + logger.debug("get_current_state %d results", len(res)) + + return self._get_pdu_tuples(txn, res) + + def persist_pdu(self, prev_pdus, **cols): + """Inserts a (non-state) PDU into the database. + + Args: + txn, + prev_pdus (list) + **cols: The columns to insert into the PdusTable. + """ + return self._db_pool.runInteraction( + self._persist_pdu, prev_pdus, cols + ) + + def _persist_pdu(self, txn, prev_pdus, cols): + entry = PdusTable.EntryType( + **{k: cols.get(k, None) for k in PdusTable.fields} + ) + + txn.execute(PdusTable.insert_statement(), entry) + + self._handle_prev_pdus( + txn, entry.outlier, entry.pdu_id, entry.origin, + prev_pdus, entry.context + ) + + def mark_pdu_as_processed(self, pdu_id, pdu_origin): + """Mark a received PDU as processed. + + Args: + txn + pdu_id (str) + pdu_origin (str) + """ + + return self._db_pool.runInteraction( + self._mark_as_processed, pdu_id, pdu_origin + ) + + def _mark_as_processed(self, txn, pdu_id, pdu_origin): + txn.execute("UPDATE %s SET have_processed = 1" % PdusTable.table_name) + + def get_all_pdus_from_context(self, context): + """Get a list of all PDUs for a given context.""" + return self._db_pool.runInteraction( + self._get_all_pdus_from_context, context, + ) + + def _get_all_pdus_from_context(self, txn, context): + query = ( + "SELECT pdu_id, origin FROM %s " + "WHERE context = ?" + ) % PdusTable.table_name + + txn.execute(query, (context,)) + + return self._get_pdu_tuples(txn, txn.fetchall()) + + def get_pagination(self, context, pdu_list, limit): + """Get a list of Pdus for a given topic that occured before (and + including) the pdus in pdu_list. Return a list of max size `limit`. + + Args: + txn + context (str) + pdu_list (list) + limit (int) + + Return: + list: A list of PduTuples + """ + return self._db_pool.runInteraction( + self._get_paginate, context, pdu_list, limit + ) + + def _get_paginate(self, txn, context, pdu_list, limit): + logger.debug( + "paginate: %s, %s, %s", + context, repr(pdu_list), limit + ) + + # We seed the pdu_results with the things from the pdu_list. + pdu_results = pdu_list + + front = pdu_list + + query = ( + "SELECT prev_pdu_id, prev_origin FROM %(edges_table)s " + "WHERE context = ? AND pdu_id = ? AND origin = ? " + "LIMIT ?" + ) % { + "edges_table": PduEdgesTable.table_name, + } + + # We iterate through all pdu_ids in `front` to select their previous + # pdus. These are dumped in `new_front`. We continue until we reach the + # limit *or* new_front is empty (i.e., we've run out of things to + # select + while front and len(pdu_results) < limit: + + new_front = [] + for pdu_id, origin in front: + logger.debug( + "_paginate_interaction: i=%s, o=%s", + pdu_id, origin + ) + + txn.execute( + query, + (context, pdu_id, origin, limit - len(pdu_results)) + ) + + for row in txn.fetchall(): + logger.debug( + "_paginate_interaction: got i=%s, o=%s", + *row + ) + new_front.append(row) + + front = new_front + pdu_results += new_front + + # We also want to update the `prev_pdus` attributes before returning. + return self._get_pdu_tuples(txn, pdu_results) + + def get_min_depth_for_context(self, context): + """Get the current minimum depth for a context + + Args: + txn + context (str) + """ + return self._db_pool.runInteraction( + self._get_min_depth_for_context, context + ) + + def _get_min_depth_for_context(self, txn, context): + return self._get_min_depth_interaction(txn, context) + + def _get_min_depth_interaction(self, txn, context): + txn.execute( + "SELECT min_depth FROM %s WHERE context = ?" + % ContextDepthTable.table_name, + (context,) + ) + + row = txn.fetchone() + + return row[0] if row else None + + def update_min_depth_for_context(self, context, depth): + """Update the minimum `depth` of the given context, which is the line + where we stop paginating backwards on. + + Args: + context (str) + depth (int) + """ + return self._db_pool.runInteraction( + self._update_min_depth_for_context, context, depth + ) + + def _update_min_depth_for_context(self, txn, context, depth): + min_depth = self._get_min_depth_interaction(txn, context) + + do_insert = depth < min_depth if min_depth else True + + if do_insert: + txn.execute( + "INSERT OR REPLACE INTO %s (context, min_depth) " + "VALUES (?,?)" % ContextDepthTable.table_name, + (context, depth) + ) + + def get_latest_pdus_in_context(self, context): + """Get's a list of the most current pdus for a given context. This is + used when we are sending a Pdu and need to fill out the `prev_pdus` + key + + Args: + txn + context + """ + return self._db_pool.runInteraction( + self._get_latest_pdus_in_context, context + ) + + def _get_latest_pdus_in_context(self, txn, context): + query = ( + "SELECT p.pdu_id, p.origin, p.depth FROM %(pdus)s as p " + "INNER JOIN %(forward)s as f ON p.pdu_id = f.pdu_id " + "AND f.origin = p.origin " + "WHERE f.context = ?" + ) % { + "pdus": PdusTable.table_name, + "forward": PduForwardExtremitiesTable.table_name, + } + + logger.debug("get_prev query: %s", query) + + txn.execute( + query, + (context, ) + ) + + results = txn.fetchall() + + return [(row[0], row[1], row[2]) for row in results] + + def get_oldest_pdus_in_context(self, context): + """Get a list of Pdus that we paginated beyond yet (and haven't seen). + This list is used when we want to paginate backwards and is the list we + send to the remote server. + + Args: + txn + context (str) + + Returns: + list: A list of PduIdTuple. + """ + return self._db_pool.runInteraction( + self._get_oldest_pdus_in_context, context + ) + + def _get_oldest_pdus_in_context(self, txn, context): + txn.execute( + "SELECT pdu_id, origin FROM %(back)s WHERE context = ?" + % {"back": PduBackwardExtremitiesTable.table_name, }, + (context,) + ) + return [PduIdTuple(i, o) for i, o in txn.fetchall()] + + def is_pdu_new(self, pdu_id, origin, context, depth): + """For a given Pdu, try and figure out if it's 'new', i.e., if it's + not something we got randomly from the past, for example when we + request the current state of the room that will probably return a bunch + of pdus from before we joined. + + Args: + txn + pdu_id (str) + origin (str) + context (str) + depth (int) + + Returns: + bool + """ + + return self._db_pool.runInteraction( + self._is_pdu_new, + pdu_id=pdu_id, + origin=origin, + context=context, + depth=depth + ) + + def _is_pdu_new(self, txn, pdu_id, origin, context, depth): + # If depth > min depth in back table, then we classify it as new. + # OR if there is nothing in the back table, then it kinda needs to + # be a new thing. + query = ( + "SELECT min(p.depth) FROM %(edges)s as e " + "INNER JOIN %(back)s as b " + "ON e.prev_pdu_id = b.pdu_id AND e.prev_origin = b.origin " + "INNER JOIN %(pdus)s as p " + "ON e.pdu_id = p.pdu_id AND p.origin = e.origin " + "WHERE p.context = ?" + ) % { + "pdus": PdusTable.table_name, + "edges": PduEdgesTable.table_name, + "back": PduBackwardExtremitiesTable.table_name, + } + + txn.execute(query, (context,)) + + min_depth, = txn.fetchone() + + if not min_depth or depth > int(min_depth): + logger.debug( + "is_new true: id=%s, o=%s, d=%s min_depth=%s", + pdu_id, origin, depth, min_depth + ) + return True + + # If this pdu is in the forwards table, then it also is a new one + query = ( + "SELECT * FROM %(forward)s WHERE pdu_id = ? AND origin = ?" + ) % { + "forward": PduForwardExtremitiesTable.table_name, + } + + txn.execute(query, (pdu_id, origin)) + + # Did we get anything? + if txn.fetchall(): + logger.debug( + "is_new true: id=%s, o=%s, d=%s was forward", + pdu_id, origin, depth + ) + return True + + logger.debug( + "is_new false: id=%s, o=%s, d=%s", + pdu_id, origin, depth + ) + + # FINE THEN. It's probably old. + return False + + @staticmethod + @log_function + def _handle_prev_pdus(txn, outlier, pdu_id, origin, prev_pdus, + context): + txn.executemany( + PduEdgesTable.insert_statement(), + [(pdu_id, origin, p[0], p[1], context) for p in prev_pdus] + ) + + # Update the extremities table if this is not an outlier. + if not outlier: + + # First, we delete the new one from the forwards extremities table. + query = ( + "DELETE FROM %s WHERE pdu_id = ? AND origin = ?" + % PduForwardExtremitiesTable.table_name + ) + txn.executemany(query, prev_pdus) + + # We only insert as a forward extremety the new pdu if there are no + # other pdus that reference it as a prev pdu + query = ( + "INSERT INTO %(table)s (pdu_id, origin, context) " + "SELECT ?, ?, ? WHERE NOT EXISTS (" + "SELECT 1 FROM %(pdu_edges)s WHERE " + "prev_pdu_id = ? AND prev_origin = ?" + ")" + ) % { + "table": PduForwardExtremitiesTable.table_name, + "pdu_edges": PduEdgesTable.table_name + } + + logger.debug("query: %s", query) + + txn.execute(query, (pdu_id, origin, context, pdu_id, origin)) + + # Insert all the prev_pdus as a backwards thing, they'll get + # deleted in a second if they're incorrect anyway. + txn.executemany( + PduBackwardExtremitiesTable.insert_statement(), + [(i, o, context) for i, o in prev_pdus] + ) + + # Also delete from the backwards extremities table all ones that + # reference pdus that we have already seen + query = ( + "DELETE FROM %(pdu_back)s WHERE EXISTS (" + "SELECT 1 FROM %(pdus)s AS pdus " + "WHERE " + "%(pdu_back)s.pdu_id = pdus.pdu_id " + "AND %(pdu_back)s.origin = pdus.origin " + "AND not pdus.outlier " + ")" + ) % { + "pdu_back": PduBackwardExtremitiesTable.table_name, + "pdus": PdusTable.table_name, + } + txn.execute(query) + + +class StatePduStore(SQLBaseStore): + """A collection of queries for handling state PDUs. + """ + + def persist_state(self, prev_pdus, **cols): + """Inserts a state PDU into the database + + Args: + txn, + prev_pdus (list) + **cols: The columns to insert into the PdusTable and StatePdusTable + """ + + return self._db_pool.runInteraction( + self._persist_state, prev_pdus, cols + ) + + def _persist_state(self, txn, prev_pdus, cols): + pdu_entry = PdusTable.EntryType( + **{k: cols.get(k, None) for k in PdusTable.fields} + ) + state_entry = StatePdusTable.EntryType( + **{k: cols.get(k, None) for k in StatePdusTable.fields} + ) + + logger.debug("Inserting pdu: %s", repr(pdu_entry)) + logger.debug("Inserting state: %s", repr(state_entry)) + + txn.execute(PdusTable.insert_statement(), pdu_entry) + txn.execute(StatePdusTable.insert_statement(), state_entry) + + self._handle_prev_pdus( + txn, + pdu_entry.outlier, pdu_entry.pdu_id, pdu_entry.origin, prev_pdus, + pdu_entry.context + ) + + def get_unresolved_state_tree(self, new_state_pdu): + return self._db_pool.runInteraction( + self._get_unresolved_state_tree, new_state_pdu + ) + + @log_function + def _get_unresolved_state_tree(self, txn, new_pdu): + current = self._get_current_interaction( + txn, + new_pdu.context, new_pdu.pdu_type, new_pdu.state_key + ) + + ReturnType = namedtuple( + "StateReturnType", ["new_branch", "current_branch"] + ) + return_value = ReturnType([new_pdu], []) + + if not current: + logger.debug("get_unresolved_state_tree No current state.") + return return_value + + return_value.current_branch.append(current) + + enum_branches = self._enumerate_state_branches( + txn, new_pdu, current + ) + + for branch, prev_state, state in enum_branches: + if state: + return_value[branch].append(state) + else: + break + + return return_value + + def update_current_state(self, pdu_id, origin, context, pdu_type, + state_key): + return self._db_pool.runInteraction( + self._update_current_state, + pdu_id, origin, context, pdu_type, state_key + ) + + def _update_current_state(self, txn, pdu_id, origin, context, pdu_type, + state_key): + query = ( + "INSERT OR REPLACE INTO %(curr)s (%(fields)s) VALUES (%(qs)s)" + ) % { + "curr": CurrentStateTable.table_name, + "fields": CurrentStateTable.get_fields_string(), + "qs": ", ".join(["?"] * len(CurrentStateTable.fields)) + } + + query_args = CurrentStateTable.EntryType( + pdu_id=pdu_id, + origin=origin, + context=context, + pdu_type=pdu_type, + state_key=state_key + ) + + txn.execute(query, query_args) + + def get_current_state(self, context, pdu_type, state_key): + """For a given context, pdu_type, state_key 3-tuple, return what is + currently considered the current state. + + Args: + txn + context (str) + pdu_type (str) + state_key (str) + + Returns: + PduEntry + """ + + return self._db_pool.runInteraction( + self._get_current_state, context, pdu_type, state_key + ) + + def _get_current_state(self, txn, context, pdu_type, state_key): + return self._get_current_interaction(txn, context, pdu_type, state_key) + + def _get_current_interaction(self, txn, context, pdu_type, state_key): + logger.debug( + "_get_current_interaction %s %s %s", + context, pdu_type, state_key + ) + + fields = _pdu_state_joiner.get_fields( + PdusTable="p", StatePdusTable="s") + + current_query = ( + "SELECT %(fields)s FROM %(state)s as s " + "INNER JOIN %(pdus)s as p " + "ON s.pdu_id = p.pdu_id AND s.origin = p.origin " + "INNER JOIN %(curr)s as c " + "ON s.pdu_id = c.pdu_id AND s.origin = c.origin " + "WHERE s.context = ? AND s.pdu_type = ? AND s.state_key = ? " + ) % { + "fields": fields, + "curr": CurrentStateTable.table_name, + "state": StatePdusTable.table_name, + "pdus": PdusTable.table_name, + } + + txn.execute( + current_query, + (context, pdu_type, state_key) + ) + + row = txn.fetchone() + + result = PduEntry(*row) if row else None + + if not result: + logger.debug("_get_current_interaction not found") + else: + logger.debug( + "_get_current_interaction found %s %s", + result.pdu_id, result.origin + ) + + return result + + def get_next_missing_pdu(self, new_pdu): + """When we get a new state pdu we need to check whether we need to do + any conflict resolution, if we do then we need to check if we need + to go back and request some more state pdus that we haven't seen yet. + + Args: + txn + new_pdu + + Returns: + PduIdTuple: A pdu that we are missing, or None if we have all the + pdus required to do the conflict resolution. + """ + return self._db_pool.runInteraction( + self._get_next_missing_pdu, new_pdu + ) + + def _get_next_missing_pdu(self, txn, new_pdu): + logger.debug( + "get_next_missing_pdu %s %s", + new_pdu.pdu_id, new_pdu.origin + ) + + current = self._get_current_interaction( + txn, + new_pdu.context, new_pdu.pdu_type, new_pdu.state_key + ) + + if (not current or not current.prev_state_id + or not current.prev_state_origin): + return None + + # Oh look, it's a straight clobber, so wooooo almost no-op. + if (new_pdu.prev_state_id == current.pdu_id + and new_pdu.prev_state_origin == current.origin): + return None + + enum_branches = self._enumerate_state_branches(txn, new_pdu, current) + for branch, prev_state, state in enum_branches: + if not state: + return PduIdTuple( + prev_state.prev_state_id, + prev_state.prev_state_origin + ) + + return None + + def handle_new_state(self, new_pdu): + """Actually perform conflict resolution on the new_pdu on the + assumption we have all the pdus required to perform it. + + Args: + new_pdu + + Returns: + bool: True if the new_pdu clobbered the current state, False if not + """ + return self._db_pool.runInteraction( + self._handle_new_state, new_pdu + ) + + def _handle_new_state(self, txn, new_pdu): + logger.debug( + "handle_new_state %s %s", + new_pdu.pdu_id, new_pdu.origin + ) + + current = self._get_current_interaction( + txn, + new_pdu.context, new_pdu.pdu_type, new_pdu.state_key + ) + + is_current = False + + if (not current or not current.prev_state_id + or not current.prev_state_origin): + # Oh, we don't have any state for this yet. + is_current = True + elif (current.pdu_id == new_pdu.prev_state_id + and current.origin == new_pdu.prev_state_origin): + # Oh! A direct clobber. Just do it. + is_current = True + else: + ## + # Ok, now loop through until we get to a common ancestor. + max_new = int(new_pdu.power_level) + max_current = int(current.power_level) + + enum_branches = self._enumerate_state_branches( + txn, new_pdu, current + ) + for branch, prev_state, state in enum_branches: + if not state: + raise RuntimeError( + "Could not find state_pdu %s %s" % + ( + prev_state.prev_state_id, + prev_state.prev_state_origin + ) + ) + + if branch == 0: + max_new = max(int(state.depth), max_new) + else: + max_current = max(int(state.depth), max_current) + + is_current = max_new > max_current + + if is_current: + logger.debug("handle_new_state make current") + + # Right, this is a new thing, so woo, just insert it. + txn.execute( + "INSERT OR REPLACE INTO %(curr)s (%(fields)s) VALUES (%(qs)s)" + % { + "curr": CurrentStateTable.table_name, + "fields": CurrentStateTable.get_fields_string(), + "qs": ", ".join(["?"] * len(CurrentStateTable.fields)) + }, + CurrentStateTable.EntryType( + *(new_pdu.__dict__[k] for k in CurrentStateTable.fields) + ) + ) + else: + logger.debug("handle_new_state not current") + + logger.debug("handle_new_state done") + + return is_current + + @classmethod + @log_function + def _enumerate_state_branches(cls, txn, pdu_a, pdu_b): + branch_a = pdu_a + branch_b = pdu_b + + get_query = ( + "SELECT %(fields)s FROM %(pdus)s as p " + "LEFT JOIN %(state)s as s " + "ON p.pdu_id = s.pdu_id AND p.origin = s.origin " + "WHERE p.pdu_id = ? AND p.origin = ? " + ) % { + "fields": _pdu_state_joiner.get_fields( + PdusTable="p", StatePdusTable="s"), + "pdus": PdusTable.table_name, + "state": StatePdusTable.table_name, + } + + while True: + if (branch_a.pdu_id == branch_b.pdu_id + and branch_a.origin == branch_b.origin): + # Woo! We found a common ancestor + logger.debug("_enumerate_state_branches Found common ancestor") + break + + do_branch_a = ( + hasattr(branch_a, "prev_state_id") and + branch_a.prev_state_id + ) + + do_branch_b = ( + hasattr(branch_b, "prev_state_id") and + branch_b.prev_state_id + ) + + logger.debug( + "do_branch_a=%s, do_branch_b=%s", + do_branch_a, do_branch_b + ) + + if do_branch_a and do_branch_b: + do_branch_a = int(branch_a.depth) > int(branch_b.depth) + + if do_branch_a: + pdu_tuple = PduIdTuple( + branch_a.prev_state_id, + branch_a.prev_state_origin + ) + + logger.debug("getting branch_a prev %s", pdu_tuple) + txn.execute(get_query, pdu_tuple) + + prev_branch = branch_a + + res = txn.fetchone() + branch_a = PduEntry(*res) if res else None + + logger.debug("branch_a=%s", branch_a) + + yield (0, prev_branch, branch_a) + + if not branch_a: + break + elif do_branch_b: + pdu_tuple = PduIdTuple( + branch_b.prev_state_id, + branch_b.prev_state_origin + ) + txn.execute(get_query, pdu_tuple) + + logger.debug("getting branch_b prev %s", pdu_tuple) + + prev_branch = branch_b + + res = txn.fetchone() + branch_b = PduEntry(*res) if res else None + + logger.debug("branch_b=%s", branch_b) + + yield (1, prev_branch, branch_b) + + if not branch_b: + break + else: + break + + +class PdusTable(Table): + table_name = "pdus" + + fields = [ + "pdu_id", + "origin", + "context", + "pdu_type", + "ts", + "depth", + "is_state", + "content_json", + "unrecognized_keys", + "outlier", + "have_processed", + ] + + EntryType = namedtuple("PdusEntry", fields) + + +class PduDestinationsTable(Table): + table_name = "pdu_destinations" + + fields = [ + "pdu_id", + "origin", + "destination", + "delivered_ts", + ] + + EntryType = namedtuple("PduDestinationsEntry", fields) + + +class PduEdgesTable(Table): + table_name = "pdu_edges" + + fields = [ + "pdu_id", + "origin", + "prev_pdu_id", + "prev_origin", + "context" + ] + + EntryType = namedtuple("PduEdgesEntry", fields) + + +class PduForwardExtremitiesTable(Table): + table_name = "pdu_forward_extremities" + + fields = [ + "pdu_id", + "origin", + "context", + ] + + EntryType = namedtuple("PduForwardExtremitiesEntry", fields) + + +class PduBackwardExtremitiesTable(Table): + table_name = "pdu_backward_extremities" + + fields = [ + "pdu_id", + "origin", + "context", + ] + + EntryType = namedtuple("PduBackwardExtremitiesEntry", fields) + + +class ContextDepthTable(Table): + table_name = "context_depth" + + fields = [ + "context", + "min_depth", + ] + + EntryType = namedtuple("ContextDepthEntry", fields) + + +class StatePdusTable(Table): + table_name = "state_pdus" + + fields = [ + "pdu_id", + "origin", + "context", + "pdu_type", + "state_key", + "power_level", + "prev_state_id", + "prev_state_origin", + ] + + EntryType = namedtuple("StatePdusEntry", fields) + + +class CurrentStateTable(Table): + table_name = "current_state" + + fields = [ + "pdu_id", + "origin", + "context", + "pdu_type", + "state_key", + ] + + EntryType = namedtuple("CurrentStateEntry", fields) + +_pdu_state_joiner = JoinHelper(PdusTable, StatePdusTable) + + +# TODO: These should probably be put somewhere more sensible +PduIdTuple = namedtuple("PduIdTuple", ("pdu_id", "origin")) + +PduEntry = _pdu_state_joiner.EntryType +""" We are always interested in the join of the PdusTable and StatePdusTable, +rather than just the PdusTable. + +This does not include a prev_pdus key. +""" + +PduTuple = namedtuple( + "PduTuple", + ("pdu_entry", "prev_pdu_list") +) +""" This is a tuple of a `PduEntry` and a list of `PduIdTuple` that represent +the `prev_pdus` key of a PDU. +""" diff --git a/synapse/storage/presence.py b/synapse/storage/presence.py new file mode 100644 index 0000000000..e57ddaf149 --- /dev/null +++ b/synapse/storage/presence.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore + + +class PresenceStore(SQLBaseStore): + def create_presence(self, user_localpart): + return self._simple_insert( + table="presence", + values={"user_id": user_localpart}, + ) + + def has_presence_state(self, user_localpart): + return self._simple_select_one( + table="presence", + keyvalues={"user_id": user_localpart}, + retcols=["user_id"], + allow_none=True, + ) + + def get_presence_state(self, user_localpart): + return self._simple_select_one( + table="presence", + keyvalues={"user_id": user_localpart}, + retcols=["state", "status_msg"], + ) + + def set_presence_state(self, user_localpart, new_state): + return self._simple_update_one( + table="presence", + keyvalues={"user_id": user_localpart}, + updatevalues={"state": new_state["state"], + "status_msg": new_state["status_msg"]}, + retcols=["state"], + ) + + def allow_presence_visible(self, observed_localpart, observer_userid): + return self._simple_insert( + table="presence_allow_inbound", + values={"observed_user_id": observed_localpart, + "observer_user_id": observer_userid}, + ) + + def disallow_presence_visible(self, observed_localpart, observer_userid): + return self._simple_delete_one( + table="presence_allow_inbound", + keyvalues={"observed_user_id": observed_localpart, + "observer_user_id": observer_userid}, + ) + + def is_presence_visible(self, observed_localpart, observer_userid): + return self._simple_select_one( + table="presence_allow_inbound", + keyvalues={"observed_user_id": observed_localpart, + "observer_user_id": observer_userid}, + allow_none=True, + ) + + def add_presence_list_pending(self, observer_localpart, observed_userid): + return self._simple_insert( + table="presence_list", + values={"user_id": observer_localpart, + "observed_user_id": observed_userid, + "accepted": False}, + ) + + def set_presence_list_accepted(self, observer_localpart, observed_userid): + return self._simple_update_one( + table="presence_list", + keyvalues={"user_id": observer_localpart, + "observed_user_id": observed_userid}, + updatevalues={"accepted": True}, + ) + + def get_presence_list(self, observer_localpart, accepted=None): + keyvalues = {"user_id": observer_localpart} + if accepted is not None: + keyvalues["accepted"] = accepted + + return self._simple_select_list( + table="presence_list", + keyvalues=keyvalues, + retcols=["observed_user_id", "accepted"], + ) + + def del_presence_list(self, observer_localpart, observed_userid): + return self._simple_delete_one( + table="presence_list", + keyvalues={"user_id": observer_localpart, + "observed_user_id": observed_userid}, + ) diff --git a/synapse/storage/profile.py b/synapse/storage/profile.py new file mode 100644 index 0000000000..d2f24930c1 --- /dev/null +++ b/synapse/storage/profile.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore + + +class ProfileStore(SQLBaseStore): + def create_profile(self, user_localpart): + return self._simple_insert( + table="profiles", + values={"user_id": user_localpart}, + ) + + def get_profile_displayname(self, user_localpart): + return self._simple_select_one_onecol( + table="profiles", + keyvalues={"user_id": user_localpart}, + retcol="displayname", + ) + + def set_profile_displayname(self, user_localpart, new_displayname): + return self._simple_update_one( + table="profiles", + keyvalues={"user_id": user_localpart}, + updatevalues={"displayname": new_displayname}, + ) + + def get_profile_avatar_url(self, user_localpart): + return self._simple_select_one_onecol( + table="profiles", + keyvalues={"user_id": user_localpart}, + retcol="avatar_url", + ) + + def set_profile_avatar_url(self, user_localpart, new_avatar_url): + return self._simple_update_one( + table="profiles", + keyvalues={"user_id": user_localpart}, + updatevalues={"avatar_url": new_avatar_url}, + ) diff --git a/synapse/storage/registration.py b/synapse/storage/registration.py new file mode 100644 index 0000000000..4a970dd546 --- /dev/null +++ b/synapse/storage/registration.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from sqlite3 import IntegrityError + +from synapse.api.errors import StoreError + +from ._base import SQLBaseStore + + +class RegistrationStore(SQLBaseStore): + + def __init__(self, hs): + super(RegistrationStore, self).__init__(hs) + + self.clock = hs.get_clock() + + @defer.inlineCallbacks + def add_access_token_to_user(self, user_id, token): + """Adds an access token for the given user. + + Args: + user_id (str): The user ID. + token (str): The new access token to add. + Raises: + StoreError if there was a problem adding this. + """ + row = yield self._simple_select_one("users", {"name": user_id}, ["id"]) + if not row: + raise StoreError(400, "Bad user ID supplied.") + row_id = row["id"] + yield self._simple_insert( + "access_tokens", + { + "user_id": row_id, + "token": token + } + ) + + @defer.inlineCallbacks + def register(self, user_id, token, password_hash): + """Attempts to register an account. + + Args: + user_id (str): The desired user ID to register. + token (str): The desired access token to use for this user. + password_hash (str): Optional. The password hash for this user. + Raises: + StoreError if the user_id could not be registered. + """ + yield self._db_pool.runInteraction(self._register, user_id, token, + password_hash) + + def _register(self, txn, user_id, token, password_hash): + now = int(self.clock.time()) + + try: + txn.execute("INSERT INTO users(name, password_hash, creation_ts) " + "VALUES (?,?,?)", + [user_id, password_hash, now]) + except IntegrityError: + raise StoreError(400, "User ID already taken.") + + # it's possible for this to get a conflict, but only for a single user + # since tokens are namespaced based on their user ID + txn.execute("INSERT INTO access_tokens(user_id, token) " + + "VALUES (?,?)", [txn.lastrowid, token]) + + def get_user_by_id(self, user_id): + query = ("SELECT users.name, users.password_hash FROM users " + "WHERE users.name = ?") + return self._execute( + self.cursor_to_dict, + query, user_id + ) + + @defer.inlineCallbacks + def get_user_by_token(self, token): + """Get a user from the given access token. + + Args: + token (str): The access token of a user. + Returns: + str: The user ID of the user. + Raises: + StoreError if no user was found. + """ + user_id = yield self._db_pool.runInteraction(self._query_for_auth, + token) + defer.returnValue(user_id) + + def _query_for_auth(self, txn, token): + txn.execute("SELECT users.name FROM access_tokens LEFT JOIN users" + + " ON users.id = access_tokens.user_id WHERE token = ?", + [token]) + row = txn.fetchone() + if row: + return row[0] + + raise StoreError(404, "Token not found.") diff --git a/synapse/storage/room.py b/synapse/storage/room.py new file mode 100644 index 0000000000..174cbcf3d8 --- /dev/null +++ b/synapse/storage/room.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from sqlite3 import IntegrityError + +from synapse.api.errors import StoreError +from synapse.api.events.room import RoomTopicEvent + +from ._base import SQLBaseStore, Table + +import collections +import json +import logging + +logger = logging.getLogger(__name__) + + +class RoomStore(SQLBaseStore): + + @defer.inlineCallbacks + def store_room(self, room_id, room_creator_user_id, is_public): + """Stores a room. + + Args: + room_id (str): The desired room ID, can be None. + room_creator_user_id (str): The user ID of the room creator. + is_public (bool): True to indicate that this room should appear in + public room lists. + Raises: + StoreError if the room could not be stored. + """ + try: + yield self._simple_insert(RoomsTable.table_name, dict( + room_id=room_id, + creator=room_creator_user_id, + is_public=is_public + )) + except IntegrityError: + raise StoreError(409, "Room ID in use.") + except Exception as e: + logger.error("store_room with room_id=%s failed: %s", room_id, e) + raise StoreError(500, "Problem creating room.") + + def store_room_config(self, room_id, visibility): + return self._simple_update_one( + table=RoomsTable.table_name, + keyvalues={"room_id": room_id}, + updatevalues={"is_public": visibility} + ) + + def get_room(self, room_id): + """Retrieve a room. + + Args: + room_id (str): The ID of the room to retrieve. + Returns: + A namedtuple containing the room information, or an empty list. + """ + query = RoomsTable.select_statement("room_id=?") + return self._execute( + RoomsTable.decode_single_result, query, room_id, + ) + + @defer.inlineCallbacks + def get_rooms(self, is_public, with_topics): + """Retrieve a list of all public rooms. + + Args: + is_public (bool): True if the rooms returned should be public. + with_topics (bool): True to include the current topic for the room + in the response. + Returns: + A list of room dicts containing at least a "room_id" key, and a + "topic" key if one is set and with_topic=True. + """ + room_data_type = RoomTopicEvent.TYPE + public = 1 if is_public else 0 + + latest_topic = ("SELECT max(room_data.id) FROM room_data WHERE " + + "room_data.type = ? GROUP BY room_id") + + query = ("SELECT rooms.*, room_data.content FROM rooms LEFT JOIN " + + "room_data ON rooms.room_id = room_data.room_id WHERE " + + "(room_data.id IN (" + latest_topic + ") " + + "OR room_data.id IS NULL) AND rooms.is_public = ?") + + res = yield self._execute( + self.cursor_to_dict, query, room_data_type, public + ) + + # return only the keys the specification expects + ret_keys = ["room_id", "topic"] + + # extract topic from the json (icky) FIXME + for i, room_row in enumerate(res): + try: + content_json = json.loads(room_row["content"]) + room_row["topic"] = content_json["topic"] + except: + pass # no topic set + # filter the dict based on ret_keys + res[i] = {k: v for k, v in room_row.iteritems() if k in ret_keys} + + defer.returnValue(res) + + +class RoomsTable(Table): + table_name = "rooms" + + fields = [ + "room_id", + "is_public", + "creator" + ] + + EntryType = collections.namedtuple("RoomEntry", fields) diff --git a/synapse/storage/roomdata.py b/synapse/storage/roomdata.py new file mode 100644 index 0000000000..781d477931 --- /dev/null +++ b/synapse/storage/roomdata.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore, Table + +import collections +import json + + +class RoomDataStore(SQLBaseStore): + + """Provides various CRUD operations for Room Events. """ + + def get_room_data(self, room_id, etype, state_key=""): + """Retrieve the data stored under this type and state_key. + + Args: + room_id (str) + etype (str) + state_key (str) + Returns: + namedtuple: Or None if nothing exists at this path. + """ + query = RoomDataTable.select_statement( + "room_id = ? AND type = ? AND state_key = ? " + "ORDER BY id DESC LIMIT 1" + ) + return self._execute( + RoomDataTable.decode_single_result, + query, room_id, etype, state_key, + ) + + def store_room_data(self, room_id, etype, state_key="", content=None): + """Stores room specific data. + + Args: + room_id (str) + etype (str) + state_key (str) + data (str)- The data to store for this path in JSON. + Returns: + The store ID for this data. + """ + return self._simple_insert(RoomDataTable.table_name, dict( + etype=etype, + state_key=state_key, + room_id=room_id, + content=content, + )) + + def get_max_room_data_id(self): + return self._simple_max_id(RoomDataTable.table_name) + + +class RoomDataTable(Table): + table_name = "room_data" + + fields = [ + "id", + "room_id", + "type", + "state_key", + "content" + ] + + class EntryType(collections.namedtuple("RoomDataEntry", fields)): + + def as_event(self, event_factory): + return event_factory.create_event( + etype=self.type, + room_id=self.room_id, + content=json.loads(self.content), + ) diff --git a/synapse/storage/roommember.py b/synapse/storage/roommember.py new file mode 100644 index 0000000000..e6e7617797 --- /dev/null +++ b/synapse/storage/roommember.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +from synapse.types import UserID +from synapse.api.constants import Membership +from synapse.api.events.room import RoomMemberEvent + +from ._base import SQLBaseStore, Table + + +import collections +import json +import logging + +logger = logging.getLogger(__name__) + + +class RoomMemberStore(SQLBaseStore): + + def get_room_member(self, user_id, room_id): + """Retrieve the current state of a room member. + + Args: + user_id (str): The member's user ID. + room_id (str): The room the member is in. + Returns: + namedtuple: The room member from the database, or None if this + member does not exist. + """ + query = RoomMemberTable.select_statement( + "room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1") + return self._execute( + RoomMemberTable.decode_single_result, + query, room_id, user_id, + ) + + def store_room_member(self, user_id, sender, room_id, membership, content): + """Store a room member in the database. + + Args: + user_id (str): The member's user ID. + room_id (str): The room in relation to the member. + membership (synapse.api.constants.Membership): The new membership + state. + content (dict): The content of the membership (JSON). + """ + content_json = json.dumps(content) + return self._simple_insert(RoomMemberTable.table_name, dict( + user_id=user_id, + sender=sender, + room_id=room_id, + membership=membership, + content=content_json, + )) + + @defer.inlineCallbacks + def get_room_members(self, room_id, membership=None): + """Retrieve the current room member list for a room. + + Args: + room_id (str): The room to get the list of members. + membership (synapse.api.constants.Membership): The filter to apply + to this list, or None to return all members with some state + associated with this room. + Returns: + list of namedtuples representing the members in this room. + """ + query = RoomMemberTable.select_statement( + "id IN (SELECT MAX(id) FROM " + RoomMemberTable.table_name + + " WHERE room_id = ? GROUP BY user_id)" + ) + res = yield self._execute( + RoomMemberTable.decode_results, query, room_id, + ) + # strip memberships which don't match + if membership: + res = [entry for entry in res if entry.membership == membership] + defer.returnValue(res) + + def get_rooms_for_user_where_membership_is(self, user_id, membership_list): + """ Get all the rooms for this user where the membership for this user + matches one in the membership list. + + Args: + user_id (str): The user ID. + membership_list (list): A list of synapse.api.constants.Membership + values which the user must be in. + Returns: + A list of dicts with "room_id" and "membership" keys. + """ + if not membership_list: + return defer.succeed(None) + + args = [user_id] + membership_placeholder = ["membership=?"] * len(membership_list) + where_membership = "(" + " OR ".join(membership_placeholder) + ")" + for membership in membership_list: + args.append(membership) + + query = ("SELECT room_id, membership FROM room_memberships" + + " WHERE user_id=? AND " + where_membership + + " GROUP BY room_id ORDER BY id DESC") + return self._execute( + self.cursor_to_dict, query, *args + ) + + @defer.inlineCallbacks + def get_joined_hosts_for_room(self, room_id): + query = RoomMemberTable.select_statement( + "id IN (SELECT MAX(id) FROM " + RoomMemberTable.table_name + + " WHERE room_id = ? GROUP BY user_id)" + ) + + res = yield self._execute( + RoomMemberTable.decode_results, query, room_id, + ) + + def host_from_user_id_string(user_id): + domain = UserID.from_string(entry.user_id, self.hs).domain + return domain + + # strip memberships which don't match + hosts = [ + host_from_user_id_string(entry.user_id) + for entry in res + if entry.membership == Membership.JOIN + ] + + logger.debug("Returning hosts: %s from results: %s", hosts, res) + + defer.returnValue(hosts) + + def get_max_room_member_id(self): + return self._simple_max_id(RoomMemberTable.table_name) + + +class RoomMemberTable(Table): + table_name = "room_memberships" + + fields = [ + "id", + "user_id", + "sender", + "room_id", + "membership", + "content" + ] + + class EntryType(collections.namedtuple("RoomMemberEntry", fields)): + + def as_event(self, event_factory): + return event_factory.create_event( + etype=RoomMemberEvent.TYPE, + room_id=self.room_id, + target_user_id=self.user_id, + user_id=self.sender, + content=json.loads(self.content), + ) diff --git a/synapse/storage/schema/edge_pdus.sql b/synapse/storage/schema/edge_pdus.sql new file mode 100644 index 0000000000..17b3c52f0d --- /dev/null +++ b/synapse/storage/schema/edge_pdus.sql @@ -0,0 +1,31 @@ +/* Copyright 2014 matrix.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +CREATE TABLE IF NOT EXISTS context_edge_pdus( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- twistar requires this + pdu_id TEXT, + origin TEXT, + context TEXT, + CONSTRAINT context_edge_pdu_id_origin UNIQUE (pdu_id, origin) +); + +CREATE TABLE IF NOT EXISTS origin_edge_pdus( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- twistar requires this + pdu_id TEXT, + origin TEXT, + CONSTRAINT origin_edge_pdu_id_origin UNIQUE (pdu_id, origin) +); + +CREATE INDEX IF NOT EXISTS context_edge_pdu_id ON context_edge_pdus(pdu_id, origin); +CREATE INDEX IF NOT EXISTS origin_edge_pdu_id ON origin_edge_pdus(pdu_id, origin); diff --git a/synapse/storage/schema/im.sql b/synapse/storage/schema/im.sql new file mode 100644 index 0000000000..77096546b2 --- /dev/null +++ b/synapse/storage/schema/im.sql @@ -0,0 +1,54 @@ +/* Copyright 2014 matrix.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +CREATE TABLE IF NOT EXISTS rooms( + room_id TEXT PRIMARY KEY NOT NULL, + is_public INTEGER, + creator TEXT +); + +CREATE TABLE IF NOT EXISTS room_memberships( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, -- no foreign key to users table, it could be an id belonging to another home server + sender TEXT NOT NULL, + room_id TEXT NOT NULL, + membership TEXT NOT NULL, + content TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS messages( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + room_id TEXT, + msg_id TEXT, + content TEXT +); + +CREATE TABLE IF NOT EXISTS feedback( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT, + feedback_type TEXT, + fb_sender_id TEXT, + msg_id TEXT, + room_id TEXT, + msg_sender_id TEXT +); + +CREATE TABLE IF NOT EXISTS room_data( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + type TEXT NOT NULL, + state_key TEXT NOT NULL, + content TEXT +); diff --git a/synapse/storage/schema/pdu.sql b/synapse/storage/schema/pdu.sql new file mode 100644 index 0000000000..ca3de005e9 --- /dev/null +++ b/synapse/storage/schema/pdu.sql @@ -0,0 +1,106 @@ +/* Copyright 2014 matrix.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +-- Stores pdus and their content +CREATE TABLE IF NOT EXISTS pdus( + pdu_id TEXT, + origin TEXT, + context TEXT, + pdu_type TEXT, + ts INTEGER, + depth INTEGER DEFAULT 0 NOT NULL, + is_state BOOL, + content_json TEXT, + unrecognized_keys TEXT, + outlier BOOL NOT NULL, + have_processed BOOL, + CONSTRAINT pdu_id_origin UNIQUE (pdu_id, origin) +); + +-- Stores what the current state pdu is for a given (context, pdu_type, key) tuple +CREATE TABLE IF NOT EXISTS state_pdus( + pdu_id TEXT, + origin TEXT, + context TEXT, + pdu_type TEXT, + state_key TEXT, + power_level TEXT, + prev_state_id TEXT, + prev_state_origin TEXT, + CONSTRAINT pdu_id_origin UNIQUE (pdu_id, origin) + CONSTRAINT prev_pdu_id_origin UNIQUE (prev_state_id, prev_state_origin) +); + +CREATE TABLE IF NOT EXISTS current_state( + pdu_id TEXT, + origin TEXT, + context TEXT, + pdu_type TEXT, + state_key TEXT, + CONSTRAINT pdu_id_origin UNIQUE (pdu_id, origin) + CONSTRAINT uniqueness UNIQUE (context, pdu_type, state_key) ON CONFLICT REPLACE +); + +-- Stores where each pdu we want to send should be sent and the delivery status. +create TABLE IF NOT EXISTS pdu_destinations( + pdu_id TEXT, + origin TEXT, + destination TEXT, + delivered_ts INTEGER DEFAULT 0, -- or 0 if not delivered + CONSTRAINT uniqueness UNIQUE (pdu_id, origin, destination) ON CONFLICT REPLACE +); + +CREATE TABLE IF NOT EXISTS pdu_forward_extremities( + pdu_id TEXT, + origin TEXT, + context TEXT, + CONSTRAINT uniqueness UNIQUE (pdu_id, origin, context) ON CONFLICT REPLACE +); + +CREATE TABLE IF NOT EXISTS pdu_backward_extremities( + pdu_id TEXT, + origin TEXT, + context TEXT, + CONSTRAINT uniqueness UNIQUE (pdu_id, origin, context) ON CONFLICT REPLACE +); + +CREATE TABLE IF NOT EXISTS pdu_edges( + pdu_id TEXT, + origin TEXT, + prev_pdu_id TEXT, + prev_origin TEXT, + context TEXT, + CONSTRAINT uniqueness UNIQUE (pdu_id, origin, prev_pdu_id, prev_origin, context) +); + +CREATE TABLE IF NOT EXISTS context_depth( + context TEXT, + min_depth INTEGER, + CONSTRAINT uniqueness UNIQUE (context) +); + +CREATE INDEX IF NOT EXISTS context_depth_context ON context_depth(context); + + +CREATE INDEX IF NOT EXISTS pdu_id ON pdus(pdu_id, origin); + +CREATE INDEX IF NOT EXISTS dests_id ON pdu_destinations (pdu_id, origin); +-- CREATE INDEX IF NOT EXISTS dests ON pdu_destinations (destination); + +CREATE INDEX IF NOT EXISTS pdu_extrem_context ON pdu_forward_extremities(context); +CREATE INDEX IF NOT EXISTS pdu_extrem_id ON pdu_forward_extremities(pdu_id, origin); + +CREATE INDEX IF NOT EXISTS pdu_edges_id ON pdu_edges(pdu_id, origin); + +CREATE INDEX IF NOT EXISTS pdu_b_extrem_context ON pdu_backward_extremities(context); diff --git a/synapse/storage/schema/presence.sql b/synapse/storage/schema/presence.sql new file mode 100644 index 0000000000..b22e3ba863 --- /dev/null +++ b/synapse/storage/schema/presence.sql @@ -0,0 +1,37 @@ +/* Copyright 2014 matrix.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +CREATE TABLE IF NOT EXISTS presence( + user_id INTEGER NOT NULL, + state INTEGER, + status_msg TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) +); + +-- For each of /my/ users which possibly-remote users are allowed to see their +-- presence state +CREATE TABLE IF NOT EXISTS presence_allow_inbound( + observed_user_id INTEGER NOT NULL, + observer_user_id TEXT, -- a UserID, + FOREIGN KEY(observed_user_id) REFERENCES users(id) +); + +-- For each of /my/ users (watcher), which possibly-remote users are they +-- watching? +CREATE TABLE IF NOT EXISTS presence_list( + user_id INTEGER NOT NULL, + observed_user_id TEXT, -- a UserID, + accepted BOOLEAN, + FOREIGN KEY(user_id) REFERENCES users(id) +); diff --git a/synapse/storage/schema/profiles.sql b/synapse/storage/schema/profiles.sql new file mode 100644 index 0000000000..1092d7672c --- /dev/null +++ b/synapse/storage/schema/profiles.sql @@ -0,0 +1,20 @@ +/* Copyright 2014 matrix.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +CREATE TABLE IF NOT EXISTS profiles( + user_id INTEGER NOT NULL, + displayname TEXT, + avatar_url TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) +); diff --git a/synapse/storage/schema/room_aliases.sql b/synapse/storage/schema/room_aliases.sql new file mode 100644 index 0000000000..71a8b90e4d --- /dev/null +++ b/synapse/storage/schema/room_aliases.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS room_aliases( + room_alias TEXT NOT NULL, + room_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS room_alias_servers( + room_alias TEXT NOT NULL, + server TEXT NOT NULL +); + + + diff --git a/synapse/storage/schema/transactions.sql b/synapse/storage/schema/transactions.sql new file mode 100644 index 0000000000..4b1a2368f6 --- /dev/null +++ b/synapse/storage/schema/transactions.sql @@ -0,0 +1,61 @@ +/* Copyright 2014 matrix.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +-- Stores what transaction ids we have received and what our response was +CREATE TABLE IF NOT EXISTS received_transactions( + transaction_id TEXT, + origin TEXT, + ts INTEGER, + response_code INTEGER, + response_json TEXT, + has_been_referenced BOOL default 0, -- Whether thishas been referenced by a prev_tx + CONSTRAINT uniquesss UNIQUE (transaction_id, origin) ON CONFLICT REPLACE +); + +CREATE UNIQUE INDEX IF NOT EXISTS transactions_txid ON received_transactions(transaction_id, origin); +CREATE INDEX IF NOT EXISTS transactions_have_ref ON received_transactions(origin, has_been_referenced);-- WHERE has_been_referenced = 0; + + +-- Stores what transactions we've sent, what their response was (if we got one) and whether we have +-- since referenced the transaction in another outgoing transaction +CREATE TABLE IF NOT EXISTS sent_transactions( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- This is used to apply insertion ordering + transaction_id TEXT, + destination TEXT, + response_code INTEGER DEFAULT 0, + response_json TEXT, + ts INTEGER +); + +CREATE INDEX IF NOT EXISTS sent_transaction_dest ON sent_transactions(destination); +CREATE INDEX IF NOT EXISTS sent_transaction_dest_referenced ON sent_transactions( + destination +); +-- So that we can do an efficient look up of all transactions that have yet to be successfully +-- sent. +CREATE INDEX IF NOT EXISTS sent_transaction_sent ON sent_transactions(response_code); + + +-- For sent transactions only. +CREATE TABLE IF NOT EXISTS transaction_id_to_pdu( + transaction_id INTEGER, + destination TEXT, + pdu_id TEXT, + pdu_origin TEXT +); + +CREATE INDEX IF NOT EXISTS transaction_id_to_pdu_tx ON transaction_id_to_pdu(transaction_id, destination); +CREATE INDEX IF NOT EXISTS transaction_id_to_pdu_dest ON transaction_id_to_pdu(destination); +CREATE INDEX IF NOT EXISTS transaction_id_to_pdu_index ON transaction_id_to_pdu(transaction_id, destination); + diff --git a/synapse/storage/schema/users.sql b/synapse/storage/schema/users.sql new file mode 100644 index 0000000000..46b60297cb --- /dev/null +++ b/synapse/storage/schema/users.sql @@ -0,0 +1,31 @@ +/* Copyright 2014 matrix.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + password_hash TEXT, + creation_ts INTEGER, + UNIQUE(name) ON CONFLICT ROLLBACK +); + +CREATE TABLE IF NOT EXISTS access_tokens( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + device_id TEXT, + token TEXT NOT NULL, + last_used INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id), + UNIQUE(token) ON CONFLICT ROLLBACK +); diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py new file mode 100644 index 0000000000..c3b1bfeb32 --- /dev/null +++ b/synapse/storage/stream.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._base import SQLBaseStore +from .message import MessagesTable +from .feedback import FeedbackTable +from .roomdata import RoomDataTable +from .roommember import RoomMemberTable + +import json +import logging + +logger = logging.getLogger(__name__) + + +class StreamStore(SQLBaseStore): + + def get_message_stream(self, user_id, from_key, to_key, room_id, limit=0, + with_feedback=False): + """Get all messages for this user between the given keys. + + Args: + user_id (str): The user who is requesting messages. + from_key (int): The ID to start returning results from (exclusive). + to_key (int): The ID to stop returning results (exclusive). + room_id (str): Gets messages only for this room. Can be None, in + which case all room messages will be returned. + Returns: + A tuple of rows (list of namedtuples), new_id(int) + """ + if with_feedback and room_id: # with fb MUST specify a room ID + return self._db_pool.runInteraction( + self._get_message_rows_with_feedback, + user_id, from_key, to_key, room_id, limit + ) + else: + return self._db_pool.runInteraction( + self._get_message_rows, + user_id, from_key, to_key, room_id, limit + ) + + def _get_message_rows(self, txn, user_id, from_pkey, to_pkey, room_id, + limit): + # work out which rooms this user is joined in on and join them with + # the room id on the messages table, bounded by the specified pkeys + + # get all messages where the *current* membership state is 'join' for + # this user in that room. + query = ("SELECT messages.* FROM messages WHERE ? IN" + + " (SELECT membership from room_memberships WHERE user_id=?" + + " AND room_id = messages.room_id ORDER BY id DESC LIMIT 1)") + query_args = ["join", user_id] + + if room_id: + query += " AND messages.room_id=?" + query_args.append(room_id) + + (query, query_args) = self._append_stream_operations( + "messages", query, query_args, from_pkey, to_pkey, limit=limit + ) + + logger.debug("[SQL] %s : %s", query, query_args) + cursor = txn.execute(query, query_args) + return self._as_events(cursor, MessagesTable, from_pkey) + + def _get_message_rows_with_feedback(self, txn, user_id, from_pkey, to_pkey, + room_id, limit): + # this col represents the compressed feedback JSON as per spec + compressed_feedback_col = ( + "'[' || group_concat('{\"sender_id\":\"' || f.fb_sender_id" + + " || '\",\"feedback_type\":\"' || f.feedback_type" + + " || '\",\"content\":' || f.content || '}') || ']'" + ) + + global_msg_id_join = ("f.room_id = messages.room_id" + + " and f.msg_id = messages.msg_id" + + " and messages.user_id = f.msg_sender_id") + + select_query = ( + "SELECT messages.*, f.content AS fb_content, f.fb_sender_id" + + ", " + compressed_feedback_col + " AS compressed_fb" + + " FROM messages LEFT JOIN feedback f ON " + global_msg_id_join) + + current_membership_sub_query = ( + "(SELECT membership from room_memberships rm" + + " WHERE user_id=? AND room_id = rm.room_id" + + " ORDER BY id DESC LIMIT 1)") + + where = (" WHERE ? IN " + current_membership_sub_query + + " AND messages.room_id=?") + + query = select_query + where + query_args = ["join", user_id, room_id] + + (query, query_args) = self._append_stream_operations( + "messages", query, query_args, from_pkey, to_pkey, + limit=limit, group_by=" GROUP BY messages.id " + ) + + logger.debug("[SQL] %s : %s", query, query_args) + cursor = txn.execute(query, query_args) + + # convert the result set into events + entries = self.cursor_to_dict(cursor) + events = [] + for entry in entries: + # TODO we should spec the cursor > event mapping somewhere else. + event = {} + straight_mappings = ["msg_id", "user_id", "room_id"] + for key in straight_mappings: + event[key] = entry[key] + event["content"] = json.loads(entry["content"]) + if entry["compressed_fb"]: + event["feedback"] = json.loads(entry["compressed_fb"]) + events.append(event) + + latest_pkey = from_pkey if len(entries) == 0 else entries[-1]["id"] + + return (events, latest_pkey) + + def get_room_member_stream(self, user_id, from_key, to_key): + """Get all room membership events for this user between the given keys. + + Args: + user_id (str): The user who is requesting membership events. + from_key (int): The ID to start returning results from (exclusive). + to_key (int): The ID to stop returning results (exclusive). + Returns: + A tuple of rows (list of namedtuples), new_id(int) + """ + return self._db_pool.runInteraction( + self._get_room_member_rows, user_id, from_key, to_key + ) + + def _get_room_member_rows(self, txn, user_id, from_pkey, to_pkey): + # get all room membership events for rooms which the user is + # *currently* joined in on, or all invite events for this user. + current_membership_sub_query = ( + "(SELECT membership FROM room_memberships" + + " WHERE user_id=? AND room_id = rm.room_id" + + " ORDER BY id DESC LIMIT 1)") + + query = ("SELECT rm.* FROM room_memberships rm " + # all membership events for rooms you've currently joined. + + " WHERE (? IN " + current_membership_sub_query + # all invite membership events for this user + + " OR rm.membership=? AND user_id=?)" + + " AND rm.id > ?") + query_args = ["join", user_id, "invite", user_id, from_pkey] + + if to_pkey != -1: + query += " AND rm.id < ?" + query_args.append(to_pkey) + + cursor = txn.execute(query, query_args) + return self._as_events(cursor, RoomMemberTable, from_pkey) + + def get_feedback_stream(self, user_id, from_key, to_key, room_id, limit=0): + return self._db_pool.runInteraction( + self._get_feedback_rows, + user_id, from_key, to_key, room_id, limit + ) + + def _get_feedback_rows(self, txn, user_id, from_pkey, to_pkey, room_id, + limit): + # work out which rooms this user is joined in on and join them with + # the room id on the feedback table, bounded by the specified pkeys + + # get all messages where the *current* membership state is 'join' for + # this user in that room. + query = ( + "SELECT feedback.* FROM feedback WHERE ? IN " + + "(SELECT membership from room_memberships WHERE user_id=?" + + " AND room_id = feedback.room_id ORDER BY id DESC LIMIT 1)") + query_args = ["join", user_id] + + if room_id: + query += " AND feedback.room_id=?" + query_args.append(room_id) + + (query, query_args) = self._append_stream_operations( + "feedback", query, query_args, from_pkey, to_pkey, limit=limit + ) + + logger.debug("[SQL] %s : %s", query, query_args) + cursor = txn.execute(query, query_args) + return self._as_events(cursor, FeedbackTable, from_pkey) + + def get_room_data_stream(self, user_id, from_key, to_key, room_id, + limit=0): + return self._db_pool.runInteraction( + self._get_room_data_rows, + user_id, from_key, to_key, room_id, limit + ) + + def _get_room_data_rows(self, txn, user_id, from_pkey, to_pkey, room_id, + limit): + # work out which rooms this user is joined in on and join them with + # the room id on the feedback table, bounded by the specified pkeys + + # get all messages where the *current* membership state is 'join' for + # this user in that room. + query = ( + "SELECT room_data.* FROM room_data WHERE ? IN " + + "(SELECT membership from room_memberships WHERE user_id=?" + + " AND room_id = room_data.room_id ORDER BY id DESC LIMIT 1)") + query_args = ["join", user_id] + + if room_id: + query += " AND room_data.room_id=?" + query_args.append(room_id) + + (query, query_args) = self._append_stream_operations( + "room_data", query, query_args, from_pkey, to_pkey, limit=limit + ) + + logger.debug("[SQL] %s : %s", query, query_args) + cursor = txn.execute(query, query_args) + return self._as_events(cursor, RoomDataTable, from_pkey) + + def _append_stream_operations(self, table_name, query, query_args, + from_pkey, to_pkey, limit=None, + group_by=""): + LATEST_ROW = -1 + order_by = "" + if to_pkey > from_pkey: + if from_pkey != LATEST_ROW: + # e.g. from=5 to=9 >> from 5 to 9 >> id>5 AND id<9 + query += (" AND %s.id > ? AND %s.id < ?" % + (table_name, table_name)) + query_args.append(from_pkey) + query_args.append(to_pkey) + else: + # e.g. from=-1 to=5 >> from now to 5 >> id>5 ORDER BY id DESC + query += " AND %s.id > ? " % table_name + order_by = "ORDER BY id DESC" + query_args.append(to_pkey) + elif from_pkey > to_pkey: + if to_pkey != LATEST_ROW: + # from=9 to=5 >> from 9 to 5 >> id>5 AND id<9 ORDER BY id DESC + query += (" AND %s.id > ? AND %s.id < ? " % + (table_name, table_name)) + order_by = "ORDER BY id DESC" + query_args.append(to_pkey) + query_args.append(from_pkey) + else: + # from=5 to=-1 >> from 5 to now >> id>5 + query += " AND %s.id > ?" % table_name + query_args.append(from_pkey) + + query += group_by + order_by + + if limit and limit > 0: + query += " LIMIT ?" + query_args.append(str(limit)) + + return (query, query_args) + + def _as_events(self, cursor, table, from_pkey): + data_entries = table.decode_results(cursor) + last_pkey = from_pkey + if data_entries: + last_pkey = data_entries[-1].id + + events = [ + entry.as_event(self.event_factory).get_dict() + for entry in data_entries + ] + + return (events, last_pkey) diff --git a/synapse/storage/transactions.py b/synapse/storage/transactions.py new file mode 100644 index 0000000000..aa41e2ad7f --- /dev/null +++ b/synapse/storage/transactions.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from ._base import SQLBaseStore, Table +from .pdu import PdusTable + +from collections import namedtuple + +import logging + +logger = logging.getLogger(__name__) + + +class TransactionStore(SQLBaseStore): + """A collection of queries for handling PDUs. + """ + + def get_received_txn_response(self, transaction_id, origin): + """For an incoming transaction from a given origin, check if we have + already responded to it. If so, return the response code and response + body (as a dict). + + Args: + transaction_id (str) + origin(str) + + Returns: + tuple: None if we have not previously responded to + this transaction or a 2-tuple of (int, dict) + """ + + return self._db_pool.runInteraction( + self._get_received_txn_response, transaction_id, origin + ) + + def _get_received_txn_response(self, txn, transaction_id, origin): + where_clause = "transaction_id = ? AND origin = ?" + query = ReceivedTransactionsTable.select_statement(where_clause) + + txn.execute(query, (transaction_id, origin)) + + results = ReceivedTransactionsTable.decode_results(txn.fetchall()) + + if results and results[0].response_code: + return (results[0].response_code, results[0].response_json) + else: + return None + + def set_received_txn_response(self, transaction_id, origin, code, + response_dict): + """Persist the response we returened for an incoming transaction, and + should return for subsequent transactions with the same transaction_id + and origin. + + Args: + txn + transaction_id (str) + origin (str) + code (int) + response_json (str) + """ + + return self._db_pool.runInteraction( + self._set_received_txn_response, + transaction_id, origin, code, response_dict + ) + + def _set_received_txn_response(self, txn, transaction_id, origin, code, + response_json): + query = ( + "UPDATE %s " + "SET response_code = ?, response_json = ? " + "WHERE transaction_id = ? AND origin = ?" + ) % ReceivedTransactionsTable.table_name + + txn.execute(query, (code, response_json, transaction_id, origin)) + + def prep_send_transaction(self, transaction_id, destination, ts, pdu_list): + """Persists an outgoing transaction and calculates the values for the + previous transaction id list. + + This should be called before sending the transaction so that it has the + correct value for the `prev_ids` key. + + Args: + transaction_id (str) + destination (str) + ts (int) + pdu_list (list) + + Returns: + list: A list of previous transaction ids. + """ + + return self._db_pool.runInteraction( + self._prep_send_transaction, + transaction_id, destination, ts, pdu_list + ) + + def _prep_send_transaction(self, txn, transaction_id, destination, ts, + pdu_list): + + # First we find out what the prev_txs should be. + # Since we know that we are only sending one transaction at a time, + # we can simply take the last one. + query = "%s ORDER BY id DESC LIMIT 1" % ( + SentTransactions.select_statement("destination = ?"), + ) + + results = txn.execute(query, (destination,)) + results = SentTransactions.decode_results(results) + + prev_txns = [r.transaction_id for r in results] + + # Actually add the new transaction to the sent_transactions table. + + query = SentTransactions.insert_statement() + txn.execute(query, SentTransactions.EntryType( + None, + transaction_id=transaction_id, + destination=destination, + ts=ts, + response_code=0, + response_json=None + )) + + # Update the tx id -> pdu id mapping + + values = [ + (transaction_id, destination, pdu[0], pdu[1]) + for pdu in pdu_list + ] + + logger.debug("Inserting: %s", repr(values)) + + query = TransactionsToPduTable.insert_statement() + txn.executemany(query, values) + + return prev_txns + + def delivered_txn(self, transaction_id, destination, code, response_dict): + """Persists the response for an outgoing transaction. + + Args: + transaction_id (str) + destination (str) + code (int) + response_json (str) + """ + return self._db_pool.runInteraction( + self._delivered_txn, + transaction_id, destination, code, response_dict + ) + + def _delivered_txn(cls, txn, transaction_id, destination, + code, response_json): + query = ( + "UPDATE %s " + "SET response_code = ?, response_json = ? " + "WHERE transaction_id = ? AND destination = ?" + ) % SentTransactions.table_name + + txn.execute(query, (code, response_json, transaction_id, destination)) + + def get_transactions_after(self, transaction_id, destination): + """Get all transactions after a given local transaction_id. + + Args: + transaction_id (str) + destination (str) + + Returns: + list: A list of `ReceivedTransactionsTable.EntryType` + """ + return self._db_pool.runInteraction( + self._get_transactions_after, transaction_id, destination + ) + + def _get_transactions_after(cls, txn, transaction_id, destination): + where = ( + "destination = ? AND id > (select id FROM %s WHERE " + "transaction_id = ? AND destination = ?)" + ) % ( + SentTransactions.table_name + ) + query = SentTransactions.select_statement(where) + + txn.execute(query, (destination, transaction_id, destination)) + + return ReceivedTransactionsTable.decode_results(txn.fetchall()) + + def get_pdus_after_transaction(self, transaction_id, destination): + """For a given local transaction_id that we sent to a given destination + home server, return a list of PDUs that were sent to that destination + after it. + + Args: + txn + transaction_id (str) + destination (str) + + Returns + list: A list of PduTuple + """ + return self._db_pool.runInteraction( + self._get_pdus_after_transaction, + transaction_id, destination + ) + + def _get_pdus_after_transaction(self, txn, transaction_id, destination): + + # Query that first get's all transaction_ids with an id greater than + # the one given from the `sent_transactions` table. Then JOIN on this + # from the `tx->pdu` table to get a list of (pdu_id, origin) that + # specify the pdus that were sent in those transactions. + query = ( + "SELECT pdu_id, pdu_origin FROM %(tx_pdu)s as tp " + "INNER JOIN %(sent_tx)s as st " + "ON tp.transaction_id = st.transaction_id " + "AND tp.destination = st.destination " + "WHERE st.id > (" + "SELECT id FROM %(sent_tx)s " + "WHERE transaction_id = ? AND destination = ?" + ) % { + "tx_pdu": TransactionsToPduTable.table_name, + "sent_tx": SentTransactions.table_name, + } + + txn.execute(query, (transaction_id, destination)) + + pdus = PdusTable.decode_results(txn.fetchall()) + + return self._get_pdu_tuples(txn, pdus) + + +class ReceivedTransactionsTable(Table): + table_name = "received_transactions" + + fields = [ + "transaction_id", + "origin", + "ts", + "response_code", + "response_json", + "has_been_referenced", + ] + + EntryType = namedtuple("ReceivedTransactionsEntry", fields) + + +class SentTransactions(Table): + table_name = "sent_transactions" + + fields = [ + "id", + "transaction_id", + "destination", + "ts", + "response_code", + "response_json", + ] + + EntryType = namedtuple("SentTransactionsEntry", fields) + + +class TransactionsToPduTable(Table): + table_name = "transaction_id_to_pdu" + + fields = [ + "transaction_id", + "destination", + "pdu_id", + "pdu_origin", + ] + + EntryType = namedtuple("TransactionsToPduEntry", fields) diff --git a/synapse/types.py b/synapse/types.py new file mode 100644 index 0000000000..1adc95bbb0 --- /dev/null +++ b/synapse/types.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from synapse.api.errors import SynapseError + +from collections import namedtuple + + +class DomainSpecificString( + namedtuple("DomainSpecificString", ("localpart", "domain", "is_mine")) +): + """Common base class among ID/name strings that have a local part and a + domain name, prefixed with a sigil. + + Has the fields: + + 'localpart' : The local part of the name (without the leading sigil) + 'domain' : The domain part of the name + 'is_mine' : Boolean indicating if the domain name is recognised by the + HomeServer as being its own + """ + + @classmethod + def from_string(cls, s, hs): + """Parse the string given by 's' into a structure object.""" + if s[0] != cls.SIGIL: + raise SynapseError(400, "Expected %s string to start with '%s'" % ( + cls.__name__, cls.SIGIL, + )) + + parts = s[1:].split(':', 1) + if len(parts) != 2: + raise SynapseError( + 400, "Expected %s of the form '%slocalname:domain'" % ( + cls.__name__, cls.SIGIL, + ) + ) + + domain = parts[1] + + # This code will need changing if we want to support multiple domain + # names on one HS + is_mine = domain == hs.hostname + return cls(localpart=parts[0], domain=domain, is_mine=is_mine) + + def to_string(self): + """Return a string encoding the fields of the structure object.""" + return "%s%s:%s" % (self.SIGIL, self.localpart, self.domain) + + @classmethod + def create_local(cls, localpart, hs): + """Create a structure on the local domain""" + return cls(localpart=localpart, domain=hs.hostname, is_mine=True) + + +class UserID(DomainSpecificString): + """Structure representing a user ID.""" + SIGIL = "@" + + +class RoomAlias(DomainSpecificString): + """Structure representing a room name.""" + SIGIL = "#" + + +class RoomID(DomainSpecificString): + """Structure representing a room id. """ + SIGIL = "!" diff --git a/synapse/util/__init__.py b/synapse/util/__init__.py new file mode 100644 index 0000000000..5361cb7ec2 --- /dev/null +++ b/synapse/util/__init__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import reactor + +import time + + +class Clock(object): + """A small utility that obtains current time-of-day so that time may be + mocked during unit-tests. + + TODO(paul): Also move the sleep() functionallity into it + """ + + def time(self): + """Returns the current system time in seconds since epoch.""" + return time.time() + + def time_msec(self): + """Returns the current system time in miliseconds since epoch.""" + return self.time() * 1000 + + def call_later(self, delay, callback): + return reactor.callLater(delay, callback) + + def cancel_call_later(self, timer): + timer.cancel() diff --git a/synapse/util/async.py b/synapse/util/async.py new file mode 100644 index 0000000000..e04db8e285 --- /dev/null +++ b/synapse/util/async.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer, reactor + + +def sleep(seconds): + d = defer.Deferred() + reactor.callLater(seconds, d.callback, seconds) + return d diff --git a/synapse/util/distributor.py b/synapse/util/distributor.py new file mode 100644 index 0000000000..32d19402b4 --- /dev/null +++ b/synapse/util/distributor.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from twisted.internet import defer + +import logging + + +logger = logging.getLogger(__name__) + + +class Distributor(object): + """A central dispatch point for loosely-connected pieces of code to + register, observe, and fire signals. + + Signals are named simply by strings. + + TODO(paul): It would be nice to give signals stronger object identities, + so we can attach metadata, docstrings, detect typoes, etc... But this + model will do for today. + """ + + def __init__(self): + self.signals = {} + self.pre_registration = {} + + def declare(self, name): + if name in self.signals: + raise KeyError("%r already has a signal named %s" % (self, name)) + + self.signals[name] = Signal(name) + + if name in self.pre_registration: + signal = self.signals[name] + for observer in self.pre_registration[name]: + signal.observe(observer) + + def observe(self, name, observer): + if name in self.signals: + self.signals[name].observe(observer) + else: + # TODO: Avoid strong ordering dependency by allowing people to + # pre-register observations on signals that don't exist yet. + if name not in self.pre_registration: + self.pre_registration[name] = [] + self.pre_registration[name].append(observer) + + def fire(self, name, *args, **kwargs): + if name not in self.signals: + raise KeyError("%r does not have a signal named %s" % (self, name)) + + return self.signals[name].fire(*args, **kwargs) + + +class Signal(object): + """A Signal is a dispatch point that stores a list of callables as + observers of it. + + Signals can be "fired", meaning that every callable observing it is + invoked. Firing a signal does not change its state; it can be fired again + at any later point. Firing a signal passes any arguments from the fire + method into all of the observers. + """ + + def __init__(self, name): + self.name = name + self.observers = [] + + def observe(self, observer): + """Adds a new callable to the observer list which will be invoked by + the 'fire' method. + + Each observer callable may return a Deferred.""" + self.observers.append(observer) + + def fire(self, *args, **kwargs): + """Invokes every callable in the observer list, passing in the args and + kwargs. Exceptions thrown by observers are logged but ignored. It is + not an error to fire a signal with no observers. + + Returns a Deferred that will complete when all the observers have + completed.""" + deferreds = [] + for observer in self.observers: + d = defer.maybeDeferred(observer, *args, **kwargs) + + def eb(failure): + logger.warning( + "%s signal observer %s failed: %r", + self.name, observer, failure, + exc_info=( + failure.type, + failure.value, + failure.getTracebackObject())) + deferreds.append(d.addErrback(eb)) + + return defer.DeferredList(deferreds) diff --git a/synapse/util/jsonobject.py b/synapse/util/jsonobject.py new file mode 100644 index 0000000000..190a80a322 --- /dev/null +++ b/synapse/util/jsonobject.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy + +class JsonEncodedObject(object): + """ A common base class for defining protocol units that are represented + as JSON. + + Attributes: + unrecognized_keys (dict): A dict containing all the key/value pairs we + don't recognize. + """ + + valid_keys = [] # keys we will store + """A list of strings that represent keys we know about + and can handle. If we have values for these keys they will be + included in the `dictionary` instance variable. + """ + + internal_keys = [] # keys to ignore while building dict + """A list of strings that should *not* be encoded into JSON. + """ + + required_keys = [] + """A list of strings that we require to exist. If they are not given upon + construction it raises an exception. + """ + + def __init__(self, **kwargs): + """ Takes the dict of `kwargs` and loads all keys that are *valid* + (i.e., are included in the `valid_keys` list) into the dictionary` + instance variable. + + Any keys that aren't recognized are added to the `unrecognized_keys` + attribute. + + Args: + **kwargs: Attributes associated with this protocol unit. + """ + for required_key in self.required_keys: + if required_key not in kwargs: + raise RuntimeError("Key %s is required" % required_key) + + self.unrecognized_keys = {} # Keys we were given not listed as valid + for k, v in kwargs.items(): + if k in self.valid_keys or k in self.internal_keys: + self.__dict__[k] = v + else: + self.unrecognized_keys[k] = v + + def get_dict(self): + """ Converts this protocol unit into a :py:class:`dict`, ready to be + encoded as JSON. + + The keys it encodes are: `valid_keys` - `internal_keys` + + Returns + dict + """ + d = { + k: _encode(v) for (k, v) in self.__dict__.items() + if k in self.valid_keys and k not in self.internal_keys + } + d.update(self.unrecognized_keys) + return copy.deepcopy(d) + + def get_full_dict(self): + d = { + k: v for (k, v) in self.__dict__.items() + if k in self.valid_keys or k in self.internal_keys + } + d.update(self.unrecognized_keys) + return copy.deepcopy(d) + + def __str__(self): + return "(%s, %s)" % (self.__class__.__name__, repr(self.__dict__)) + +def _encode(obj): + if type(obj) is list: + return [_encode(o) for o in obj] + + if isinstance(obj, JsonEncodedObject): + return obj.get_dict() + + return obj diff --git a/synapse/util/lockutils.py b/synapse/util/lockutils.py new file mode 100644 index 0000000000..e4d609d84e --- /dev/null +++ b/synapse/util/lockutils.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer + +import logging + + +logger = logging.getLogger(__name__) + + +class Lock(object): + + def __init__(self, deferred): + self._deferred = deferred + self.released = False + + def release(self): + self.released = True + self._deferred.callback(None) + + def __del__(self): + if not self.released: + logger.critical("Lock was destructed but never released!") + self.release() + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.release() + + +class LockManager(object): + """ Utility class that allows us to lock based on a `key` """ + + def __init__(self): + self._lock_deferreds = {} + + @defer.inlineCallbacks + def lock(self, key): + """ Allows us to block until it is our turn. + Args: + key (str) + Returns: + Lock + """ + new_deferred = defer.Deferred() + old_deferred = self._lock_deferreds.get(key) + self._lock_deferreds[key] = new_deferred + + if old_deferred: + yield old_deferred + + defer.returnValue(Lock(new_deferred)) diff --git a/synapse/util/logutils.py b/synapse/util/logutils.py new file mode 100644 index 0000000000..08d5aafca4 --- /dev/null +++ b/synapse/util/logutils.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from inspect import getcallargs + +import logging + + +def log_function(f): + """ Function decorator that logs every call to that function. + """ + func_name = f.__name__ + lineno = f.func_code.co_firstlineno + pathname = f.func_code.co_filename + + def wrapped(*args, **kwargs): + name = f.__module__ + logger = logging.getLogger(name) + level = logging.DEBUG + + if logger.isEnabledFor(level): + bound_args = getcallargs(f, *args, **kwargs) + + def format(value): + r = str(value) + if len(r) > 50: + r = r[:50] + "..." + return r + + func_args = [ + "%s=%s" % (k, format(v)) for k, v in bound_args.items() + ] + + msg_args = { + "func_name": func_name, + "args": ", ".join(func_args) + } + + record = logging.LogRecord( + name=name, + level=level, + pathname=pathname, + lineno=lineno, + msg="Invoked '%(func_name)s' with args: %(args)s", + args=msg_args, + exc_info=None + ) + + logger.handle(record) + + return f(*args, **kwargs) + + return wrapped diff --git a/synapse/util/stringutils.py b/synapse/util/stringutils.py new file mode 100644 index 0000000000..91550583a4 --- /dev/null +++ b/synapse/util/stringutils.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import random +import string + + +def origin_from_ucid(ucid): + return ucid.split("@", 1)[1] + + +def random_string(length): + return ''.join(random.choice(string.ascii_letters) for _ in xrange(length)) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/events/__init__.py b/tests/events/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/tests/events/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/events/test_events.py b/tests/events/test_events.py new file mode 100644 index 0000000000..11d3d09c96 --- /dev/null +++ b/tests/events/test_events.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +from synapse.api.events import SynapseEvent + +import unittest + + +class SynapseTemplateCheckTestCase(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_top_level_keys(self): + template = { + "person": {}, + "friends": ["string"] + } + + content = { + "person": {"name": "bob"}, + "friends": ["jill", "mike"] + } + + event = MockSynapseEvent(template) + self.assertTrue(event.check_json(content, raises=False)) + + content = { + "person": {"name": "bob"}, + "friends": ["jill"], + "enemies": ["mike"] + } + event = MockSynapseEvent(template) + self.assertTrue(event.check_json(content, raises=False)) + + content = { + "person": {"name": "bob"}, + # missing friends + "enemies": ["mike", "jill"] + } + self.assertFalse(event.check_json(content, raises=False)) + + def test_lists(self): + template = { + "person": {}, + "friends": [{"name":"string"}] + } + + content = { + "person": {"name": "bob"}, + "friends": ["jill", "mike"] # should be in objects + } + + event = MockSynapseEvent(template) + self.assertFalse(event.check_json(content, raises=False)) + + content = { + "person": {"name": "bob"}, + "friends": [{"name": "jill"}, {"name": "mike"}] + } + self.assertTrue(event.check_json(content, raises=False)) + + def test_nested_lists(self): + template = { + "results": { + "families": [ + { + "name": "string", + "members": [ + {} + ] + } + ] + } + } + + content = { + "results": { + "families": [ + { + "name": "Smith", + "members": [ + "Alice", "Bob" # wrong types + ] + } + ] + } + } + + event = MockSynapseEvent(template) + self.assertFalse(event.check_json(content, raises=False)) + + content = { + "results": { + "families": [ + { + "name": "Smith", + "members": [ + {"name": "Alice"}, {"name": "Bob"} + ] + } + ] + } + } + self.assertTrue(event.check_json(content, raises=False)) + + def test_nested_keys(self): + template = { + "person": { + "attributes": { + "hair": "string", + "eye": "string" + }, + "age": 0, + "fav_books": ["string"] + } + } + event = MockSynapseEvent(template) + + content = { + "person": { + "attributes": { + "hair": "brown", + "eye": "green", + "skin": "purple" + }, + "age": 33, + "fav_books": ["lotr", "hobbit"], + "fav_music": ["abba", "beatles"] + } + } + + self.assertTrue(event.check_json(content, raises=False)) + + content = { + "person": { + "attributes": { + "hair": "brown" + # missing eye + }, + "age": 33, + "fav_books": ["lotr", "hobbit"], + "fav_music": ["abba", "beatles"] + } + } + + self.assertFalse(event.check_json(content, raises=False)) + + content = { + "person": { + "attributes": { + "hair": "brown", + "eye": "green", + "skin": "purple" + }, + "age": 33, + "fav_books": "nothing", # should be a list + } + } + + self.assertFalse(event.check_json(content, raises=False)) + + +class MockSynapseEvent(SynapseEvent): + + def __init__(self, template): + self.template = template + + def get_content_template(self): + return self.template + diff --git a/tests/federation/__init__.py b/tests/federation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/federation/test_federation.py b/tests/federation/test_federation.py new file mode 100644 index 0000000000..1792f9de56 --- /dev/null +++ b/tests/federation/test_federation.py @@ -0,0 +1,240 @@ +# trial imports +from twisted.internet import defer +from twisted.trial import unittest + +# python imports +from mock import Mock +import logging + +from ..utils import MockHttpServer + +from synapse.server import HomeServer +from synapse.federation import initialize_http_replication +from synapse.federation.units import Pdu +from synapse.storage.pdu import PduTuple, PduEntry + + +logging.getLogger().addHandler(logging.NullHandler()) + + +def make_pdu(prev_pdus=[], **kwargs): + """Provide some default fields for making a PduTuple.""" + pdu_fields = { + "is_state": False, + "unrecognized_keys": [], + "outlier": False, + "have_processed": True, + "state_key": None, + "power_level": None, + "prev_state_id": None, + "prev_state_origin": None, + } + pdu_fields.update(kwargs) + + return PduTuple(PduEntry(**pdu_fields), prev_pdus) + + +class MockClock(object): + now = 1000 + + def time(self): + return self.now + + def time_msec(self): + return self.time() * 1000 + + +class FederationTestCase(unittest.TestCase): + def setUp(self): + self.mock_http_server = MockHttpServer() + self.mock_http_client = Mock(spec=[ + "put_json", + ]) + self.mock_persistence = Mock(spec=[ + "get_current_state_for_context", + "get_pdu", + "persist_pdu", + "update_min_depth_for_context", + "prep_send_transaction", + "delivered_txn", + "get_received_txn_response", + "set_received_txn_response", + ]) + self.mock_persistence.get_received_txn_response.return_value = ( + defer.succeed(None) + ) + self.clock = MockClock() + hs = HomeServer("test", + http_server=self.mock_http_server, + http_client=self.mock_http_client, + db_pool=None, + datastore=self.mock_persistence, + clock=self.clock, + ) + self.federation = initialize_http_replication(hs) + self.distributor = hs.get_distributor() + + @defer.inlineCallbacks + def test_get_state(self): + self.mock_persistence.get_current_state_for_context.return_value = ( + defer.succeed([]) + ) + + # Empty context initially + (code, response) = yield self.mock_http_server.trigger("GET", + "/state/my-context/", None) + self.assertEquals(200, code) + self.assertFalse(response["pdus"]) + + # Now lets give the context some state + self.mock_persistence.get_current_state_for_context.return_value = ( + defer.succeed([ + make_pdu( + pdu_id="the-pdu-id", + origin="red", + context="my-context", + pdu_type="m.topic", + ts=123456789000, + depth=1, + is_state=True, + content_json='{"topic":"The topic"}', + state_key="", + power_level=1000, + prev_state_id="last-pdu-id", + prev_state_origin="blue", + ), + ]) + ) + + (code, response) = yield self.mock_http_server.trigger("GET", + "/state/my-context/", None) + self.assertEquals(200, code) + self.assertEquals(1, len(response["pdus"])) + + @defer.inlineCallbacks + def test_get_pdu(self): + self.mock_persistence.get_pdu.return_value = ( + defer.succeed(None) + ) + + (code, response) = yield self.mock_http_server.trigger("GET", + "/pdu/red/abc123def456/", None) + self.assertEquals(404, code) + + # Now insert such a PDU + self.mock_persistence.get_pdu.return_value = ( + defer.succeed( + make_pdu( + pdu_id="abc123def456", + origin="red", + context="my-context", + pdu_type="m.text", + ts=123456789001, + depth=1, + content_json='{"text":"Here is the message"}', + ) + ) + ) + + (code, response) = yield self.mock_http_server.trigger("GET", + "/pdu/red/abc123def456/", None) + self.assertEquals(200, code) + self.assertEquals(1, len(response["pdus"])) + self.assertEquals("m.text", response["pdus"][0]["pdu_type"]) + + @defer.inlineCallbacks + def test_send_pdu(self): + self.mock_http_client.put_json.return_value = defer.succeed( + (200, "OK") + ) + + pdu = Pdu( + pdu_id="abc123def456", + origin="red", + destinations=["remote"], + context="my-context", + ts=123456789002, + pdu_type="m.test", + content={"testing": "content here"}, + depth=1, + ) + + yield self.federation.send_pdu(pdu) + + self.mock_http_client.put_json.assert_called_with( + "remote", + path="/send/1000000/", + data={ + "ts": 1000000, + "origin": "test", + "pdus": [ + { + "origin": "red", + "pdu_id": "abc123def456", + "prev_pdus": [], + "ts": 123456789002, + "context": "my-context", + "pdu_type": "m.test", + "is_state": False, + "content": {"testing": "content here"}, + "depth": 1, + }, + ] + } + ) + + @defer.inlineCallbacks + def test_send_edu(self): + self.mock_http_client.put_json.return_value = defer.succeed( + (200, "OK") + ) + + yield self.federation.send_edu( + destination="remote", + edu_type="m.test", + content={"testing": "content here"}, + ) + + # MockClock ensures we can guess these timestamps + self.mock_http_client.put_json.assert_called_with( + "remote", + path="/send/1000000/", + data={ + "origin": "test", + "ts": 1000000, + "pdus": [], + "edus": [ + { + "origin": "test", + "destination": "remote", + "edu_type": "m.test", + "content": {"testing": "content here"}, + } + ], + }) + + @defer.inlineCallbacks + def test_recv_edu(self): + recv_observer = Mock() + recv_observer.return_value = defer.succeed(()) + + self.federation.register_edu_handler("m.test", recv_observer) + + yield self.mock_http_server.trigger("PUT", "/send/1001000/", + """{ + "origin": "remote", + "ts": 1001000, + "pdus": [], + "edus": [ + { + "origin": "remote", + "destination": "test", + "edu_type": "m.test", + "content": {"testing": "reply here"} + } + ] + }""") + + recv_observer.assert_called_with( + "remote", {"testing": "reply here"} + ) diff --git a/tests/federation/test_pdu_codec.py b/tests/federation/test_pdu_codec.py new file mode 100644 index 0000000000..688182fa5b --- /dev/null +++ b/tests/federation/test_pdu_codec.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +from twisted.trial import unittest + +from synapse.federation.pdu_codec import ( + PduCodec, encode_event_id, decode_event_id +) +from synapse.federation.units import Pdu +#from synapse.api.events.room import MessageEvent + +from synapse.server import HomeServer + +from mock import Mock + + +class PduCodecTestCase(unittest.TestCase): + def setUp(self): + self.hs = HomeServer("blargle.net") + self.event_factory = self.hs.get_event_factory() + + self.codec = PduCodec(self.hs) + + def test_decode_event_id(self): + self.assertEquals( + ("foo", "bar.com"), + decode_event_id("foo@bar.com", "A") + ) + + self.assertEquals( + ("foo", "bar.com"), + decode_event_id("foo", "bar.com") + ) + + def test_encode_event_id(self): + self.assertEquals("A@B", encode_event_id("A", "B")) + + def test_codec_event_id(self): + event_id = "aa@bb.com" + + self.assertEquals( + event_id, + encode_event_id(*decode_event_id(event_id, None)) + ) + + pdu_id = ("aa", "bb.com") + + self.assertEquals( + pdu_id, + decode_event_id(encode_event_id(*pdu_id), None) + ) + + def test_event_from_pdu(self): + pdu = Pdu( + pdu_id="foo", + context="rooooom", + pdu_type="m.room.message", + origin="bar.com", + ts=12345, + depth=5, + prev_pdus=[("alice", "bob.com")], + is_state=False, + content={"msgtype": u"test"}, + ) + + event = self.codec.event_from_pdu(pdu) + + self.assertEquals("foo@bar.com", event.event_id) + self.assertEquals(pdu.context, event.room_id) + self.assertEquals(pdu.is_state, event.is_state) + self.assertEquals(pdu.depth, event.depth) + self.assertEquals(["alice@bob.com"], event.prev_events) + self.assertEquals(pdu.content, event.content) + + def test_pdu_from_event(self): + event = self.event_factory.create_event( + etype="m.room.message", + event_id="gargh_id", + room_id="rooom", + user_id="sender", + content={"msgtype": u"test"}, + ) + + pdu = self.codec.pdu_from_event(event) + + self.assertEquals(event.event_id, pdu.pdu_id) + self.assertEquals(self.hs.hostname, pdu.origin) + self.assertEquals(event.room_id, pdu.context) + self.assertEquals(event.content, pdu.content) + self.assertEquals(event.type, pdu.pdu_type) + + event = self.event_factory.create_event( + etype="m.room.message", + event_id="gargh_id@bob.com", + room_id="rooom", + user_id="sender", + content={"msgtype": u"test"}, + ) + + pdu = self.codec.pdu_from_event(event) + + self.assertEquals("gargh_id", pdu.pdu_id) + self.assertEquals("bob.com", pdu.origin) + self.assertEquals(event.room_id, pdu.context) + self.assertEquals(event.content, pdu.content) + self.assertEquals(event.type, pdu.pdu_type) + + def test_event_from_state_pdu(self): + pdu = Pdu( + pdu_id="foo", + context="rooooom", + pdu_type="m.room.topic", + origin="bar.com", + ts=12345, + depth=5, + prev_pdus=[("alice", "bob.com")], + is_state=True, + content={"topic": u"test"}, + state_key="", + ) + + event = self.codec.event_from_pdu(pdu) + + self.assertEquals("foo@bar.com", event.event_id) + self.assertEquals(pdu.context, event.room_id) + self.assertEquals(pdu.is_state, event.is_state) + self.assertEquals(pdu.depth, event.depth) + self.assertEquals(["alice@bob.com"], event.prev_events) + self.assertEquals(pdu.content, event.content) + self.assertEquals(pdu.state_key, event.state_key) + + def test_pdu_from_state_event(self): + event = self.event_factory.create_event( + etype="m.room.topic", + event_id="gargh_id", + room_id="rooom", + user_id="sender", + content={"topic": u"test"}, + ) + + pdu = self.codec.pdu_from_event(event) + + self.assertEquals(event.event_id, pdu.pdu_id) + self.assertEquals(self.hs.hostname, pdu.origin) + self.assertEquals(event.room_id, pdu.context) + self.assertEquals(event.content, pdu.content) + self.assertEquals(event.type, pdu.pdu_type) + self.assertEquals(event.state_key, pdu.state_key) diff --git a/tests/handlers/__init__.py b/tests/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py new file mode 100644 index 0000000000..880cfb47b8 --- /dev/null +++ b/tests/handlers/test_federation.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- + +from twisted.internet import defer +from twisted.trial import unittest + +from synapse.api.events.room import ( + InviteJoinEvent, MessageEvent, RoomMemberEvent +) +from synapse.api.constants import Membership +from synapse.handlers.federation import FederationHandler +from synapse.server import HomeServer + +from mock import NonCallableMock + +import logging + +logging.getLogger().addHandler(logging.NullHandler()) + + +class FederationTestCase(unittest.TestCase): + + def setUp(self): + self.hostname = "test" + hs = HomeServer( + self.hostname, + db_pool=None, + datastore=NonCallableMock(spec_set=[ + "persist_event", + "store_room", + ]), + http_server=NonCallableMock(), + http_client=NonCallableMock(spec_set=[]), + notifier=NonCallableMock(spec_set=["on_new_room_event"]), + handlers=NonCallableMock(spec_set=[ + "room_member_handler", + "federation_handler", + ]), + ) + + self.datastore = hs.get_datastore() + self.handlers = hs.get_handlers() + self.notifier = hs.get_notifier() + self.hs = hs + + self.handlers.federation_handler = FederationHandler(self.hs) + + @defer.inlineCallbacks + def test_msg(self): + event = self.hs.get_event_factory().create_event( + etype=MessageEvent.TYPE, + msg_id="bob", + room_id="foo", + content={"msgtype": u"fooo"}, + ) + + store_id = "ASD" + self.datastore.persist_event.return_value = defer.succeed(store_id) + + yield self.handlers.federation_handler.on_receive(event, False) + + self.datastore.persist_event.assert_called_once_with(event) + self.notifier.on_new_room_event.assert_called_once_with( + event, store_id) + + @defer.inlineCallbacks + def test_invite_join_target_this(self): + room_id = "foo" + user_id = "@bob:red" + + event = self.hs.get_event_factory().create_event( + etype=InviteJoinEvent.TYPE, + user_id=user_id, + target_host=self.hostname, + room_id=room_id, + content={}, + ) + + yield self.handlers.federation_handler.on_receive(event, False) + + mem_handler = self.handlers.room_member_handler + self.assertEquals(1, mem_handler.change_membership.call_count) + self.assertEquals(True, mem_handler.change_membership.call_args[0][1]) + + new_event = mem_handler.change_membership.call_args[0][0] + self.assertEquals(RoomMemberEvent.TYPE, new_event.type) + self.assertEquals(room_id, new_event.room_id) + self.assertEquals(user_id, new_event.target_user_id) + self.assertEquals(user_id, new_event.state_key) + self.assertEquals(Membership.JOIN, new_event.membership) + + @defer.inlineCallbacks + def test_invite_join_target_other(self): + room_id = "foo" + user_id = "@bob:red" + + event = self.hs.get_event_factory().create_event( + etype=InviteJoinEvent.TYPE, + user_id=user_id, + target_user_id="@red:not%s" % self.hostname, + room_id=room_id, + content={}, + ) + + yield self.handlers.federation_handler.on_receive(event, False) + + mem_handler = self.handlers.room_member_handler + self.assertEquals(0, mem_handler.change_membership.call_count) diff --git a/tests/handlers/test_presence.py b/tests/handlers/test_presence.py new file mode 100644 index 0000000000..e814357520 --- /dev/null +++ b/tests/handlers/test_presence.py @@ -0,0 +1,884 @@ +# -*- coding: utf-8 -*- + +from twisted.trial import unittest +from twisted.internet import defer + +from mock import Mock, call, ANY +import logging + +from synapse.server import HomeServer +from synapse.api.constants import PresenceState +from synapse.api.errors import SynapseError +from synapse.handlers.presence import PresenceHandler, UserPresenceCache + + +OFFLINE = PresenceState.OFFLINE +BUSY = PresenceState.BUSY +ONLINE = PresenceState.ONLINE + + +logging.getLogger().addHandler(logging.NullHandler()) + + +class MockReplication(object): + def __init__(self): + self.edu_handlers = {} + + def register_edu_handler(self, edu_type, handler): + self.edu_handlers[edu_type] = handler + + def received_edu(self, origin, edu_type, content): + self.edu_handlers[edu_type](origin, content) + + +class JustPresenceHandlers(object): + def __init__(self, hs): + self.presence_handler = PresenceHandler(hs) + + +class PresenceStateTestCase(unittest.TestCase): + """ Tests presence management. """ + + def setUp(self): + hs = HomeServer("test", + db_pool=None, + datastore=Mock(spec=[ + "get_presence_state", + "set_presence_state", + "add_presence_list_pending", + "set_presence_list_accepted", + ]), + handlers=None, + http_server=Mock(), + http_client=None, + ) + hs.handlers = JustPresenceHandlers(hs) + + self.datastore = hs.get_datastore() + + def is_presence_visible(observed_localpart, observer_userid): + allow = (observed_localpart == "apple" and + observer_userid == "@banana:test" + ) + return defer.succeed(allow) + self.datastore.is_presence_visible = is_presence_visible + + # Some local users to test with + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + self.u_clementine = hs.parse_userid("@clementine:test") + + self.handler = hs.get_handlers().presence_handler + + hs.handlers.room_member_handler = Mock(spec=[ + "get_rooms_for_user", + ]) + hs.handlers.room_member_handler.get_rooms_for_user = ( + lambda u: defer.succeed([])) + + self.mock_start = Mock() + self.mock_stop = Mock() + + self.handler.start_polling_presence = self.mock_start + self.handler.stop_polling_presence = self.mock_stop + + @defer.inlineCallbacks + def test_get_my_state(self): + mocked_get = self.datastore.get_presence_state + mocked_get.return_value = defer.succeed( + {"state": ONLINE, "status_msg": "Online"} + ) + + state = yield self.handler.get_state( + target_user=self.u_apple, auth_user=self.u_apple + ) + + self.assertEquals({"state": ONLINE, "status_msg": "Online"}, + state + ) + mocked_get.assert_called_with("apple") + + @defer.inlineCallbacks + def test_get_allowed_state(self): + mocked_get = self.datastore.get_presence_state + mocked_get.return_value = defer.succeed( + {"state": ONLINE, "status_msg": "Online"} + ) + + state = yield self.handler.get_state( + target_user=self.u_apple, auth_user=self.u_banana + ) + + self.assertEquals({"state": ONLINE, "status_msg": "Online"}, + state + ) + mocked_get.assert_called_with("apple") + + @defer.inlineCallbacks + def test_get_disallowed_state(self): + mocked_get = self.datastore.get_presence_state + mocked_get.return_value = defer.succeed( + {"state": ONLINE, "status_msg": "Online"} + ) + + yield self.assertFailure( + self.handler.get_state( + target_user=self.u_apple, auth_user=self.u_clementine + ), + SynapseError + ) + + @defer.inlineCallbacks + def test_set_my_state(self): + mocked_set = self.datastore.set_presence_state + mocked_set.return_value = defer.succeed({"state": OFFLINE}) + + yield self.handler.set_state( + target_user=self.u_apple, auth_user=self.u_apple, + state={"state": BUSY, "status_msg": "Away"}) + + mocked_set.assert_called_with("apple", + {"state": 1, "status_msg": "Away"}) + self.mock_start.assert_called_with(self.u_apple, + state={"state": 1, "status_msg": "Away"}) + + yield self.handler.set_state( + target_user=self.u_apple, auth_user=self.u_apple, + state={"state": OFFLINE}) + + self.mock_stop.assert_called_with(self.u_apple) + + +class PresenceInvitesTestCase(unittest.TestCase): + """ Tests presence management. """ + + def setUp(self): + self.replication = MockReplication() + self.replication.send_edu = Mock() + + hs = HomeServer("test", + db_pool=None, + datastore=Mock(spec=[ + "has_presence_state", + "allow_presence_visible", + "add_presence_list_pending", + "set_presence_list_accepted", + "get_presence_list", + "del_presence_list", + ]), + handlers=None, + http_server=Mock(), + http_client=None, + replication_layer=self.replication + ) + hs.handlers = JustPresenceHandlers(hs) + + self.datastore = hs.get_datastore() + + def has_presence_state(user_localpart): + return defer.succeed( + user_localpart in ("apple", "banana")) + self.datastore.has_presence_state = has_presence_state + + # Some local users to test with + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + # ID of a local user that does not exist + self.u_durian = hs.parse_userid("@durian:test") + + # A remote user + self.u_cabbage = hs.parse_userid("@cabbage:elsewhere") + + self.handler = hs.get_handlers().presence_handler + + self.mock_start = Mock() + self.mock_stop = Mock() + + self.handler.start_polling_presence = self.mock_start + self.handler.stop_polling_presence = self.mock_stop + + @defer.inlineCallbacks + def test_invite_local(self): + # TODO(paul): This test will likely break if/when real auth permissions + # are added; for now the HS will always accept any invite + + yield self.handler.send_invite( + observer_user=self.u_apple, observed_user=self.u_banana) + + self.datastore.add_presence_list_pending.assert_called_with( + "apple", "@banana:test") + self.datastore.allow_presence_visible.assert_called_with( + "banana", "@apple:test") + self.datastore.set_presence_list_accepted.assert_called_with( + "apple", "@banana:test") + + self.mock_start.assert_called_with( + self.u_apple, target_user=self.u_banana) + + @defer.inlineCallbacks + def test_invite_local_nonexistant(self): + yield self.handler.send_invite( + observer_user=self.u_apple, observed_user=self.u_durian) + + self.datastore.add_presence_list_pending.assert_called_with( + "apple", "@durian:test") + self.datastore.del_presence_list.assert_called_with( + "apple", "@durian:test") + + @defer.inlineCallbacks + def test_invite_remote(self): + self.replication.send_edu.return_value = defer.succeed((200, "OK")) + + yield self.handler.send_invite( + observer_user=self.u_apple, observed_user=self.u_cabbage) + + self.datastore.add_presence_list_pending.assert_called_with( + "apple", "@cabbage:elsewhere") + + self.replication.send_edu.assert_called_with( + destination="elsewhere", + edu_type="m.presence_invite", + content={ + "observer_user": "@apple:test", + "observed_user": "@cabbage:elsewhere", + } + ) + + @defer.inlineCallbacks + def test_accept_remote(self): + # TODO(paul): This test will likely break if/when real auth permissions + # are added; for now the HS will always accept any invite + self.replication.send_edu.return_value = defer.succeed((200, "OK")) + + yield self.replication.received_edu( + "elsewhere", "m.presence_invite", { + "observer_user": "@cabbage:elsewhere", + "observed_user": "@apple:test", + } + ) + + self.datastore.allow_presence_visible.assert_called_with( + "apple", "@cabbage:elsewhere") + + self.replication.send_edu.assert_called_with( + destination="elsewhere", + edu_type="m.presence_accept", + content={ + "observer_user": "@cabbage:elsewhere", + "observed_user": "@apple:test", + } + ) + + @defer.inlineCallbacks + def test_invited_remote_nonexistant(self): + self.replication.send_edu.return_value = defer.succeed((200, "OK")) + + yield self.replication.received_edu( + "elsewhere", "m.presence_invite", { + "observer_user": "@cabbage:elsewhere", + "observed_user": "@durian:test", + } + ) + + self.replication.send_edu.assert_called_with( + destination="elsewhere", + edu_type="m.presence_deny", + content={ + "observer_user": "@cabbage:elsewhere", + "observed_user": "@durian:test", + } + ) + + @defer.inlineCallbacks + def test_accepted_remote(self): + yield self.replication.received_edu( + "elsewhere", "m.presence_accept", { + "observer_user": "@apple:test", + "observed_user": "@cabbage:elsewhere", + } + ) + + self.datastore.set_presence_list_accepted.assert_called_with( + "apple", "@cabbage:elsewhere") + + self.mock_start.assert_called_with( + self.u_apple, target_user=self.u_cabbage) + + @defer.inlineCallbacks + def test_denied_remote(self): + yield self.replication.received_edu( + "elsewhere", "m.presence_deny", { + "observer_user": "@apple:test", + "observed_user": "@eggplant:elsewhere", + } + ) + + self.datastore.del_presence_list.assert_called_with( + "apple", "@eggplant:elsewhere") + + @defer.inlineCallbacks + def test_drop_local(self): + yield self.handler.drop( + observer_user=self.u_apple, observed_user=self.u_banana) + + self.datastore.del_presence_list.assert_called_with( + "apple", "@banana:test") + + self.mock_stop.assert_called_with( + self.u_apple, target_user=self.u_banana) + + @defer.inlineCallbacks + def test_get_presence_list(self): + self.datastore.get_presence_list.return_value = defer.succeed( + [{"observed_user_id": "@banana:test"}] + ) + + presence = yield self.handler.get_presence_list( + observer_user=self.u_apple) + + self.assertEquals([{"observed_user": self.u_banana, + "state": OFFLINE}], presence) + + self.datastore.get_presence_list.assert_called_with("apple", + accepted=None) + + + self.datastore.get_presence_list.return_value = defer.succeed( + [{"observed_user_id": "@banana:test"}] + ) + + presence = yield self.handler.get_presence_list( + observer_user=self.u_apple, accepted=True) + + self.assertEquals([{"observed_user": self.u_banana, + "state": OFFLINE}], presence) + + self.datastore.get_presence_list.assert_called_with("apple", + accepted=True) + + +class PresencePushTestCase(unittest.TestCase): + """ Tests steady-state presence status updates. + + They assert that presence state update messages are pushed around the place + when users change state, presuming that the watches are all established. + + These tests are MASSIVELY fragile currently as they poke internals of the + presence handler; namely the _local_pushmap and _remote_recvmap. + BE WARNED... + """ + def setUp(self): + self.replication = MockReplication() + self.replication.send_edu = Mock() + self.replication.send_edu.return_value = defer.succeed((200, "OK")) + + hs = HomeServer("test", + db_pool=None, + datastore=Mock(spec=[ + "set_presence_state", + ]), + handlers=None, + http_server=Mock(), + http_client=None, + replication_layer=self.replication, + ) + hs.handlers = JustPresenceHandlers(hs) + + self.mock_update_client = Mock() + self.mock_update_client.return_value = defer.succeed(None) + + self.datastore = hs.get_datastore() + self.handler = hs.get_handlers().presence_handler + self.handler.push_update_to_clients = self.mock_update_client + + # Mock the RoomMemberHandler + hs.handlers.room_member_handler = Mock(spec=[ + "get_rooms_for_user", + "get_room_members", + ]) + self.room_member_handler = hs.handlers.room_member_handler + + self.room_members = [] + + def get_rooms_for_user(user): + if user in self.room_members: + return defer.succeed(["a-room"]) + else: + return defer.succeed([]) + self.room_member_handler.get_rooms_for_user = get_rooms_for_user + + def get_room_members(room_id): + if room_id == "a-room": + return defer.succeed(self.room_members) + else: + return defer.succeed([]) + self.room_member_handler.get_room_members = get_room_members + + @defer.inlineCallbacks + def fetch_room_distributions_into(room_id, localusers=None, + remotedomains=None, ignore_user=None): + + members = yield get_room_members(room_id) + for member in members: + if ignore_user is not None and member == ignore_user: + continue + + if member.is_mine: + if localusers is not None: + localusers.add(member) + else: + if remotedomains is not None: + remotedomains.add(member.domain) + self.room_member_handler.fetch_room_distributions_into = ( + fetch_room_distributions_into) + + def get_presence_list(user_localpart, accepted=None): + if user_localpart == "apple": + return defer.succeed([ + {"observed_user_id": "@banana:test"}, + {"observed_user_id": "@clementine:test"}, + ]) + else: + return defer.succeed([]) + self.datastore.get_presence_list = get_presence_list + + def is_presence_visible(observer_userid, observed_localpart): + if (observed_localpart == "clementine" and + observer_userid == "@banana:test"): + return False + return False + self.datastore.is_presence_visible = is_presence_visible + + self.distributor = hs.get_distributor() + self.distributor.declare("user_joined_room") + + # Some local users to test with + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + self.u_clementine = hs.parse_userid("@clementine:test") + self.u_elderberry = hs.parse_userid("@elderberry:test") + + # Remote user + self.u_onion = hs.parse_userid("@onion:farm") + self.u_potato = hs.parse_userid("@potato:remote") + + @defer.inlineCallbacks + def test_push_local(self): + self.room_members = [self.u_apple, self.u_elderberry] + + self.datastore.set_presence_state.return_value = defer.succeed( + {"state": ONLINE}) + + # TODO(paul): Gut-wrenching + self.handler._user_cachemap[self.u_apple] = UserPresenceCache() + apple_set = self.handler._local_pushmap.setdefault("apple", set()) + apple_set.add(self.u_banana) + apple_set.add(self.u_clementine) + + yield self.handler.set_state(self.u_apple, self.u_apple, + {"state": ONLINE}) + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_apple, + observed_user=self.u_apple, + statuscache=ANY), # self-reflection + call(observer_user=self.u_banana, + observed_user=self.u_apple, + statuscache=ANY), + call(observer_user=self.u_clementine, + observed_user=self.u_apple, + statuscache=ANY), + call(observer_user=self.u_elderberry, + observed_user=self.u_apple, + statuscache=ANY), + ], any_order=True) + self.mock_update_client.reset_mock() + + presence = yield self.handler.get_presence_list( + observer_user=self.u_apple, accepted=True) + + self.assertEquals([ + {"observed_user": self.u_banana, "state": OFFLINE}, + {"observed_user": self.u_clementine, "state": OFFLINE}], + presence) + + yield self.handler.set_state(self.u_banana, self.u_banana, + {"state": ONLINE}) + + presence = yield self.handler.get_presence_list( + observer_user=self.u_apple, accepted=True) + + self.assertEquals([ + {"observed_user": self.u_banana, "state": ONLINE}, + {"observed_user": self.u_clementine, "state": OFFLINE}], + presence) + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_banana, + observed_user=self.u_banana, + statuscache=ANY), # self-reflection + ]) # and no others... + + @defer.inlineCallbacks + def test_push_remote(self): + self.room_members = [self.u_apple, self.u_onion] + + self.datastore.set_presence_state.return_value = defer.succeed( + {"state": ONLINE}) + + # TODO(paul): Gut-wrenching + self.handler._user_cachemap[self.u_apple] = UserPresenceCache() + apple_set = self.handler._remote_sendmap.setdefault("apple", set()) + apple_set.add(self.u_potato.domain) + + yield self.handler.set_state(self.u_apple, self.u_apple, + {"state": ONLINE}) + + self.replication.send_edu.assert_has_calls([ + call( + destination="remote", + edu_type="m.presence", + content={ + "push": [ + {"user_id": "@apple:test", + "state": 2}, + ], + }), + call( + destination="farm", + edu_type="m.presence", + content={ + "push": [ + {"user_id": "@apple:test", + "state": 2}, + ], + }) + ], any_order=True) + + @defer.inlineCallbacks + def test_recv_remote(self): + # TODO(paul): Gut-wrenching + potato_set = self.handler._remote_recvmap.setdefault(self.u_potato, + set()) + potato_set.add(self.u_apple) + + self.room_members = [self.u_banana, self.u_potato] + + yield self.replication.received_edu( + "remote", "m.presence", { + "push": [ + {"user_id": "@potato:remote", + "state": 2}, + ], + } + ) + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_apple, + observed_user=self.u_potato, + statuscache=ANY), + call(observer_user=self.u_banana, + observed_user=self.u_potato, + statuscache=ANY), + ], any_order=True) + + state = yield self.handler.get_state(self.u_potato, self.u_apple) + + self.assertEquals({"state": ONLINE}, state) + + @defer.inlineCallbacks + def test_join_room_local(self): + self.room_members = [self.u_apple, self.u_banana] + + yield self.distributor.fire("user_joined_room", self.u_elderberry, + "a-room" + ) + + self.mock_update_client.assert_has_calls([ + # Apple and Elderberry see each other + call(observer_user=self.u_apple, + observed_user=self.u_elderberry, + statuscache=ANY), + call(observer_user=self.u_elderberry, + observed_user=self.u_apple, + statuscache=ANY), + # Banana and Elderberry see each other + call(observer_user=self.u_banana, + observed_user=self.u_elderberry, + statuscache=ANY), + call(observer_user=self.u_elderberry, + observed_user=self.u_banana, + statuscache=ANY), + ], any_order=True) + + @defer.inlineCallbacks + def test_join_room_remote(self): + ## Sending local user state to a newly-joined remote user + + # TODO(paul): Gut-wrenching + self.handler._user_cachemap[self.u_apple] = UserPresenceCache() + self.handler._user_cachemap[self.u_apple].update( + {"state": PresenceState.ONLINE}, self.u_apple) + self.room_members = [self.u_apple, self.u_banana] + + yield self.distributor.fire("user_joined_room", self.u_potato, + "a-room" + ) + + self.replication.send_edu.assert_has_calls([ + call( + destination="remote", + edu_type="m.presence", + content={ + "push": [ + {"user_id": "@apple:test", + "state": 2}, + ], + }), + call( + destination="remote", + edu_type="m.presence", + content={ + "push": [ + {"user_id": "@banana:test", + "state": 0}, + ], + }), + ], any_order=True) + + self.replication.send_edu.reset_mock() + + ## Sending newly-joined local user state to remote users + + self.handler._user_cachemap[self.u_clementine] = UserPresenceCache() + self.handler._user_cachemap[self.u_clementine].update( + {"state": PresenceState.ONLINE}, self.u_clementine) + self.room_members.append(self.u_potato) + + yield self.distributor.fire("user_joined_room", self.u_clementine, + "a-room" + ) + + self.replication.send_edu.assert_has_calls( + call( + destination="remote", + edu_type="m.presence", + content={ + "push": [ + {"user_id": "@clementine:test", + "state": 2}, + ], + }), + ) + + +class PresencePollingTestCase(unittest.TestCase): + """ Tests presence status polling. """ + + # For this test, we have three local users; apple is watching and is + # watched by the other two, but the others don't watch each other. + # Additionally clementine is watching a remote user. + PRESENCE_LIST = { + 'apple': [ "@banana:test", "@clementine:test" ], + 'banana': [ "@apple:test" ], + 'clementine': [ "@apple:test", "@potato:remote" ], + } + + + def setUp(self): + self.replication = MockReplication() + self.replication.send_edu = Mock() + + hs = HomeServer("test", + db_pool=None, + datastore=Mock(spec=[]), + handlers=None, + http_server=Mock(), + http_client=None, + replication_layer=self.replication, + ) + hs.handlers = JustPresenceHandlers(hs) + + self.datastore = hs.get_datastore() + + self.mock_update_client = Mock() + self.mock_update_client.return_value = defer.succeed(None) + + self.handler = hs.get_handlers().presence_handler + self.handler.push_update_to_clients = self.mock_update_client + + hs.handlers.room_member_handler = Mock(spec=[ + "get_rooms_for_user", + ]) + # For this test no users are ever in rooms + def get_rooms_for_user(user): + return defer.succeed([]) + hs.handlers.room_member_handler.get_rooms_for_user = get_rooms_for_user + + # Mocked database state + # Local users always start offline + self.current_user_state = { + "apple": OFFLINE, + "banana": OFFLINE, + "clementine": OFFLINE, + } + + def get_presence_state(user_localpart): + return defer.succeed( + {"state": self.current_user_state[user_localpart], + "status_msg": None} + ) + self.datastore.get_presence_state = get_presence_state + + def set_presence_state(user_localpart, new_state): + was = self.current_user_state[user_localpart] + self.current_user_state[user_localpart] = new_state["state"] + return defer.succeed({"state": was}) + self.datastore.set_presence_state = set_presence_state + + def get_presence_list(user_localpart, accepted): + return defer.succeed([ + {"observed_user_id": u} for u in + self.PRESENCE_LIST[user_localpart]]) + self.datastore.get_presence_list = get_presence_list + + def is_presence_visible(observed_localpart, observer_userid): + return True + self.datastore.is_presence_visible = is_presence_visible + + # Local users + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + self.u_clementine = hs.parse_userid("@clementine:test") + + # Remote users + self.u_potato = hs.parse_userid("@potato:remote") + + @defer.inlineCallbacks + def test_push_local(self): + # apple goes online + yield self.handler.set_state( + target_user=self.u_apple, auth_user=self.u_apple, + state={"state": ONLINE}) + + # apple should see both banana and clementine currently offline + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_apple, + observed_user=self.u_banana, + statuscache=ANY), + call(observer_user=self.u_apple, + observed_user=self.u_clementine, + statuscache=ANY), + ], any_order=True) + + # Gut-wrenching tests + self.assertTrue("banana" in self.handler._local_pushmap) + self.assertTrue(self.u_apple in self.handler._local_pushmap["banana"]) + self.assertTrue("clementine" in self.handler._local_pushmap) + self.assertTrue(self.u_apple in self.handler._local_pushmap["clementine"]) + + self.mock_update_client.reset_mock() + + # banana goes online + yield self.handler.set_state( + target_user=self.u_banana, auth_user=self.u_banana, + state={"state": ONLINE}) + + # apple and banana should now both see each other online + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_apple, + observed_user=self.u_banana, + statuscache=ANY), + call(observer_user=self.u_banana, + observed_user=self.u_apple, + statuscache=ANY), + ], any_order=True) + + self.assertTrue("apple" in self.handler._local_pushmap) + self.assertTrue(self.u_banana in self.handler._local_pushmap["apple"]) + + self.mock_update_client.reset_mock() + + # apple goes offline + yield self.handler.set_state( + target_user=self.u_apple, auth_user=self.u_apple, + state={"state": OFFLINE}) + + # banana should now be told apple is offline + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_banana, + observed_user=self.u_apple, + statuscache=ANY), + ], any_order=True) + + self.assertFalse("banana" in self.handler._local_pushmap) + self.assertFalse("clementine" in self.handler._local_pushmap) + + @defer.inlineCallbacks + def test_remote_poll_send(self): + # clementine goes online + yield self.handler.set_state( + target_user=self.u_clementine, auth_user=self.u_clementine, + state={"state": ONLINE}) + + self.replication.send_edu.assert_called_with( + destination="remote", + edu_type="m.presence", + content={ + "poll": [ "@potato:remote" ], + }, + ) + + # Gut-wrenching tests + self.assertTrue(self.u_potato in self.handler._remote_recvmap) + self.assertTrue(self.u_clementine in + self.handler._remote_recvmap[self.u_potato]) + + self.replication.send_edu.reset_mock() + + # clementine goes offline + yield self.handler.set_state( + target_user=self.u_clementine, auth_user=self.u_clementine, + state={"state": OFFLINE}) + + self.replication.send_edu.assert_called_with( + destination="remote", + edu_type="m.presence", + content={ + "unpoll": [ "@potato:remote" ], + }, + ) + + self.assertFalse(self.u_potato in self.handler._remote_recvmap) + + @defer.inlineCallbacks + def test_remote_poll_receive(self): + yield self.replication.received_edu( + "remote", "m.presence", { + "poll": [ "@banana:test" ], + } + ) + + # Gut-wrenching tests + self.assertTrue(self.u_banana in self.handler._remote_sendmap) + + self.replication.send_edu.assert_called_with( + destination="remote", + edu_type="m.presence", + content={ + "push": [ + {"user_id": "@banana:test", + "state": 0, + "status_msg": None}, + ], + }, + ) + + yield self.replication.received_edu( + "remote", "m.presence", { + "unpoll": [ "@banana:test" ], + } + ) + + # Gut-wrenching tests + self.assertFalse(self.u_banana in self.handler._remote_sendmap) diff --git a/tests/handlers/test_presencelike.py b/tests/handlers/test_presencelike.py new file mode 100644 index 0000000000..c194e4dd72 --- /dev/null +++ b/tests/handlers/test_presencelike.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +"""This file contains tests of the "presence-like" data that is shared between +presence and profiles; namely, the displayname and avatar_url.""" + +from twisted.trial import unittest +from twisted.internet import defer + +from mock import Mock, call, ANY +import logging + +from synapse.server import HomeServer +from synapse.api.constants import PresenceState +from synapse.handlers.presence import PresenceHandler +from synapse.handlers.profile import ProfileHandler + + +OFFLINE = PresenceState.OFFLINE +BUSY = PresenceState.BUSY +ONLINE = PresenceState.ONLINE + + +logging.getLogger().addHandler(logging.NullHandler()) + + +class MockReplication(object): + def __init__(self): + self.edu_handlers = {} + + def register_edu_handler(self, edu_type, handler): + self.edu_handlers[edu_type] = handler + + def received_edu(self, origin, edu_type, content): + self.edu_handlers[edu_type](origin, content) + + +class PresenceAndProfileHandlers(object): + def __init__(self, hs): + self.presence_handler = PresenceHandler(hs) + self.profile_handler = ProfileHandler(hs) + + +class PresenceProfilelikeDataTestCase(unittest.TestCase): + + def setUp(self): + hs = HomeServer("test", + db_pool=None, + datastore=Mock(spec=[ + "set_presence_state", + + "set_profile_displayname", + ]), + handlers=None, + http_server=Mock(), + http_client=None, + replication_layer=MockReplication(), + ) + hs.handlers = PresenceAndProfileHandlers(hs) + + self.datastore = hs.get_datastore() + + self.replication = hs.get_replication_layer() + self.replication.send_edu = Mock() + self.replication.send_edu.return_value = defer.succeed((200, "OK")) + + def get_profile_displayname(user_localpart): + return defer.succeed("Frank") + self.datastore.get_profile_displayname = get_profile_displayname + + def get_profile_avatar_url(user_localpart): + return defer.succeed("http://foo") + self.datastore.get_profile_avatar_url = get_profile_avatar_url + + def get_presence_list(user_localpart, accepted=None): + return defer.succeed([ + {"observed_user_id": "@banana:test"}, + {"observed_user_id": "@clementine:test"}, + ]) + self.datastore.get_presence_list = get_presence_list + + self.handlers = hs.get_handlers() + + self.mock_start = Mock() + self.mock_stop = Mock() + + self.mock_update_client = Mock() + self.mock_update_client.return_value = defer.succeed(None) + + self.handlers.presence_handler.start_polling_presence = self.mock_start + self.handlers.presence_handler.stop_polling_presence = self.mock_stop + self.handlers.presence_handler.push_update_to_clients = ( + self.mock_update_client) + + hs.handlers.room_member_handler = Mock(spec=[ + "get_rooms_for_user", + ]) + hs.handlers.room_member_handler.get_rooms_for_user = ( + lambda u: defer.succeed([])) + + # Some local users to test with + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + self.u_clementine = hs.parse_userid("@clementine:test") + + # Remote user + self.u_potato = hs.parse_userid("@potato:remote") + + @defer.inlineCallbacks + def test_set_my_state(self): + mocked_set = self.datastore.set_presence_state + mocked_set.return_value = defer.succeed({"state": OFFLINE}) + + yield self.handlers.presence_handler.set_state( + target_user=self.u_apple, auth_user=self.u_apple, + state={"state": BUSY, "status_msg": "Away"}) + + mocked_set.assert_called_with("apple", + {"state": 1, "status_msg": "Away"}) + self.mock_start.assert_called_with(self.u_apple, + state={"state": 1, "status_msg": "Away", + "displayname": "Frank", + "avatar_url": "http://foo"}) + + @defer.inlineCallbacks + def test_push_local(self): + self.datastore.set_presence_state.return_value = defer.succeed( + {"state": ONLINE}) + + # TODO(paul): Gut-wrenching + from synapse.handlers.presence import UserPresenceCache + self.handlers.presence_handler._user_cachemap[self.u_apple] = ( + UserPresenceCache()) + apple_set = self.handlers.presence_handler._local_pushmap.setdefault( + "apple", set()) + apple_set.add(self.u_banana) + apple_set.add(self.u_clementine) + + yield self.handlers.presence_handler.set_state(self.u_apple, + self.u_apple, {"state": ONLINE}) + yield self.handlers.presence_handler.set_state(self.u_banana, + self.u_banana, {"state": ONLINE}) + + presence = yield self.handlers.presence_handler.get_presence_list( + observer_user=self.u_apple, accepted=True) + + self.assertEquals([ + {"observed_user": self.u_banana, "state": ONLINE, + "displayname": "Frank", "avatar_url": "http://foo"}, + {"observed_user": self.u_clementine, "state": OFFLINE}], + presence) + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_apple, + observed_user=self.u_apple, + statuscache=ANY), # self-reflection + call(observer_user=self.u_banana, + observed_user=self.u_apple, + statuscache=ANY), + ], any_order=True) + + statuscache = self.mock_update_client.call_args[1]["statuscache"] + self.assertEquals({"state": ONLINE, + "displayname": "Frank", + "avatar_url": "http://foo"}, statuscache.state) + + self.mock_update_client.reset_mock() + + self.datastore.set_profile_displayname.return_value = defer.succeed( + None) + + yield self.handlers.profile_handler.set_displayname(self.u_apple, + self.u_apple, "I am an Apple") + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_apple, + observed_user=self.u_apple, + statuscache=ANY), # self-reflection + call(observer_user=self.u_banana, + observed_user=self.u_apple, + statuscache=ANY), + ], any_order=True) + + statuscache = self.mock_update_client.call_args[1]["statuscache"] + self.assertEquals({"state": ONLINE, + "displayname": "I am an Apple", + "avatar_url": "http://foo"}, statuscache.state) + + @defer.inlineCallbacks + def test_push_remote(self): + self.datastore.set_presence_state.return_value = defer.succeed( + {"state": ONLINE}) + + # TODO(paul): Gut-wrenching + from synapse.handlers.presence import UserPresenceCache + self.handlers.presence_handler._user_cachemap[self.u_apple] = ( + UserPresenceCache()) + apple_set = self.handlers.presence_handler._remote_sendmap.setdefault( + "apple", set()) + apple_set.add(self.u_potato.domain) + + yield self.handlers.presence_handler.set_state(self.u_apple, + self.u_apple, {"state": ONLINE}) + + self.replication.send_edu.assert_called_with( + destination="remote", + edu_type="m.presence", + content={ + "push": [ + {"user_id": "@apple:test", + "state": 2, + "displayname": "Frank", + "avatar_url": "http://foo"}, + ], + }, + ) + + @defer.inlineCallbacks + def test_recv_remote(self): + # TODO(paul): Gut-wrenching + potato_set = self.handlers.presence_handler._remote_recvmap.setdefault( + self.u_potato, set()) + potato_set.add(self.u_apple) + + yield self.replication.received_edu( + "remote", "m.presence", { + "push": [ + {"user_id": "@potato:remote", + "state": 2, + "displayname": "Frank", + "avatar_url": "http://foo"}, + ], + } + ) + + self.mock_update_client.assert_called_with( + observer_user=self.u_apple, + observed_user=self.u_potato, + statuscache=ANY) + + statuscache = self.mock_update_client.call_args[1]["statuscache"] + self.assertEquals({"state": ONLINE, + "displayname": "Frank", + "avatar_url": "http://foo"}, statuscache.state) + + state = yield self.handlers.presence_handler.get_state(self.u_potato, + self.u_apple) + + self.assertEquals({"state": ONLINE, + "displayname": "Frank", + "avatar_url": "http://foo"}, + state) diff --git a/tests/handlers/test_profile.py b/tests/handlers/test_profile.py new file mode 100644 index 0000000000..a4408e9fd3 --- /dev/null +++ b/tests/handlers/test_profile.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- + +from twisted.trial import unittest +from twisted.internet import defer + +from mock import Mock +import logging + +from synapse.api.errors import AuthError +from synapse.server import HomeServer +from synapse.handlers.profile import ProfileHandler + + +logging.getLogger().addHandler(logging.NullHandler()) + + +class ProfileHandlers(object): + def __init__(self, hs): + self.profile_handler = ProfileHandler(hs) + + +class ProfileTestCase(unittest.TestCase): + """ Tests profile management. """ + + def setUp(self): + self.mock_client = Mock(spec=[ + "get_json", + ]) + + hs = HomeServer("test", + db_pool=None, + http_client=self.mock_client, + datastore=Mock(spec=[ + "get_profile_displayname", + "set_profile_displayname", + "get_profile_avatar_url", + "set_profile_avatar_url", + ]), + handlers=None, + http_server=Mock(), + ) + hs.handlers = ProfileHandlers(hs) + + self.datastore = hs.get_datastore() + + self.frank = hs.parse_userid("@1234ABCD:test") + self.bob = hs.parse_userid("@4567:test") + self.alice = hs.parse_userid("@alice:remote") + + self.handler = hs.get_handlers().profile_handler + + # TODO(paul): Icky signal declarings.. booo + hs.get_distributor().declare("changed_presencelike_data") + + @defer.inlineCallbacks + def test_get_my_name(self): + mocked_get = self.datastore.get_profile_displayname + mocked_get.return_value = defer.succeed("Frank") + + displayname = yield self.handler.get_displayname(self.frank) + + self.assertEquals("Frank", displayname) + mocked_get.assert_called_with("1234ABCD") + + @defer.inlineCallbacks + def test_set_my_name(self): + mocked_set = self.datastore.set_profile_displayname + mocked_set.return_value = defer.succeed(()) + + yield self.handler.set_displayname(self.frank, self.frank, "Frank Jr.") + + mocked_set.assert_called_with("1234ABCD", "Frank Jr.") + + @defer.inlineCallbacks + def test_set_my_name_noauth(self): + d = self.handler.set_displayname(self.frank, self.bob, "Frank Jr.") + + yield self.assertFailure(d, AuthError) + + @defer.inlineCallbacks + def test_get_other_name(self): + self.mock_client.get_json.return_value = defer.succeed( + {"displayname": "Alice"}) + + displayname = yield self.handler.get_displayname(self.alice) + + self.assertEquals(displayname, "Alice") + self.mock_client.get_json.assert_called_with( + destination="remote", + path="/matrix/client/api/v1/profile/@alice:remote/displayname" + "?local_only=1" + ) + + @defer.inlineCallbacks + def test_get_my_avatar(self): + mocked_get = self.datastore.get_profile_avatar_url + mocked_get.return_value = defer.succeed("http://my.server/me.png") + + avatar_url = yield self.handler.get_avatar_url(self.frank) + + self.assertEquals("http://my.server/me.png", avatar_url) + mocked_get.assert_called_with("1234ABCD") + + @defer.inlineCallbacks + def test_set_my_avatar(self): + mocked_set = self.datastore.set_profile_avatar_url + mocked_set.return_value = defer.succeed(()) + + yield self.handler.set_avatar_url(self.frank, self.frank, + "http://my.server/pic.gif") + + mocked_set.assert_called_with("1234ABCD", "http://my.server/pic.gif") diff --git a/tests/handlers/test_room.py b/tests/handlers/test_room.py new file mode 100644 index 0000000000..26b233bccd --- /dev/null +++ b/tests/handlers/test_room.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- + +from twisted.internet import defer +from twisted.trial import unittest + +from synapse.api.events.room import ( + InviteJoinEvent, RoomMemberEvent, RoomConfigEvent +) +from synapse.api.constants import Membership +from synapse.handlers.room import RoomMemberHandler, RoomCreationHandler +from synapse.handlers.profile import ProfileHandler +from synapse.server import HomeServer + +from mock import Mock, NonCallableMock + +import logging + +logging.getLogger().addHandler(logging.NullHandler()) + + +class RoomMemberHandlerTestCase(unittest.TestCase): + + def setUp(self): + self.hostname = "red" + hs = HomeServer( + self.hostname, + db_pool=None, + datastore=NonCallableMock(spec_set=[ + "store_room_member", + "get_joined_hosts_for_room", + "get_room_member", + "get_room", + "store_room", + ]), + http_server=NonCallableMock(), + http_client=NonCallableMock(spec_set=[]), + notifier=NonCallableMock(spec_set=["on_new_room_event"]), + handlers=NonCallableMock(spec_set=[ + "room_member_handler", + "profile_handler", + ]), + auth=NonCallableMock(spec_set=["check"]), + federation=NonCallableMock(spec_set=[ + "handle_new_event", + "get_state_for_room", + ]), + state_handler=NonCallableMock(spec_set=["handle_new_event"]), + ) + + self.datastore = hs.get_datastore() + self.handlers = hs.get_handlers() + self.notifier = hs.get_notifier() + self.federation = hs.get_federation() + self.state_handler = hs.get_state_handler() + self.distributor = hs.get_distributor() + self.hs = hs + + self.handlers.room_member_handler = RoomMemberHandler(self.hs) + self.handlers.profile_handler = ProfileHandler(self.hs) + self.room_member_handler = self.handlers.room_member_handler + + @defer.inlineCallbacks + def test_invite(self): + room_id = "!foo:red" + user_id = "@bob:red" + target_user_id = "@red:blue" + content = {"membership": Membership.INVITE} + + event = self.hs.get_event_factory().create_event( + etype=RoomMemberEvent.TYPE, + user_id=user_id, + target_user_id=target_user_id, + room_id=room_id, + membership=Membership.INVITE, + content=content, + ) + + joined = ["red", "green"] + + self.state_handler.handle_new_event.return_value = defer.succeed(True) + self.datastore.get_joined_hosts_for_room.return_value = ( + defer.succeed(joined) + ) + + store_id = "store_id_fooo" + self.datastore.store_room_member.return_value = defer.succeed(store_id) + + # Actual invocation + yield self.room_member_handler.change_membership(event) + + self.state_handler.handle_new_event.assert_called_once_with(event) + self.federation.handle_new_event.assert_called_once_with(event) + + self.assertEquals( + set(["blue", "red", "green"]), + set(event.destinations) + ) + + self.datastore.store_room_member.assert_called_once_with( + user_id=target_user_id, + sender=user_id, + room_id=room_id, + content=content, + membership=Membership.INVITE, + ) + self.notifier.on_new_room_event.assert_called_once_with( + event, store_id) + + self.assertFalse(self.datastore.get_room.called) + self.assertFalse(self.datastore.store_room.called) + self.assertFalse(self.federation.get_state_for_room.called) + + @defer.inlineCallbacks + def test_simple_join(self): + room_id = "!foo:red" + user_id = "@bob:red" + user = self.hs.parse_userid(user_id) + target_user_id = "@bob:red" + content = {"membership": Membership.JOIN} + + event = self.hs.get_event_factory().create_event( + etype=RoomMemberEvent.TYPE, + user_id=user_id, + target_user_id=target_user_id, + room_id=room_id, + membership=Membership.JOIN, + content=content, + ) + + joined = ["red", "green"] + + self.state_handler.handle_new_event.return_value = defer.succeed(True) + self.datastore.get_joined_hosts_for_room.return_value = ( + defer.succeed(joined) + ) + + store_id = "store_id_fooo" + self.datastore.store_room_member.return_value = defer.succeed(store_id) + self.datastore.get_room.return_value = defer.succeed(1) # Not None. + + prev_state = NonCallableMock() + prev_state.membership = Membership.INVITE + prev_state.sender = "@foo:red" + self.datastore.get_room_member.return_value = defer.succeed(prev_state) + + join_signal_observer = Mock() + self.distributor.observe("user_joined_room", join_signal_observer) + + # Actual invocation + yield self.room_member_handler.change_membership(event) + + self.state_handler.handle_new_event.assert_called_once_with(event) + self.federation.handle_new_event.assert_called_once_with(event) + + self.assertEquals( + set(["red", "green"]), + set(event.destinations) + ) + + self.datastore.store_room_member.assert_called_once_with( + user_id=target_user_id, + sender=user_id, + room_id=room_id, + content=content, + membership=Membership.JOIN, + ) + self.notifier.on_new_room_event.assert_called_once_with( + event, store_id) + + join_signal_observer.assert_called_with( + user=user, room_id=room_id) + + @defer.inlineCallbacks + def STALE_test_invite_join(self): + room_id = "foo" + user_id = "@bob:red" + target_user_id = "@bob:red" + content = {"membership": Membership.JOIN} + + event = self.hs.get_event_factory().create_event( + etype=RoomMemberEvent.TYPE, + user_id=user_id, + target_user_id=target_user_id, + room_id=room_id, + membership=Membership.JOIN, + content=content, + ) + + joined = ["red", "blue", "green"] + + self.state_handler.handle_new_event.return_value = defer.succeed(True) + self.datastore.get_joined_hosts_for_room.return_value = ( + defer.succeed(joined) + ) + + store_id = "store_id_fooo" + self.datastore.store_room_member.return_value = defer.succeed(store_id) + self.datastore.get_room.return_value = defer.succeed(None) + + prev_state = NonCallableMock(name="prev_state") + prev_state.membership = Membership.INVITE + prev_state.sender = "@foo:blue" + self.datastore.get_room_member.return_value = defer.succeed(prev_state) + + # Actual invocation + yield self.room_member_handler.change_membership(event) + + self.datastore.get_room_member.assert_called_once_with( + target_user_id, room_id + ) + + self.assertTrue(self.federation.handle_new_event.called) + args = self.federation.handle_new_event.call_args[0] + invite_join_event = args[0] + + self.assertTrue(InviteJoinEvent.TYPE, invite_join_event.TYPE) + self.assertTrue("blue", invite_join_event.target_host) + self.assertTrue(room_id, invite_join_event.room_id) + self.assertTrue(user_id, invite_join_event.user_id) + self.assertFalse(hasattr(invite_join_event, "state_key")) + + self.assertEquals( + set(["blue"]), + set(invite_join_event.destinations) + ) + + self.federation.get_state_for_room.assert_called_once_with( + "blue", room_id + ) + + self.assertFalse(self.datastore.store_room_member.called) + + self.assertFalse(self.notifier.on_new_room_event.called) + self.assertFalse(self.state_handler.handle_new_event.called) + + @defer.inlineCallbacks + def STALE_test_invite_join_public(self): + room_id = "#foo:blue" + user_id = "@bob:red" + target_user_id = "@bob:red" + content = {"membership": Membership.JOIN} + + event = self.hs.get_event_factory().create_event( + etype=RoomMemberEvent.TYPE, + user_id=user_id, + target_user_id=target_user_id, + room_id=room_id, + membership=Membership.JOIN, + content=content, + ) + + joined = ["red", "blue", "green"] + + self.state_handler.handle_new_event.return_value = defer.succeed(True) + self.datastore.get_joined_hosts_for_room.return_value = ( + defer.succeed(joined) + ) + + store_id = "store_id_fooo" + self.datastore.store_room_member.return_value = defer.succeed(store_id) + self.datastore.get_room.return_value = defer.succeed(None) + + prev_state = NonCallableMock(name="prev_state") + prev_state.membership = Membership.INVITE + prev_state.sender = "@foo:blue" + self.datastore.get_room_member.return_value = defer.succeed(prev_state) + + # Actual invocation + yield self.room_member_handler.change_membership(event) + + self.assertTrue(self.federation.handle_new_event.called) + args = self.federation.handle_new_event.call_args[0] + invite_join_event = args[0] + + self.assertTrue(InviteJoinEvent.TYPE, invite_join_event.TYPE) + self.assertTrue("blue", invite_join_event.target_host) + self.assertTrue("foo", invite_join_event.room_id) + self.assertTrue(user_id, invite_join_event.user_id) + self.assertFalse(hasattr(invite_join_event, "state_key")) + + self.assertEquals( + set(["blue"]), + set(invite_join_event.destinations) + ) + + self.federation.get_state_for_room.assert_called_once_with( + "blue", "foo" + ) + + self.assertFalse(self.datastore.store_room_member.called) + + self.assertFalse(self.notifier.on_new_room_event.called) + self.assertFalse(self.state_handler.handle_new_event.called) + + +class RoomCreationTest(unittest.TestCase): + + def setUp(self): + self.hostname = "red" + hs = HomeServer( + self.hostname, + db_pool=None, + datastore=NonCallableMock(spec_set=[ + "store_room", + ]), + http_server=NonCallableMock(), + http_client=NonCallableMock(spec_set=[]), + notifier=NonCallableMock(spec_set=["on_new_room_event"]), + handlers=NonCallableMock(spec_set=[ + "room_creation_handler", + "room_member_handler", + ]), + auth=NonCallableMock(spec_set=["check"]), + federation=NonCallableMock(spec_set=[ + "handle_new_event", + ]), + state_handler=NonCallableMock(spec_set=["handle_new_event"]), + ) + + self.datastore = hs.get_datastore() + self.handlers = hs.get_handlers() + self.notifier = hs.get_notifier() + self.federation = hs.get_federation() + self.state_handler = hs.get_state_handler() + self.hs = hs + + self.handlers.room_creation_handler = RoomCreationHandler(self.hs) + self.room_creation_handler = self.handlers.room_creation_handler + + self.handlers.room_member_handler = NonCallableMock(spec_set=[ + "change_membership" + ]) + self.room_member_handler = self.handlers.room_member_handler + + @defer.inlineCallbacks + def test_room_creation(self): + user_id = "@foo:red" + room_id = "!bobs_room:red" + config = {"visibility": "private"} + + yield self.room_creation_handler.create_room( + user_id=user_id, + room_id=room_id, + config=config, + ) + + self.assertTrue(self.room_member_handler.change_membership.called) + join_event = self.room_member_handler.change_membership.call_args[0][0] + + self.assertEquals(RoomMemberEvent.TYPE, join_event.type) + self.assertEquals(room_id, join_event.room_id) + self.assertEquals(user_id, join_event.user_id) + self.assertEquals(user_id, join_event.target_user_id) + + self.assertTrue(self.state_handler.handle_new_event.called) + + self.assertTrue(self.federation.handle_new_event.called) + config_event = self.federation.handle_new_event.call_args[0][0] + + self.assertEquals(RoomConfigEvent.TYPE, config_event.type) + self.assertEquals(room_id, config_event.room_id) + self.assertEquals(user_id, config_event.user_id) + self.assertEquals(config, config_event.content) diff --git a/tests/rest/__init__.py b/tests/rest/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/tests/rest/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/rest/test_events.py b/tests/rest/test_events.py new file mode 100644 index 0000000000..fa40e049ea --- /dev/null +++ b/tests/rest/test_events.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +""" Tests REST events for /events paths.""" +from twisted.trial import unittest + +# twisted imports +from twisted.internet import defer + +import synapse.rest.events +import synapse.rest.register +import synapse.rest.room + +from synapse.server import HomeServer + +# python imports +import json +import logging + +from ..utils import MockHttpServer, MemoryDataStore +from .utils import RestTestCase + +from mock import Mock + +logging.getLogger().addHandler(logging.NullHandler()) + +PATH_PREFIX = "/matrix/client/api/v1" + + +class EventStreamPaginationApiTestCase(unittest.TestCase): + """ Tests event streaming query parameters and start/end keys used in the + Pagination stream API. """ + user_id = "sid1" + + def setUp(self): + # configure stream and inject items + pass + + def tearDown(self): + pass + + def test_long_poll(self): + # stream from 'end' key, send (self+other) message, expect message. + + # stream from 'END', send (self+other) message, expect message. + + # stream from 'end' key, send (self+other) topic, expect topic. + + # stream from 'END', send (self+other) topic, expect topic. + + # stream from 'end' key, send (self+other) invite, expect invite. + + # stream from 'END', send (self+other) invite, expect invite. + + pass + + def test_stream_forward(self): + # stream from START, expect injected items + + # stream from 'start' key, expect same content + + # stream from 'end' key, expect nothing + + # stream from 'END', expect nothing + + # The following is needed for cases where content is removed e.g. you + # left a room, so the token you're streaming from is > the one that + # would be returned naturally from START>END. + # stream from very new token (higher than end key), expect same token + # returned as end key + pass + + def test_limits(self): + # stream from a key, expect limit_num items + + # stream from START, expect limit_num items + + pass + + def test_range(self): + # stream from key to key, expect X items + + # stream from key to END, expect X items + + # stream from START to key, expect X items + + # stream from START to END, expect all items + pass + + def test_direction(self): + # stream from END to START and fwds, expect newest first + + # stream from END to START and bwds, expect oldest first + + # stream from START to END and fwds, expect oldest first + + # stream from START to END and bwds, expect newest first + + pass + + +class EventStreamPermissionsTestCase(RestTestCase): + """ Tests event streaming (GET /events). """ + + @defer.inlineCallbacks + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + + state_handler = Mock(spec=["handle_new_event"]) + state_handler.handle_new_event.return_value = True + + persistence_service = Mock(spec=["get_latest_pdus_in_context"]) + persistence_service.get_latest_pdus_in_context.return_value = [] + + hs = HomeServer( + "test", + db_pool=None, + http_client=None, + federation=Mock(), + replication_layer=Mock(), + state_handler=state_handler, + persistence_service=persistence_service, + clock=Mock(spec=[ + "call_later", + "cancel_call_later", + "time_msec", + ]), + ) + + hs.get_clock().time_msec.return_value = 1000000 + + hs.datastore = MemoryDataStore() + synapse.rest.register.register_servlets(hs, self.mock_server) + synapse.rest.events.register_servlets(hs, self.mock_server) + synapse.rest.room.register_servlets(hs, self.mock_server) + + # register an account + self.user_id = "sid1" + response = yield self.register(self.user_id) + self.token = response["access_token"] + self.user_id = response["user_id"] + + # register a 2nd account + self.other_user = "other1" + response = yield self.register(self.other_user) + self.other_token = response["access_token"] + self.other_user = response["user_id"] + + def tearDown(self): + pass + + @defer.inlineCallbacks + def test_stream_basic_permissions(self): + # invalid token, expect 403 + (code, response) = yield self.mock_server.trigger_get( + "/events?access_token=%s" % ("invalid" + self.token)) + self.assertEquals(403, code, msg=str(response)) + + # valid token, expect content + (code, response) = yield self.mock_server.trigger_get( + "/events?access_token=%s&timeout=0" % (self.token)) + self.assertEquals(200, code, msg=str(response)) + self.assertTrue("chunk" in response) + self.assertTrue("start" in response) + self.assertTrue("end" in response) + + @defer.inlineCallbacks + def test_stream_room_permissions(self): + room_id = "!rid1:test" + yield self.create_room_as(room_id, self.other_user, + tok=self.other_token) + yield self.send(room_id, self.other_user, tok=self.other_token) + + # invited to room (expect no content for room) + yield self.invite(room_id, src=self.other_user, targ=self.user_id, + tok=self.other_token) + (code, response) = yield self.mock_server.trigger_get( + "/events?access_token=%s&timeout=0" % (self.token)) + self.assertEquals(200, code, msg=str(response)) + + # First message is a reflection of my own presence status change + self.assertEquals(1, len(response["chunk"])) + self.assertEquals("m.presence", response["chunk"][0]["type"]) + + # joined room (expect all content for room) + yield self.join(room=room_id, user=self.user_id, tok=self.token) + + # left to room (expect no content for room) + + def test_stream_items(self): + # new user, no content + + # join room, expect 1 item (join) + + # send message, expect 2 items (join,send) + + # set topic, expect 3 items (join,send,topic) + + # someone else join room, expect 4 (join,send,topic,join) + + # someone else send message, expect 5 (join,send.topic,join,send) + + # someone else set topic, expect 6 (join,send,topic,join,send,topic) + pass diff --git a/tests/rest/test_presence.py b/tests/rest/test_presence.py new file mode 100644 index 0000000000..3a2e86e5c4 --- /dev/null +++ b/tests/rest/test_presence.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +"""Tests REST events for /presence paths.""" + +from twisted.trial import unittest +from twisted.internet import defer + +from mock import Mock +import logging + +from ..utils import MockHttpServer + +from synapse.api.constants import PresenceState +from synapse.server import HomeServer + + +logging.getLogger().addHandler(logging.NullHandler()) + + +OFFLINE = PresenceState.OFFLINE +BUSY = PresenceState.BUSY +ONLINE = PresenceState.ONLINE + + +myid = "@apple:test" +PATH_PREFIX = "/matrix/client/api/v1" + + +class PresenceStateTestCase(unittest.TestCase): + + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + self.mock_handler = Mock(spec=[ + "get_state", + "set_state", + ]) + + hs = HomeServer("test", + db_pool=None, + http_client=None, + http_server=self.mock_server, + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(myid) + + hs.get_auth().get_user_by_token = _get_user_by_token + + hs.get_handlers().presence_handler = self.mock_handler + + hs.register_servlets() + + self.u_apple = hs.parse_userid(myid) + + @defer.inlineCallbacks + def test_get_my_status(self): + mocked_get = self.mock_handler.get_state + mocked_get.return_value = defer.succeed( + {"state": 2, "status_msg": "Available"}) + + (code, response) = yield self.mock_server.trigger("GET", + "/presence/%s/status" % (myid), None) + + self.assertEquals(200, code) + self.assertEquals({"state": ONLINE, "status_msg": "Available"}, + response) + mocked_get.assert_called_with(target_user=self.u_apple, + auth_user=self.u_apple) + + @defer.inlineCallbacks + def test_set_my_status(self): + mocked_set = self.mock_handler.set_state + mocked_set.return_value = defer.succeed(()) + + (code, response) = yield self.mock_server.trigger("PUT", + "/presence/%s/status" % (myid), + '{"state": 1, "status_msg": "Away"}') + + self.assertEquals(200, code) + mocked_set.assert_called_with(target_user=self.u_apple, + auth_user=self.u_apple, + state={"state": 1, "status_msg": "Away"}) + + +class PresenceListTestCase(unittest.TestCase): + + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + self.mock_handler = Mock(spec=[ + "get_presence_list", + "send_invite", + "drop", + ]) + + hs = HomeServer("test", + db_pool=None, + http_client=None, + http_server=self.mock_server, + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(myid) + + hs.get_auth().get_user_by_token = _get_user_by_token + + hs.get_handlers().presence_handler = self.mock_handler + + hs.register_servlets() + + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + + @defer.inlineCallbacks + def test_get_my_list(self): + self.mock_handler.get_presence_list.return_value = defer.succeed( + [{"observed_user": self.u_banana}] + ) + + (code, response) = yield self.mock_server.trigger("GET", + "/presence_list/%s" % (myid), None) + + self.assertEquals(200, code) + self.assertEquals([{"user_id": "@banana:test"}], response) + + @defer.inlineCallbacks + def test_invite(self): + self.mock_handler.send_invite.return_value = defer.succeed(()) + + (code, response) = yield self.mock_server.trigger("POST", + "/presence_list/%s" % (myid), + """{ + "invite": ["@banana:test"] + }""") + + self.assertEquals(200, code) + + self.mock_handler.send_invite.assert_called_with( + observer_user=self.u_apple, observed_user=self.u_banana) + + @defer.inlineCallbacks + def test_drop(self): + self.mock_handler.drop.return_value = defer.succeed(()) + + (code, response) = yield self.mock_server.trigger("POST", + "/presence_list/%s" % (myid), + """{ + "drop": ["@banana:test"] + }""") + + self.assertEquals(200, code) + + self.mock_handler.drop.assert_called_with( + observer_user=self.u_apple, observed_user=self.u_banana) + + +class PresenceEventStreamTestCase(unittest.TestCase): + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + + # TODO: mocked data store + + # HIDEOUS HACKERY + # TODO(paul): This should be injected in via the HomeServer DI system + from synapse.handlers.events import EventStreamHandler + from synapse.handlers.presence import PresenceStreamData + EventStreamHandler.stream_data_classes = [ + PresenceStreamData + ] + + hs = HomeServer("test", + db_pool=None, + http_client=None, + http_server=self.mock_server, + datastore=Mock(spec=[ + "set_presence_state", + "get_presence_list", + ]), + clock=Mock(spec=[ + "call_later", + "cancel_call_later", + "time_msec", + ]), + ) + + hs.get_clock().time_msec.return_value = 1000000 + + def _get_user_by_req(req=None): + return hs.parse_userid(myid) + + hs.get_auth().get_user_by_req = _get_user_by_req + + hs.register_servlets() + + hs.handlers.room_member_handler = Mock(spec=[ + "get_rooms_for_user", + ]) + hs.handlers.room_member_handler.get_rooms_for_user = ( + lambda u: defer.succeed([])) + + self.mock_datastore = hs.get_datastore() + self.presence = hs.get_handlers().presence_handler + + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + + @defer.inlineCallbacks + def test_shortpoll(self): + self.mock_datastore.set_presence_state.return_value = defer.succeed( + {"state": ONLINE}) + self.mock_datastore.get_presence_list.return_value = defer.succeed( + []) + + (code, response) = yield self.mock_server.trigger("GET", + "/events?timeout=0", None) + + self.assertEquals(200, code) + + # We've forced there to be only one data stream so the tokens will + # all be ours + + # I'll already get my own presence state change + self.assertEquals({"start": "0", "end": "1", "chunk": [ + {"type": "m.presence", + "content": {"user_id": "@apple:test", "state": 2}}, + ]}, response) + + self.mock_datastore.set_presence_state.return_value = defer.succeed( + {"state": ONLINE}) + self.mock_datastore.get_presence_list.return_value = defer.succeed( + []) + + yield self.presence.set_state(self.u_banana, self.u_banana, + state={"state": ONLINE}) + + (code, response) = yield self.mock_server.trigger("GET", + "/events?from=1&timeout=0", None) + + self.assertEquals(200, code) + self.assertEquals({"start": "1", "end": "2", "chunk": [ + {"type": "m.presence", + "content": {"user_id": "@banana:test", "state": 2}}, + ]}, response) diff --git a/tests/rest/test_profile.py b/tests/rest/test_profile.py new file mode 100644 index 0000000000..13342b61e5 --- /dev/null +++ b/tests/rest/test_profile.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""Tests REST events for /profile paths.""" + +from twisted.trial import unittest +from twisted.internet import defer + +from mock import Mock + +from ..utils import MockHttpServer + +from synapse.api.errors import SynapseError, AuthError +from synapse.server import HomeServer + +myid = "@1234ABCD:test" +PATH_PREFIX = "/matrix/client/api/v1" + +class ProfileTestCase(unittest.TestCase): + """ Tests profile management. """ + + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + self.mock_handler = Mock(spec=[ + "get_displayname", + "set_displayname", + "get_avatar_url", + "set_avatar_url", + ]) + + hs = HomeServer("test", + db_pool=None, + http_client=None, + http_server=self.mock_server, + federation=Mock(), + replication_layer=Mock(), + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(myid) + + hs.get_auth().get_user_by_token = _get_user_by_token + + hs.get_handlers().profile_handler = self.mock_handler + + hs.register_servlets() + + @defer.inlineCallbacks + def test_get_my_name(self): + mocked_get = self.mock_handler.get_displayname + mocked_get.return_value = defer.succeed("Frank") + + (code, response) = yield self.mock_server.trigger("GET", + "/profile/%s/displayname" % (myid), None) + + self.assertEquals(200, code) + self.assertEquals({"displayname": "Frank"}, response) + self.assertEquals(mocked_get.call_args[0][0].localpart, "1234ABCD") + + @defer.inlineCallbacks + def test_set_my_name(self): + mocked_set = self.mock_handler.set_displayname + mocked_set.return_value = defer.succeed(()) + + (code, response) = yield self.mock_server.trigger("PUT", + "/profile/%s/displayname" % (myid), + '{"displayname": "Frank Jr."}') + + self.assertEquals(200, code) + self.assertEquals(mocked_set.call_args[0][0].localpart, "1234ABCD") + self.assertEquals(mocked_set.call_args[0][1].localpart, "1234ABCD") + self.assertEquals(mocked_set.call_args[0][2], "Frank Jr.") + + @defer.inlineCallbacks + def test_set_my_name_noauth(self): + mocked_set = self.mock_handler.set_displayname + mocked_set.side_effect = AuthError(400, "message") + + (code, response) = yield self.mock_server.trigger("PUT", + "/profile/%s/displayname" % ("@4567:test"), '"Frank Jr."') + + self.assertTrue(400 <= code < 499, + msg="code %d is in the 4xx range" % (code)) + + @defer.inlineCallbacks + def test_get_other_name(self): + mocked_get = self.mock_handler.get_displayname + mocked_get.return_value = defer.succeed("Bob") + + (code, response) = yield self.mock_server.trigger("GET", + "/profile/%s/displayname" % ("@opaque:elsewhere"), None) + + self.assertEquals(200, code) + self.assertEquals({"displayname": "Bob"}, response) + + @defer.inlineCallbacks + def test_set_other_name(self): + mocked_set = self.mock_handler.set_displayname + mocked_set.side_effect = SynapseError(400, "message") + + (code, response) = yield self.mock_server.trigger("PUT", + "/profile/%s/displayname" % ("@opaque:elsewhere"), None) + + self.assertTrue(400 <= code <= 499, + msg="code %d is in the 4xx range" % (code)) + + @defer.inlineCallbacks + def test_get_my_avatar(self): + mocked_get = self.mock_handler.get_avatar_url + mocked_get.return_value = defer.succeed("http://my.server/me.png") + + (code, response) = yield self.mock_server.trigger("GET", + "/profile/%s/avatar_url" % (myid), None) + + self.assertEquals(200, code) + self.assertEquals({"avatar_url": "http://my.server/me.png"}, response) + self.assertEquals(mocked_get.call_args[0][0].localpart, "1234ABCD") + + @defer.inlineCallbacks + def test_set_my_avatar(self): + mocked_set = self.mock_handler.set_avatar_url + mocked_set.return_value = defer.succeed(()) + + (code, response) = yield self.mock_server.trigger("PUT", + "/profile/%s/avatar_url" % (myid), + '{"avatar_url": "http://my.server/pic.gif"}') + + self.assertEquals(200, code) + self.assertEquals(mocked_set.call_args[0][0].localpart, "1234ABCD") + self.assertEquals(mocked_set.call_args[0][1].localpart, "1234ABCD") + self.assertEquals(mocked_set.call_args[0][2], + "http://my.server/pic.gif") diff --git a/tests/rest/test_rooms.py b/tests/rest/test_rooms.py new file mode 100644 index 0000000000..29e82fc13c --- /dev/null +++ b/tests/rest/test_rooms.py @@ -0,0 +1,924 @@ +# -*- coding: utf-8 -*- +"""Tests REST events for /rooms paths.""" + +# twisted imports +from twisted.internet import defer + +import synapse.rest.room +from synapse.api.constants import Membership + +from synapse.server import HomeServer + +# python imports +import json +import urllib + +from ..utils import MockHttpServer, MemoryDataStore +from .utils import RestTestCase + +from mock import Mock + +PATH_PREFIX = "/matrix/client/api/v1" + + +class RoomPermissionsTestCase(RestTestCase): + """ Tests room permissions. """ + user_id = "@sid1:red" + rmcreator_id = "@notme:red" + + @defer.inlineCallbacks + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + + state_handler = Mock(spec=["handle_new_event"]) + state_handler.handle_new_event.return_value = True + + persistence_service = Mock(spec=["get_latest_pdus_in_context"]) + persistence_service.get_latest_pdus_in_context.return_value = [] + + hs = HomeServer( + "test", + db_pool=None, + http_client=None, + federation=Mock(), + datastore=MemoryDataStore(), + replication_layer=Mock(), + state_handler=state_handler, + persistence_service=persistence_service, + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(self.auth_user_id) + hs.get_auth().get_user_by_token = _get_user_by_token + + self.auth_user_id = self.rmcreator_id + + synapse.rest.room.register_servlets(hs, self.mock_server) + + self.auth = hs.get_auth() + + # create some rooms under the name rmcreator_id + self.uncreated_rmid = "!aa:test" + + self.created_rmid = "!abc:test" + yield self.create_room_as(self.created_rmid, self.rmcreator_id, + is_public=False) + + self.created_public_rmid = "!def1234ghi:test" + yield self.create_room_as(self.created_public_rmid, self.rmcreator_id, + is_public=True) + + # send a message in one of the rooms + self.created_rmid_msg_path = ("/rooms/%s/messages/%s/midaaa1" % + (self.created_rmid, self.rmcreator_id)) + (code, response) = yield self.mock_server.trigger( + "PUT", + self.created_rmid_msg_path, + '{"msgtype":"m.text","body":"test msg"}') + self.assertEquals(200, code, msg=str(response)) + + # set topic for public room + (code, response) = yield self.mock_server.trigger( + "PUT", + "/rooms/%s/topic" % self.created_public_rmid, + '{"topic":"Public Room Topic"}') + self.assertEquals(200, code, msg=str(response)) + + # auth as user_id now + self.auth_user_id = self.user_id + + def tearDown(self): + pass + + @defer.inlineCallbacks + def test_get_message(self): + # get message in uncreated room, expect 403 + (code, response) = yield self.mock_server.trigger_get( + "/rooms/noroom/messages/someid/m1") + self.assertEquals(403, code, msg=str(response)) + + # get message in created room not joined (no state), expect 403 + (code, response) = yield self.mock_server.trigger_get( + self.created_rmid_msg_path) + self.assertEquals(403, code, msg=str(response)) + + # get message in created room and invited, expect 403 + yield self.invite(room=self.created_rmid, src=self.rmcreator_id, + targ=self.user_id) + (code, response) = yield self.mock_server.trigger_get( + self.created_rmid_msg_path) + self.assertEquals(403, code, msg=str(response)) + + # get message in created room and joined, expect 200 + yield self.join(room=self.created_rmid, user=self.user_id) + (code, response) = yield self.mock_server.trigger_get( + self.created_rmid_msg_path) + self.assertEquals(200, code, msg=str(response)) + + # get message in created room and left, expect 403 + yield self.leave(room=self.created_rmid, user=self.user_id) + (code, response) = yield self.mock_server.trigger_get( + self.created_rmid_msg_path) + self.assertEquals(403, code, msg=str(response)) + + @defer.inlineCallbacks + def test_send_message(self): + msg_content = '{"msgtype":"m.text","body":"hello"}' + send_msg_path = ("/rooms/%s/messages/%s/mid1" % + (self.created_rmid, self.user_id)) + + # send message in uncreated room, expect 403 + (code, response) = yield self.mock_server.trigger( + "PUT", + "/rooms/%s/messages/%s/mid1" % + (self.uncreated_rmid, self.user_id), msg_content) + self.assertEquals(403, code, msg=str(response)) + + # send message in created room not joined (no state), expect 403 + (code, response) = yield self.mock_server.trigger( + "PUT", send_msg_path, msg_content) + self.assertEquals(403, code, msg=str(response)) + + # send message in created room and invited, expect 403 + yield self.invite(room=self.created_rmid, src=self.rmcreator_id, + targ=self.user_id) + (code, response) = yield self.mock_server.trigger( + "PUT", send_msg_path, msg_content) + self.assertEquals(403, code, msg=str(response)) + + # send message in created room and joined, expect 200 + yield self.join(room=self.created_rmid, user=self.user_id) + (code, response) = yield self.mock_server.trigger( + "PUT", send_msg_path, msg_content) + self.assertEquals(200, code, msg=str(response)) + + # send message in created room and left, expect 403 + yield self.leave(room=self.created_rmid, user=self.user_id) + (code, response) = yield self.mock_server.trigger( + "PUT", send_msg_path, msg_content) + self.assertEquals(403, code, msg=str(response)) + + @defer.inlineCallbacks + def test_topic_perms(self): + topic_content = '{"topic":"My Topic Name"}' + topic_path = "/rooms/%s/topic" % self.created_rmid + + # set/get topic in uncreated room, expect 403 + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/%s/topic" % self.uncreated_rmid, + topic_content) + self.assertEquals(403, code, msg=str(response)) + (code, response) = yield self.mock_server.trigger_get( + "/rooms/%s/topic" % self.uncreated_rmid) + self.assertEquals(403, code, msg=str(response)) + + # set/get topic in created PRIVATE room not joined, expect 403 + (code, response) = yield self.mock_server.trigger( + "PUT", topic_path, topic_content) + self.assertEquals(403, code, msg=str(response)) + (code, response) = yield self.mock_server.trigger_get(topic_path) + self.assertEquals(403, code, msg=str(response)) + + # set topic in created PRIVATE room and invited, expect 403 + yield self.invite(room=self.created_rmid, src=self.rmcreator_id, + targ=self.user_id) + (code, response) = yield self.mock_server.trigger( + "PUT", topic_path, topic_content) + self.assertEquals(403, code, msg=str(response)) + + # get topic in created PRIVATE room and invited, expect 200 (or 404) + (code, response) = yield self.mock_server.trigger_get(topic_path) + self.assertEquals(404, code, msg=str(response)) + + # set/get topic in created PRIVATE room and joined, expect 200 + yield self.join(room=self.created_rmid, user=self.user_id) + (code, response) = yield self.mock_server.trigger( + "PUT", topic_path, topic_content) + self.assertEquals(200, code, msg=str(response)) + (code, response) = yield self.mock_server.trigger_get(topic_path) + self.assertEquals(200, code, msg=str(response)) + self.assert_dict(json.loads(topic_content), response) + + # set/get topic in created PRIVATE room and left, expect 403 + yield self.leave(room=self.created_rmid, user=self.user_id) + (code, response) = yield self.mock_server.trigger( + "PUT", topic_path, topic_content) + self.assertEquals(403, code, msg=str(response)) + (code, response) = yield self.mock_server.trigger_get(topic_path) + self.assertEquals(403, code, msg=str(response)) + + # get topic in PUBLIC room, not joined, expect 200 (or 404) + (code, response) = yield self.mock_server.trigger_get( + "/rooms/%s/topic" % self.created_public_rmid) + self.assertEquals(200, code, msg=str(response)) + + # set topic in PUBLIC room, not joined, expect 403 + (code, response) = yield self.mock_server.trigger( + "PUT", + "/rooms/%s/topic" % self.created_public_rmid, + topic_content) + self.assertEquals(403, code, msg=str(response)) + + @defer.inlineCallbacks + def _test_get_membership(self, room=None, members=[], expect_code=None): + path = "/rooms/%s/members/%s/state" + for member in members: + (code, response) = yield self.mock_server.trigger_get( + path % + (room, member)) + self.assertEquals(expect_code, code) + + @defer.inlineCallbacks + def test_membership_basic_room_perms(self): + # === room does not exist === + room = self.uncreated_rmid + # get membership of self, get membership of other, uncreated room + # expect all 403s + yield self._test_get_membership( + members=[self.user_id, self.rmcreator_id], + room=room, expect_code=403) + + # trying to invite people to this room should 403 + yield self.invite(room=room, src=self.user_id, targ=self.rmcreator_id, + expect_code=403) + + # set [invite/join/left] of self, set [invite/join/left] of other, + # expect all 403s + for usr in [self.user_id, self.rmcreator_id]: + yield self.join(room=room, user=usr, expect_code=403) + yield self.leave(room=room, user=usr, expect_code=403) + + @defer.inlineCallbacks + def test_membership_private_room_perms(self): + room = self.created_rmid + # get membership of self, get membership of other, private room + invite + # expect all 403s + yield self.invite(room=room, src=self.rmcreator_id, + targ=self.user_id) + yield self._test_get_membership( + members=[self.user_id, self.rmcreator_id], + room=room, expect_code=403) + + # get membership of self, get membership of other, private room + joined + # expect all 200s + yield self.join(room=room, user=self.user_id) + yield self._test_get_membership( + members=[self.user_id, self.rmcreator_id], + room=room, expect_code=200) + + # get membership of self, get membership of other, private room + left + # expect all 403s + yield self.leave(room=room, user=self.user_id) + yield self._test_get_membership( + members=[self.user_id, self.rmcreator_id], + room=room, expect_code=403) + + @defer.inlineCallbacks + def test_membership_public_room_perms(self): + room = self.created_public_rmid + # get membership of self, get membership of other, public room + invite + # expect all 403s + yield self.invite(room=room, src=self.rmcreator_id, + targ=self.user_id) + yield self._test_get_membership( + members=[self.user_id, self.rmcreator_id], + room=room, expect_code=403) + + # get membership of self, get membership of other, public room + joined + # expect all 200s + yield self.join(room=room, user=self.user_id) + yield self._test_get_membership( + members=[self.user_id, self.rmcreator_id], + room=room, expect_code=200) + + # get membership of self, get membership of other, public room + left + # expect all 403s + yield self.leave(room=room, user=self.user_id) + yield self._test_get_membership( + members=[self.user_id, self.rmcreator_id], + room=room, expect_code=403) + + @defer.inlineCallbacks + def test_invited_permissions(self): + room = self.created_rmid + yield self.invite(room=room, src=self.rmcreator_id, targ=self.user_id) + + # set [invite/join/left] of other user, expect 403s + yield self.invite(room=room, src=self.user_id, targ=self.rmcreator_id, + expect_code=403) + yield self.change_membership(room=room, src=self.user_id, + targ=self.rmcreator_id, + membership=Membership.JOIN, + expect_code=403) + yield self.change_membership(room=room, src=self.user_id, + targ=self.rmcreator_id, + membership=Membership.LEAVE, + expect_code=403) + + @defer.inlineCallbacks + def test_joined_permissions(self): + room = self.created_rmid + yield self.invite(room=room, src=self.rmcreator_id, targ=self.user_id) + yield self.join(room=room, user=self.user_id) + + # set invited of self, expect 403 + yield self.invite(room=room, src=self.user_id, targ=self.user_id, + expect_code=403) + + # set joined of self, expect 200 (NOOP) + yield self.join(room=room, user=self.user_id) + + other = "@burgundy:red" + # set invited of other, expect 200 + yield self.invite(room=room, src=self.user_id, targ=other, + expect_code=200) + + # set joined of other, expect 403 + yield self.change_membership(room=room, src=self.user_id, + targ=other, + membership=Membership.JOIN, + expect_code=403) + + # set left of other, expect 403 + yield self.change_membership(room=room, src=self.user_id, + targ=other, + membership=Membership.LEAVE, + expect_code=403) + + # set left of self, expect 200 + yield self.leave(room=room, user=self.user_id) + + @defer.inlineCallbacks + def test_leave_permissions(self): + room = self.created_rmid + yield self.invite(room=room, src=self.rmcreator_id, targ=self.user_id) + yield self.join(room=room, user=self.user_id) + yield self.leave(room=room, user=self.user_id) + + # set [invite/join/left] of self, set [invite/join/left] of other, + # expect all 403s + for usr in [self.user_id, self.rmcreator_id]: + yield self.change_membership(room=room, src=self.user_id, + targ=usr, + membership=Membership.INVITE, + expect_code=403) + yield self.change_membership(room=room, src=self.user_id, + targ=usr, + membership=Membership.JOIN, + expect_code=403) + yield self.change_membership(room=room, src=self.user_id, + targ=usr, + membership=Membership.LEAVE, + expect_code=403) + + +class RoomsMemberListTestCase(RestTestCase): + """ Tests /rooms/$room_id/members/list REST events.""" + user_id = "@sid1:red" + + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + + state_handler = Mock(spec=["handle_new_event"]) + state_handler.handle_new_event.return_value = True + + persistence_service = Mock(spec=["get_latest_pdus_in_context"]) + persistence_service.get_latest_pdus_in_context.return_value = [] + + hs = HomeServer( + "test", + db_pool=None, + http_client=None, + federation=Mock(), + datastore=MemoryDataStore(), + replication_layer=Mock(), + state_handler=state_handler, + persistence_service=persistence_service, + ) + + self.auth_user_id = self.user_id + + def _get_user_by_token(token=None): + return hs.parse_userid(self.auth_user_id) + hs.get_auth().get_user_by_token = _get_user_by_token + + synapse.rest.room.register_servlets(hs, self.mock_server) + + def tearDown(self): + pass + + @defer.inlineCallbacks + def test_get_member_list(self): + room_id = "!aa:test" + yield self.create_room_as(room_id, self.user_id) + (code, response) = yield self.mock_server.trigger_get( + "/rooms/%s/members/list" % room_id) + self.assertEquals(200, code, msg=str(response)) + + @defer.inlineCallbacks + def test_get_member_list_no_room(self): + (code, response) = yield self.mock_server.trigger_get( + "/rooms/roomdoesnotexist/members/list") + self.assertEquals(403, code, msg=str(response)) + + @defer.inlineCallbacks + def test_get_member_list_no_permission(self): + room_id = "!bb:test" + yield self.create_room_as(room_id, "@some_other_guy:red") + (code, response) = yield self.mock_server.trigger_get( + "/rooms/%s/members/list" % room_id) + self.assertEquals(403, code, msg=str(response)) + + @defer.inlineCallbacks + def test_get_member_list_mixed_memberships(self): + room_id = "!bb:test" + room_creator = "@some_other_guy:blue" + room_path = "/rooms/%s/members/list" % room_id + yield self.create_room_as(room_id, room_creator) + yield self.invite(room=room_id, src=room_creator, + targ=self.user_id) + # can't see list if you're just invited. + (code, response) = yield self.mock_server.trigger_get(room_path) + self.assertEquals(403, code, msg=str(response)) + + yield self.join(room=room_id, user=self.user_id) + # can see list now joined + (code, response) = yield self.mock_server.trigger_get(room_path) + self.assertEquals(200, code, msg=str(response)) + + yield self.leave(room=room_id, user=self.user_id) + # can no longer see list, you've left. + (code, response) = yield self.mock_server.trigger_get(room_path) + self.assertEquals(403, code, msg=str(response)) + + +class RoomsCreateTestCase(RestTestCase): + """ Tests /rooms and /rooms/$room_id REST events. """ + user_id = "@sid1:red" + + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + self.auth_user_id = self.user_id + + state_handler = Mock(spec=["handle_new_event"]) + state_handler.handle_new_event.return_value = True + + persistence_service = Mock(spec=["get_latest_pdus_in_context"]) + persistence_service.get_latest_pdus_in_context.return_value = [] + + hs = HomeServer( + "test", + db_pool=None, + http_client=None, + federation=Mock(), + datastore=MemoryDataStore(), + replication_layer=Mock(), + state_handler=state_handler, + persistence_service=persistence_service, + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(self.auth_user_id) + hs.get_auth().get_user_by_token = _get_user_by_token + + synapse.rest.room.register_servlets(hs, self.mock_server) + + def tearDown(self): + pass + + @defer.inlineCallbacks + def test_post_room_no_keys(self): + # POST with no config keys, expect new room id + (code, response) = yield self.mock_server.trigger("POST", "/rooms", + "{}") + self.assertEquals(200, code, response) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_post_room_visibility_key(self): + # POST with visibility config key, expect new room id + (code, response) = yield self.mock_server.trigger("POST", "/rooms", + '{"visibility":"private"}') + self.assertEquals(200, code) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_post_room_custom_key(self): + # POST with custom config keys, expect new room id + (code, response) = yield self.mock_server.trigger("POST", "/rooms", + '{"custom":"stuff"}') + self.assertEquals(200, code) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_post_room_known_and_unknown_keys(self): + # POST with custom + known config keys, expect new room id + (code, response) = yield self.mock_server.trigger("POST", "/rooms", + '{"visibility":"private","custom":"things"}') + self.assertEquals(200, code) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_post_room_invalid_content(self): + # POST with invalid content / paths, expect 400 + (code, response) = yield self.mock_server.trigger("POST", "/rooms", + '{"visibili') + self.assertEquals(400, code) + + (code, response) = yield self.mock_server.trigger("POST", "/rooms", + '["hello"]') + self.assertEquals(400, code) + + @defer.inlineCallbacks + def test_put_room_no_keys(self): + # PUT with no config keys, expect new room id + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/%21aa%3Atest", "{}" + ) + self.assertEquals(200, code) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_put_room_visibility_key(self): + # PUT with known config keys, expect new room id + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/%21bb%3Atest", '{"visibility":"private"}' + ) + self.assertEquals(200, code) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_put_room_custom_key(self): + # PUT with custom config keys, expect new room id + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/%21cc%3Atest", '{"custom":"stuff"}' + ) + self.assertEquals(200, code) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_put_room_known_and_unknown_keys(self): + # PUT with custom + known config keys, expect new room id + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/%21dd%3Atest", + '{"visibility":"private","custom":"things"}' + ) + self.assertEquals(200, code) + self.assertTrue("room_id" in response) + + @defer.inlineCallbacks + def test_put_room_invalid_content(self): + # PUT with invalid content / room names, expect 400 + + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/ee", '{"sdf"' + ) + self.assertEquals(400, code) + + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/ee", '["hello"]' + ) + self.assertEquals(400, code) + + @defer.inlineCallbacks + def test_put_room_conflict(self): + yield self.create_room_as("!aa:test", self.user_id) + + # PUT with conflicting room ID, expect 409 + (code, response) = yield self.mock_server.trigger( + "PUT", "/rooms/%21aa%3Atest", "{}" + ) + self.assertEquals(409, code) + + +class RoomTopicTestCase(RestTestCase): + """ Tests /rooms/$room_id/topic REST events. """ + user_id = "@sid1:red" + + @defer.inlineCallbacks + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + self.auth_user_id = self.user_id + self.room_id = "!rid1:test" + self.path = "/rooms/%s/topic" % self.room_id + + state_handler = Mock(spec=["handle_new_event"]) + state_handler.handle_new_event.return_value = True + + persistence_service = Mock(spec=["get_latest_pdus_in_context"]) + persistence_service.get_latest_pdus_in_context.return_value = [] + + hs = HomeServer( + "test", + db_pool=None, + http_client=None, + federation=Mock(), + datastore=MemoryDataStore(), + replication_layer=Mock(), + state_handler=state_handler, + persistence_service=persistence_service, + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(self.auth_user_id) + hs.get_auth().get_user_by_token = _get_user_by_token + + synapse.rest.room.register_servlets(hs, self.mock_server) + + # create the room + yield self.create_room_as(self.room_id, self.user_id) + + def tearDown(self): + pass + + @defer.inlineCallbacks + def test_invalid_puts(self): + # missing keys or invalid json + (code, response) = yield self.mock_server.trigger("PUT", + self.path, '{}') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + self.path, '{"_name":"bob"}') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + self.path, '{"nao') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + self.path, '[{"_name":"bob"},{"_name":"jill"}]') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + self.path, 'text only') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + self.path, '') + self.assertEquals(400, code, msg=str(response)) + + # valid key, wrong type + content = '{"topic":["Topic name"]}' + (code, response) = yield self.mock_server.trigger("PUT", + self.path, content) + self.assertEquals(400, code, msg=str(response)) + + @defer.inlineCallbacks + def test_rooms_topic(self): + # nothing should be there + (code, response) = yield self.mock_server.trigger_get(self.path) + self.assertEquals(404, code, msg=str(response)) + + # valid put + content = '{"topic":"Topic name"}' + (code, response) = yield self.mock_server.trigger("PUT", + self.path, content) + self.assertEquals(200, code, msg=str(response)) + + # valid get + (code, response) = yield self.mock_server.trigger_get(self.path) + self.assertEquals(200, code, msg=str(response)) + self.assert_dict(json.loads(content), response) + + @defer.inlineCallbacks + def test_rooms_topic_with_extra_keys(self): + # valid put with extra keys + content = '{"topic":"Seasons","subtopic":"Summer"}' + (code, response) = yield self.mock_server.trigger("PUT", + self.path, content) + self.assertEquals(200, code, msg=str(response)) + + # valid get + (code, response) = yield self.mock_server.trigger_get(self.path) + self.assertEquals(200, code, msg=str(response)) + self.assert_dict(json.loads(content), response) + + +class RoomMemberStateTestCase(RestTestCase): + """ Tests /rooms/$room_id/members/$user_id/state REST events. """ + user_id = "@sid1:red" + + @defer.inlineCallbacks + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + self.auth_user_id = self.user_id + self.room_id = "!rid1:test" + + state_handler = Mock(spec=["handle_new_event"]) + state_handler.handle_new_event.return_value = True + + persistence_service = Mock(spec=["get_latest_pdus_in_context"]) + persistence_service.get_latest_pdus_in_context.return_value = [] + + hs = HomeServer( + "test", + db_pool=None, + http_client=None, + federation=Mock(), + datastore=MemoryDataStore(), + replication_layer=Mock(), + state_handler=state_handler, + persistence_service=persistence_service, + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(self.auth_user_id) + hs.get_auth().get_user_by_token = _get_user_by_token + + synapse.rest.room.register_servlets(hs, self.mock_server) + + yield self.create_room_as(self.room_id, self.user_id) + + def tearDown(self): + pass + + @defer.inlineCallbacks + def test_invalid_puts(self): + path = "/rooms/%s/members/%s/state" % (self.room_id, self.user_id) + # missing keys or invalid json + (code, response) = yield self.mock_server.trigger("PUT", + path, '{}') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '{"_name":"bob"}') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '{"nao') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '[{"_name":"bob"},{"_name":"jill"}]') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, 'text only') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '') + self.assertEquals(400, code, msg=str(response)) + + # valid keys, wrong types + content = ('{"membership":["%s","%s","%s"]}' % + (Membership.INVITE, Membership.JOIN, Membership.LEAVE)) + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(400, code, msg=str(response)) + + @defer.inlineCallbacks + def test_rooms_members_self(self): + path = "/rooms/%s/members/%s/state" % ( + urllib.quote(self.room_id), self.user_id + ) + + # valid join message (NOOP since we made the room) + content = '{"membership":"%s"}' % Membership.JOIN + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(200, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("GET", path, None) + self.assertEquals(200, code, msg=str(response)) + self.assertEquals(json.loads(content), response) + + @defer.inlineCallbacks + def test_rooms_members_other(self): + self.other_id = "@zzsid1:red" + path = "/rooms/%s/members/%s/state" % ( + urllib.quote(self.room_id), self.other_id + ) + + # valid invite message + content = '{"membership":"%s"}' % Membership.INVITE + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(200, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("GET", path, None) + self.assertEquals(200, code, msg=str(response)) + self.assertEquals(json.loads(content), response) + + @defer.inlineCallbacks + def test_rooms_members_other_custom_keys(self): + self.other_id = "@zzsid1:red" + path = "/rooms/%s/members/%s/state" % ( + urllib.quote(self.room_id), self.other_id + ) + + # valid invite message with custom key + content = ('{"membership":"%s","invite_text":"%s"}' % + (Membership.INVITE, "Join us!")) + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(200, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("GET", path, None) + self.assertEquals(200, code, msg=str(response)) + self.assertEquals(json.loads(content), response) + + +class RoomMessagesTestCase(RestTestCase): + """ Tests /rooms/$room_id/messages/$user_id/$msg_id REST events. """ + user_id = "@sid1:red" + + @defer.inlineCallbacks + def setUp(self): + self.mock_server = MockHttpServer(prefix=PATH_PREFIX) + self.auth_user_id = self.user_id + self.room_id = "!rid1:test" + + state_handler = Mock(spec=["handle_new_event"]) + state_handler.handle_new_event.return_value = True + + persistence_service = Mock(spec=["get_latest_pdus_in_context"]) + persistence_service.get_latest_pdus_in_context.return_value = [] + + hs = HomeServer( + "test", + db_pool=None, + http_client=None, + federation=Mock(), + datastore=MemoryDataStore(), + replication_layer=Mock(), + state_handler=state_handler, + persistence_service=persistence_service, + ) + + def _get_user_by_token(token=None): + return hs.parse_userid(self.auth_user_id) + hs.get_auth().get_user_by_token = _get_user_by_token + + synapse.rest.room.register_servlets(hs, self.mock_server) + + yield self.create_room_as(self.room_id, self.user_id) + + def tearDown(self): + pass + + @defer.inlineCallbacks + def test_invalid_puts(self): + path = "/rooms/%s/messages/%s/mid1" % ( + urllib.quote(self.room_id), self.user_id + ) + # missing keys or invalid json + (code, response) = yield self.mock_server.trigger("PUT", + path, '{}') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '{"_name":"bob"}') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '{"nao') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '[{"_name":"bob"},{"_name":"jill"}]') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, 'text only') + self.assertEquals(400, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("PUT", + path, '') + self.assertEquals(400, code, msg=str(response)) + + @defer.inlineCallbacks + def test_rooms_messages_sent(self): + path = "/rooms/%s/messages/%s/mid1" % ( + urllib.quote(self.room_id), self.user_id + ) + + content = '{"body":"test","msgtype":{"type":"a"}}' + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(400, code, msg=str(response)) + + # custom message types + content = '{"body":"test","msgtype":"test.custom.text"}' + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(200, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("GET", path, None) + self.assertEquals(200, code, msg=str(response)) + self.assert_dict(json.loads(content), response) + + # m.text message type + path = "/rooms/%s/messages/%s/mid2" % ( + urllib.quote(self.room_id), self.user_id + ) + content = '{"body":"test2","msgtype":"m.text"}' + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(200, code, msg=str(response)) + + (code, response) = yield self.mock_server.trigger("GET", path, None) + self.assertEquals(200, code, msg=str(response)) + self.assert_dict(json.loads(content), response) + + # trying to send message in different user path + path = "/rooms/%s/messages/%s/mid2" % ( + urllib.quote(self.room_id), "invalid" + self.user_id + ) + content = '{"body":"test2","msgtype":"m.text"}' + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(403, code, msg=str(response)) diff --git a/tests/rest/utils.py b/tests/rest/utils.py new file mode 100644 index 0000000000..7e4e570eff --- /dev/null +++ b/tests/rest/utils.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# twisted imports +from twisted.internet import defer + +# trial imports +from twisted.trial import unittest + +from synapse.api.constants import Membership + +import time + +class RestTestCase(unittest.TestCase): + """Contains extra helper functions to quickly and clearly perform a given + REST action, which isn't the focus of the test. + + This subclass assumes there are mock_server and auth_user_id attributes. + """ + + def __init__(self, *args, **kwargs): + super(RestTestCase, self).__init__(*args, **kwargs) + self.mock_server = None + self.auth_user_id = None + + def mock_get_user_by_token(self, token=None): + return self.auth_user_id + + @defer.inlineCallbacks + def create_room_as(self, room_id, room_creator, is_public=True, tok=None): + temp_id = self.auth_user_id + self.auth_user_id = room_creator + path = "/rooms/%s" % room_id + content = "{}" + if not is_public: + content = '{"visibility":"private"}' + if tok: + path = path + "?access_token=%s" % tok + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(200, code, msg=str(response)) + self.auth_user_id = temp_id + + @defer.inlineCallbacks + def invite(self, room=None, src=None, targ=None, expect_code=200, tok=None): + yield self.change_membership(room=room, src=src, targ=targ, tok=tok, + membership=Membership.INVITE, + expect_code=expect_code) + + @defer.inlineCallbacks + def join(self, room=None, user=None, expect_code=200, tok=None): + yield self.change_membership(room=room, src=user, targ=user, tok=tok, + membership=Membership.JOIN, + expect_code=expect_code) + + @defer.inlineCallbacks + def leave(self, room=None, user=None, expect_code=200, tok=None): + yield self.change_membership(room=room, src=user, targ=user, tok=tok, + membership=Membership.LEAVE, + expect_code=expect_code) + + @defer.inlineCallbacks + def change_membership(self, room=None, src=None, targ=None, + membership=None, expect_code=200, tok=None): + temp_id = self.auth_user_id + self.auth_user_id = src + + path = "/rooms/%s/members/%s/state" % (room, targ) + if tok: + path = path + "?access_token=%s" % tok + + if membership == Membership.LEAVE: + (code, response) = yield self.mock_server.trigger("DELETE", path, + None) + self.assertEquals(expect_code, code, msg=str(response)) + else: + (code, response) = yield self.mock_server.trigger("PUT", path, + '{"membership":"%s"}' % membership) + self.assertEquals(expect_code, code, msg=str(response)) + + self.auth_user_id = temp_id + + @defer.inlineCallbacks + def register(self, user_id): + (code, response) = yield self.mock_server.trigger("POST", "/register", + '{"user_id":"%s"}' % user_id) + self.assertEquals(200, code) + defer.returnValue(response) + + @defer.inlineCallbacks + def send(self, room_id, sender_id, body=None, msg_id=None, tok=None, + expect_code=200): + if msg_id is None: + msg_id = "m%s" % (str(time.time())) + if body is None: + body = "body_text_here" + + path = "/rooms/%s/messages/%s/%s" % (room_id, sender_id, msg_id) + content = '{"msgtype":"m.text","body":"%s"}' % body + if tok: + path = path + "?access_token=%s" % tok + + (code, response) = yield self.mock_server.trigger("PUT", path, content) + self.assertEquals(expect_code, code, msg=str(response)) + + def assert_dict(self, required, actual): + """Does a partial assert of a dict. + + Args: + required (dict): The keys and value which MUST be in 'actual'. + actual (dict): The test result. Extra keys will not be checked. + """ + for key in required: + self.assertEquals(required[key], actual[key], + msg="%s mismatch. %s" % (key, actual)) diff --git a/tests/storage/__init__.py b/tests/storage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/storage/test_base.py b/tests/storage/test_base.py new file mode 100644 index 0000000000..72869ef910 --- /dev/null +++ b/tests/storage/test_base.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- + +from twisted.trial import unittest +from twisted.internet import defer + +from mock import Mock, call + +from collections import OrderedDict + +from synapse.server import HomeServer +from synapse.storage._base import SQLBaseStore + + +class SQLBaseStoreTestCase(unittest.TestCase): + """ Test the "simple" SQL generating methods in SQLBaseStore. """ + + def setUp(self): + self.db_pool = Mock(spec=["runInteraction"]) + self.mock_txn = Mock() + # Our fake runInteraction just runs synchronously inline + + def runInteraction(func, *args, **kwargs): + return defer.succeed(func(self.mock_txn, *args, **kwargs)) + self.db_pool.runInteraction = runInteraction + + hs = HomeServer("test", + db_pool=self.db_pool) + + self.datastore = SQLBaseStore(hs) + + @defer.inlineCallbacks + def test_insert_1col(self): + self.mock_txn.rowcount = 1 + + yield self.datastore._simple_insert( + table="tablename", + values={"columname": "Value"} + ) + + self.mock_txn.execute.assert_called_with( + "INSERT INTO tablename (columname) VALUES(?)", + ["Value"] + ) + + @defer.inlineCallbacks + def test_insert_3cols(self): + self.mock_txn.rowcount = 1 + + yield self.datastore._simple_insert( + table="tablename", + # Use OrderedDict() so we can assert on the SQL generated + values=OrderedDict([("colA", 1), ("colB", 2), ("colC", 3)]) + ) + + self.mock_txn.execute.assert_called_with( + "INSERT INTO tablename (colA, colB, colC) VALUES(?, ?, ?)", + [1, 2, 3] + ) + + @defer.inlineCallbacks + def test_select_one_1col(self): + self.mock_txn.rowcount = 1 + self.mock_txn.fetchone.return_value = ("Value",) + + value = yield self.datastore._simple_select_one_onecol( + table="tablename", + keyvalues={"keycol": "TheKey"}, + retcol="retcol" + ) + + self.assertEquals("Value", value) + self.mock_txn.execute.assert_called_with( + "SELECT retcol FROM tablename WHERE keycol = ?", + ["TheKey"] + ) + + @defer.inlineCallbacks + def test_select_one_3col(self): + self.mock_txn.rowcount = 1 + self.mock_txn.fetchone.return_value = (1, 2, 3) + + ret = yield self.datastore._simple_select_one( + table="tablename", + keyvalues={"keycol": "TheKey"}, + retcols=["colA", "colB", "colC"] + ) + + self.assertEquals({"colA": 1, "colB": 2, "colC": 3}, ret) + self.mock_txn.execute.assert_called_with( + "SELECT colA, colB, colC FROM tablename WHERE keycol = ?", + ["TheKey"] + ) + + @defer.inlineCallbacks + def test_select_one_missing(self): + self.mock_txn.rowcount = 0 + self.mock_txn.fetchone.return_value = None + + ret = yield self.datastore._simple_select_one( + table="tablename", + keyvalues={"keycol": "Not here"}, + retcols=["colA"], + allow_none=True + ) + + self.assertFalse(ret) + + @defer.inlineCallbacks + def test_select_list(self): + self.mock_txn.rowcount = 3; + self.mock_txn.fetchall.return_value = ((1,), (2,), (3,)) + self.mock_txn.description = ( + ("colA", None, None, None, None, None, None), + ) + + ret = yield self.datastore._simple_select_list( + table="tablename", + keyvalues={"keycol": "A set"}, + retcols=["colA"], + ) + + self.assertEquals([{"colA": 1}, {"colA": 2}, {"colA": 3}], ret) + self.mock_txn.execute.assert_called_with( + "SELECT colA FROM tablename WHERE keycol = ?", + ["A set"] + ) + + @defer.inlineCallbacks + def test_update_one_1col(self): + self.mock_txn.rowcount = 1 + + yield self.datastore._simple_update_one( + table="tablename", + keyvalues={"keycol": "TheKey"}, + updatevalues={"columnname": "New Value"} + ) + + self.mock_txn.execute.assert_called_with( + "UPDATE tablename SET columnname = ? WHERE keycol = ?", + ["New Value", "TheKey"] + ) + + @defer.inlineCallbacks + def test_update_one_4cols(self): + self.mock_txn.rowcount = 1 + + yield self.datastore._simple_update_one( + table="tablename", + keyvalues=OrderedDict([("colA", 1), ("colB", 2)]), + updatevalues=OrderedDict([("colC", 3), ("colD", 4)]) + ) + + self.mock_txn.execute.assert_called_with( + "UPDATE tablename SET colC = ?, colD = ? WHERE " + + "colA = ? AND colB = ?", + [3, 4, 1, 2] + ) + + @defer.inlineCallbacks + def test_update_one_with_return(self): + self.mock_txn.rowcount = 1 + self.mock_txn.fetchone.return_value = ("Old Value",) + + ret = yield self.datastore._simple_update_one( + table="tablename", + keyvalues={"keycol": "TheKey"}, + updatevalues={"columname": "New Value"}, + retcols=["columname"] + ) + + self.assertEquals({"columname": "Old Value"}, ret) + self.mock_txn.execute.assert_has_calls([ + call('SELECT columname FROM tablename WHERE keycol = ?', + ['TheKey']), + call("UPDATE tablename SET columname = ? WHERE keycol = ?", + ["New Value", "TheKey"]) + ]) + + @defer.inlineCallbacks + def test_delete_one(self): + self.mock_txn.rowcount = 1 + + yield self.datastore._simple_delete_one( + table="tablename", + keyvalues={"keycol": "Go away"}, + ) + + self.mock_txn.execute.assert_called_with( + "DELETE FROM tablename WHERE keycol = ?", + ["Go away"] + ) diff --git a/tests/test_distributor.py b/tests/test_distributor.py new file mode 100644 index 0000000000..36cbf6c52d --- /dev/null +++ b/tests/test_distributor.py @@ -0,0 +1,74 @@ +import unittest + +from twisted.internet import defer + +from mock import Mock, patch + +from synapse.util.distributor import Distributor + + +class DistributorTestCase(unittest.TestCase): + + def setUp(self): + self.dist = Distributor() + + def test_signal_dispatch(self): + self.dist.declare("alert") + + observer = Mock() + self.dist.observe("alert", observer) + + d = self.dist.fire("alert", 1, 2, 3) + + self.assertTrue(d.called) + observer.assert_called_with(1, 2, 3) + + def test_signal_dispatch_deferred(self): + self.dist.declare("whine") + + d_inner = defer.Deferred() + def observer(): + return d_inner + self.dist.observe("whine", observer) + + d_outer = self.dist.fire("whine") + + self.assertFalse(d_outer.called) + + d_inner.callback(None) + self.assertTrue(d_outer.called) + + def test_signal_catch(self): + self.dist.declare("alarm") + + observers = [Mock() for i in 1, 2] + for o in observers: + self.dist.observe("alarm", o) + + observers[0].side_effect = Exception("Awoogah!") + + with patch("synapse.util.distributor.logger", + spec=["warning"] + ) as mock_logger: + d = self.dist.fire("alarm", "Go") + self.assertTrue(d.called) + + observers[0].assert_called_once("Go") + observers[1].assert_called_once("Go") + + self.assertEquals(mock_logger.warning.call_count, 1) + self.assertIsInstance(mock_logger.warning.call_args[0][0], + str) + + def test_signal_prereg(self): + observer = Mock() + self.dist.observe("flare", observer) + + self.dist.declare("flare") + self.dist.fire("flare", 4, 5) + + observer.assert_called_with(4, 5) + + def test_signal_undeclared(self): + with self.assertRaises(KeyError): + self.dist.fire("notification") diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000000..8d0251b1e7 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +from twisted.internet import defer +from twisted.trial import unittest + +from synapse.state import StateHandler +from synapse.storage.pdu import PduEntry +from synapse.federation.pdu_codec import encode_event_id + +from collections import namedtuple + +from mock import Mock + + +ReturnType = namedtuple( + "StateReturnType", ["new_branch", "current_branch"] +) + + +class StateTestCase(unittest.TestCase): + def setUp(self): + self.persistence = Mock(spec=[ + "get_unresolved_state_tree", + "update_current_state", + "get_latest_pdus_in_context", + "get_current_state", + ]) + self.replication = Mock(spec=["get_pdu"]) + + hs = Mock(spec=["get_datastore", "get_replication_layer"]) + hs.get_datastore.return_value = self.persistence + hs.get_replication_layer.return_value = self.replication + hs.hostname = "bob.com" + + self.state = StateHandler(hs) + + @defer.inlineCallbacks + def test_new_state_key(self): + # We've never seen anything for this state before + new_pdu = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + + self.persistence.get_unresolved_state_tree.return_value = ( + ReturnType([new_pdu], []) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + + @defer.inlineCallbacks + def test_direct_overwrite(self): + # We do a direct overwriting of the old state, i.e., the new state + # points to the old state. + + old_pdu = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + new_pdu = new_fake_pdu_entry("B", "test", "mem", "x", "A", 5) + + self.persistence.get_unresolved_state_tree.return_value = ( + ReturnType([new_pdu, old_pdu], [old_pdu]) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + + @defer.inlineCallbacks + def test_power_level_fail(self): + # We try to update the state based on an outdated state, and have a + # too low power level. + + old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) + new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 5) + + self.persistence.get_unresolved_state_tree.return_value = ( + ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertFalse(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(0, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + + @defer.inlineCallbacks + def test_power_level_succeed(self): + # We try to update the state based on an outdated state, but have + # sufficient power level to force the update. + + old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) + new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 15) + + self.persistence.get_unresolved_state_tree.return_value = ( + ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + + @defer.inlineCallbacks + def test_power_level_equal_same_len(self): + # We try to update the state based on an outdated state, the power + # levels are the same and so are the branch lengths + + old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) + new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 10) + + self.persistence.get_unresolved_state_tree.return_value = ( + ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + + @defer.inlineCallbacks + def test_power_level_equal_diff_len(self): + # We try to update the state based on an outdated state, the power + # levels are the same but the branch length of the new one is longer. + + old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) + old_pdu_3 = new_fake_pdu_entry("C", "test", "mem", "x", "A", 10) + new_pdu = new_fake_pdu_entry("D", "test", "mem", "x", "C", 10) + + self.persistence.get_unresolved_state_tree.return_value = ( + ReturnType([new_pdu, old_pdu_3, old_pdu_1], [old_pdu_2, old_pdu_1]) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + + @defer.inlineCallbacks + def test_missing_pdu(self): + # We try to update state against a PDU we haven't yet seen, + # triggering a get_pdu request + + # The pdu we haven't seen + old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + + old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) + new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 20) + + # The return_value of `get_unresolved_state_tree`, which changes after + # the call to get_pdu + tree_to_return = [ReturnType([new_pdu], [old_pdu_2])] + + def return_tree(p): + return tree_to_return[0] + + def set_return_tree(*args, **kwargs): + tree_to_return[0] = ReturnType( + [new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1] + ) + + self.persistence.get_unresolved_state_tree.side_effect = return_tree + + self.replication.get_pdu.side_effect = set_return_tree + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_with( + new_pdu + ) + + self.assertEquals( + 2, self.persistence.get_unresolved_state_tree.call_count + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + @defer.inlineCallbacks + def test_new_event(self): + event = Mock() + + state_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 20) + + tup = ("pdu_id", "origin.com", 5) + pdus = [tup] + + self.persistence.get_latest_pdus_in_context.return_value = pdus + self.persistence.get_current_state.return_value = state_pdu + + yield self.state.handle_new_event(event) + + self.assertLess(tup[2], event.depth) + + self.assertEquals(1, len(event.prev_events)) + + prev_id = event.prev_events[0] + + self.assertEqual(encode_event_id(tup[0], tup[1]), prev_id) + + self.assertEqual( + encode_event_id(state_pdu.pdu_id, state_pdu.origin), + event.prev_state + ) + + +def new_fake_pdu_entry(pdu_id, context, pdu_type, state_key, prev_state_id, + power_level): + new_pdu = PduEntry( + pdu_id=pdu_id, + pdu_type=pdu_type, + state_key=state_key, + power_level=power_level, + prev_state_id=prev_state_id, + origin="example.com", + context="context", + ts=1405353060021, + depth=0, + content_json="{}", + unrecognized_keys="{}", + outlier=True, + is_state=True, + prev_state_origin="example.com", + have_processed=True, + ) + + return new_pdu diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000000..0f3c9492d0 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,49 @@ +import unittest + +from synapse.server import BaseHomeServer +from synapse.types import UserID, RoomAlias + +mock_homeserver = BaseHomeServer(hostname="my.domain") + +class UserIDTestCase(unittest.TestCase): + + def test_parse(self): + user = UserID.from_string("@1234abcd:my.domain", hs=mock_homeserver) + + self.assertEquals("1234abcd", user.localpart) + self.assertEquals("my.domain", user.domain) + self.assertEquals(True, user.is_mine) + + def test_build(self): + user = UserID("5678efgh", "my.domain", True) + + self.assertEquals(user.to_string(), "@5678efgh:my.domain") + + def test_compare(self): + userA = UserID.from_string("@userA:my.domain", hs=mock_homeserver) + userAagain = UserID.from_string("@userA:my.domain", hs=mock_homeserver) + userB = UserID.from_string("@userB:my.domain", hs=mock_homeserver) + + self.assertTrue(userA == userAagain) + self.assertTrue(userA != userB) + + def test_via_homeserver(self): + user = mock_homeserver.parse_userid("@3456ijkl:my.domain") + + self.assertEquals("3456ijkl", user.localpart) + self.assertEquals("my.domain", user.domain) + + +class RoomAliasTestCase(unittest.TestCase): + + def test_parse(self): + room = RoomAlias.from_string("#channel:my.domain", hs=mock_homeserver) + + self.assertEquals("channel", room.localpart) + self.assertEquals("my.domain", room.domain) + self.assertEquals(True, room.is_mine) + + def test_build(self): + room = RoomAlias("channel", "my.domain", True) + + self.assertEquals(room.to_string(), "#channel:my.domain") diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/tests/util/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/util/test_lock.py b/tests/util/test_lock.py new file mode 100644 index 0000000000..b7b8779fd3 --- /dev/null +++ b/tests/util/test_lock.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- + +from twisted.internet import defer +from twisted.trial import unittest + +from synapse.util.lockutils import LockManager + + +class LockManagerTestCase(unittest.TestCase): + + def setUp(self): + self.lock_manager = LockManager() + + @defer.inlineCallbacks + def test_one_lock(self): + key = "test" + deferred_lock1 = self.lock_manager.lock(key) + + self.assertTrue(deferred_lock1.called) + + lock1 = yield deferred_lock1 + + self.assertFalse(lock1.released) + + lock1.release() + + self.assertTrue(lock1.released) + + @defer.inlineCallbacks + def test_concurrent_locks(self): + key = "test" + deferred_lock1 = self.lock_manager.lock(key) + deferred_lock2 = self.lock_manager.lock(key) + + self.assertTrue(deferred_lock1.called) + self.assertFalse(deferred_lock2.called) + + lock1 = yield deferred_lock1 + + self.assertFalse(lock1.released) + self.assertFalse(deferred_lock2.called) + + lock1.release() + + self.assertTrue(lock1.released) + self.assertTrue(deferred_lock2.called) + + lock2 = yield deferred_lock2 + + lock2.release() + + @defer.inlineCallbacks + def test_sequential_locks(self): + key = "test" + deferred_lock1 = self.lock_manager.lock(key) + + self.assertTrue(deferred_lock1.called) + + lock1 = yield deferred_lock1 + + self.assertFalse(lock1.released) + + lock1.release() + + self.assertTrue(lock1.released) + + deferred_lock2 = self.lock_manager.lock(key) + + self.assertTrue(deferred_lock2.called) + + lock2 = yield deferred_lock2 + + self.assertFalse(lock2.released) + + lock2.release() + + self.assertTrue(lock2.released) + + @defer.inlineCallbacks + def test_with_statement(self): + key = "test" + with (yield self.lock_manager.lock(key)) as lock: + self.assertFalse(lock.released) + + self.assertTrue(lock.released) + + @defer.inlineCallbacks + def test_two_with_statement(self): + key = "test" + with (yield self.lock_manager.lock(key)): + pass + + with (yield self.lock_manager.lock(key)): + pass \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000000..13f6b31c9a --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,252 @@ +from synapse.http.server import HttpServer +from synapse.api.errors import cs_error, CodeMessageException, StoreError +from synapse.api.constants import Membership + +from synapse.api.events.room import ( + RoomMemberEvent, MessageEvent +) + +from twisted.internet import defer + +from collections import namedtuple +from mock import patch, Mock +import json +import urlparse + + +class MockHttpServer(HttpServer): + + def __init__(self, prefix=""): + self.callbacks = [] # 3-tuple of method/pattern/function + self.prefix = prefix + + def trigger_get(self, path): + return self.trigger("GET", path, None) + + @patch('twisted.web.http.Request') + @defer.inlineCallbacks + def trigger(self, http_method, path, content, mock_request): + """ Fire an HTTP event. + + Args: + http_method : The HTTP method + path : The HTTP path + content : The HTTP body + mock_request : Mocked request to pass to the event so it can get + content. + Returns: + A tuple of (code, response) + Raises: + KeyError If no event is found which will handle the path. + """ + path = self.prefix + path + + # annoyingly we return a twisted http request which has chained calls + # to get at the http content, hence mock it here. + mock_content = Mock() + config = {'read.return_value': content} + mock_content.configure_mock(**config) + mock_request.content = mock_content + + # return the right path if the event requires it + mock_request.path = path + + # add in query params to the right place + try: + mock_request.args = urlparse.parse_qs(path.split('?')[1]) + mock_request.path = path.split('?')[0] + path = mock_request.path + except: + pass + + for (method, pattern, func) in self.callbacks: + if http_method != method: + continue + + matcher = pattern.match(path) + if matcher: + try: + (code, response) = yield func( + mock_request, + *matcher.groups() + ) + defer.returnValue((code, response)) + except CodeMessageException as e: + defer.returnValue((e.code, cs_error(e.msg))) + + raise KeyError("No event can handle %s" % path) + + def register_path(self, method, path_pattern, callback): + self.callbacks.append((method, path_pattern, callback)) + + +class MemoryDataStore(object): + + class RoomMember(namedtuple( + "RoomMember", + ["room_id", "user_id", "sender", "membership", "content"] + )): + def as_event(self, event_factory): + return event_factory.create_event( + etype=RoomMemberEvent.TYPE, + room_id=self.room_id, + target_user_id=self.user_id, + user_id=self.sender, + content=json.loads(self.content), + ) + + PathData = namedtuple("PathData", + ["room_id", "path", "content"]) + + Message = namedtuple("Message", + ["room_id", "msg_id", "user_id", "content"]) + + Room = namedtuple("Room", + ["room_id", "is_public", "creator"]) + + def __init__(self): + self.tokens_to_users = {} + self.paths_to_content = {} + self.members = {} + self.messages = {} + self.rooms = {} + self.room_members = {} + + def register(self, user_id, token, password_hash): + if user_id in self.tokens_to_users.values(): + raise StoreError(400, "User in use.") + self.tokens_to_users[token] = user_id + + def get_user_by_token(self, token): + try: + return self.tokens_to_users[token] + except: + raise StoreError(400, "User does not exist.") + + def get_room(self, room_id): + try: + return self.rooms[room_id] + except: + return None + + def store_room(self, room_id, room_creator_user_id, is_public): + if room_id in self.rooms: + raise StoreError(409, "Conflicting room!") + + room = MemoryDataStore.Room(room_id=room_id, is_public=is_public, + creator=room_creator_user_id) + self.rooms[room_id] = room + #self.store_room_member(user_id=room_creator_user_id, room_id=room_id, + #membership=Membership.JOIN, + #content={"membership": Membership.JOIN}) + + def get_message(self, user_id=None, room_id=None, msg_id=None): + try: + return self.messages[user_id + room_id + msg_id] + except: + return None + + def store_message(self, user_id=None, room_id=None, msg_id=None, + content=None): + msg = MemoryDataStore.Message(room_id=room_id, msg_id=msg_id, + user_id=user_id, content=content) + self.messages[user_id + room_id + msg_id] = msg + + def get_room_member(self, user_id=None, room_id=None): + try: + return self.members[user_id + room_id] + except: + return None + + def get_room_members(self, room_id=None, membership=None): + try: + return self.room_members[room_id] + except: + return None + + def get_rooms_for_user_where_membership_is(self, user_id, membership_list): + return [r for r in self.room_members + if user_id in self.room_members[r]] + + def store_room_member(self, user_id=None, sender=None, room_id=None, + membership=None, content=None): + member = MemoryDataStore.RoomMember(room_id=room_id, user_id=user_id, + sender=sender, membership=membership, content=json.dumps(content)) + self.members[user_id + room_id] = member + + # TODO should be latest state + if room_id not in self.room_members: + self.room_members[room_id] = [] + self.room_members[room_id].append(member) + + def get_room_data(self, room_id, etype, state_key=""): + path = "%s-%s-%s" % (room_id, etype, state_key) + try: + return self.paths_to_content[path] + except: + return None + + def store_room_data(self, room_id, etype, state_key="", content=None): + path = "%s-%s-%s" % (room_id, etype, state_key) + data = MemoryDataStore.PathData(path=path, room_id=room_id, + content=content) + self.paths_to_content[path] = data + + def get_message_stream(self, user_id=None, from_key=None, to_key=None, + room_id=None, limit=0, with_feedback=False): + return ([], from_key) # TODO + + def get_room_member_stream(self, user_id=None, from_key=None, to_key=None): + return ([], from_key) # TODO + + def get_feedback_stream(self, user_id=None, from_key=None, to_key=None, + room_id=None, limit=0): + return ([], from_key) # TODO + + def get_room_data_stream(self, user_id=None, from_key=None, to_key=None, + room_id=None, limit=0): + return ([], from_key) # TODO + + def to_events(self, data_store_list): + return data_store_list # TODO + + def get_max_message_id(self): + return 0 # TODO + + def get_max_feedback_id(self): + return 0 # TODO + + def get_max_room_member_id(self): + return 0 # TODO + + def get_max_room_data_id(self): + return 0 # TODO + + def get_joined_hosts_for_room(self, room_id): + return defer.succeed([]) + + def persist_event(self, event): + if event.type == MessageEvent.TYPE: + return self.store_message( + user_id=event.user_id, + room_id=event.room_id, + msg_id=event.msg_id, + content=json.dumps(event.content) + ) + elif event.type == RoomMemberEvent.TYPE: + return self.store_room_member( + user_id=event.target_user_id, + room_id=event.room_id, + content=event.content, + membership=event.content["membership"] + ) + else: + raise NotImplementedError( + "Don't know how to persist type=%s" % event.type + ) + + def set_presence_state(self, user_localpart, state): + return defer.succeed({"state": 0}) + + def get_presence_list(self, user_localpart, accepted): + return [] diff --git a/webclient/README b/webclient/README new file mode 100644 index 0000000000..0f893b1712 --- /dev/null +++ b/webclient/README @@ -0,0 +1,12 @@ +Basic Usage +----------- + +The Synapse web client needs to be hosted by a basic HTTP server. + +You can use the Python simple HTTP server:: + + $ python -m SimpleHTTPServer + +Then, open this URL in a WEB browser:: + + http://127.0.0.1:8000/ diff --git a/webclient/app-controller.js b/webclient/app-controller.js new file mode 100644 index 0000000000..e7601cc213 --- /dev/null +++ b/webclient/app-controller.js @@ -0,0 +1,46 @@ +/* + * Main controller + */ + +'use strict'; + +angular.module('MatrixWebClientController', ['matrixService']) +.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', 'matrixService', + function($scope, $location, $rootScope, matrixService) { + + // Check current URL to avoid to display the logout button on the login page + $scope.location = $location.path(); + + // Update the location state when the ng location changed + $rootScope.$on('$routeChangeSuccess', function (event, current, previous) { + $scope.location = $location.path(); + }); + + + // Manage the display of the current config + $scope.config; + + // Toggles the config display + $scope.showConfig = function() { + if ($scope.config) { + $scope.config = undefined; + } + else { + $scope.config = matrixService.config(); + } + }; + + + // Logs the user out + $scope.logout = function() { + // Clean permanent data + matrixService.setConfig({}); + matrixService.saveConfig(); + + // And go to the login page + $location.path("login"); + }; + +}]); + + \ No newline at end of file diff --git a/webclient/app.css b/webclient/app.css new file mode 100644 index 0000000000..6a9ea25b33 --- /dev/null +++ b/webclient/app.css @@ -0,0 +1,228 @@ +body { + font-family: "Myriad Pro", "Myriad", Helvetica, Arial, sans-serif; + font-size: 12pt; + margin: 0px; +} + +h1 { + font-family: Helvetica, Arial, sans-serif; +} + +/*** Overall page layout ***/ + +.page { + max-width: 1280px; + margin: auto; + margin-bottom: 80px ! important; + padding-left: 20px; + padding-right: 20px; +} + +.roomName { + text-align: right; + font-size: 16pt; + margin-bottom: 10px; +} + +.controlPanel { + position: fixed; + bottom: 0px; + width: 100%; + background-color: #f8f8f8; + border-top: #aaa 1px solid; +} + +.controls { + max-width: 1280px; + padding: 12px; + margin: auto; +} + +.inputBarTable { + width: 100%; +} + +.inputBarTable tr td { + padding: 4px; +} + +.mainInput { + width: 100%; +} + +/*** Participant list ***/ + +.usersTable { + float: right; + width: 120px; + border-collapse: collapse; + margin-bottom: 150px; +} + +.usersTable td { + padding: 0px; +} + +.userAvatar { + width: 80px; + height: 80px; + position: relative; +} + +.userAvatar .userAvatarImage { + position: absolute; + top: 0px; +} + +.userAvatar .userAvatarGradient { + position: absolute; + bottom: 0px; +} + +.userAvatar .userName { + position: absolute; + color: #fff; + margin: 2px; + bottom: 0px; + font-size: 8pt; + word-wrap: break-word; + word-break: break-all; +} + +.userPresence { + text-align: center; + font-size: 8pt; + color: #fff; + background-color: #aaa; + border-bottom: 1px #ddd solid; +} + +.online { + background-color: #38AF00; +} + +.away { + background-color: #FFCC00; +} + + +/*** Message table ***/ + +.messageTableWrapper { + width: auto; + margin-right: 140px; +} + +.messageTable { + width: 100%; + border-collapse: collapse; + margin-bottom: 100px; +} + +.messageTable td { + padding: 0px; +} + +.leftBlock { + width: 1px; + vertical-align: top; + background-color: #fff; + color: #888; + font-weight: medium; + font-size: 8pt; + text-align: right; + border-top: 1px #ddd solid; +} + +.rightBlock { + width: 32px; + color: #888; + line-height: 0; + vertical-align: top; +} + +.sender, .timestamp { + padding-right: 1em; + padding-left: 1em; + padding-top: 3px; +} + +.sender { + margin-bottom: -3px; +} + +.avatar { + width: 48px; + text-align: right; + vertical-align: top; + line-height: 0; +} + +.text { + background-color: #eee; + border: 1px solid #d8d8d8; + height: 32px; + display: inline-table; + max-width: 90%; +} + +.emote { + background-color: #fff ! important; + border: 0px ! important; +} + +.bubble { + padding: 6px; + padding-left: 1em; + padding-right: 1em; +} + +.mine { + text-align: right; +} + +.mine .text .bubble { + text-align: left ! important; + background-color: #d8d8e8 ! important; +} + +.mine .emote .bubble { + background-color: #fff ! important; +} + +/******************************/ + +.header { + margin-top: 12px ! important; + padding-left: 20px; + padding-right: 20px; + max-width: 1280px; + margin: auto; + height: 60px; +} + +.header-buttons { + float: right; +} + +.text_entry_section { + position: fixed; + bottom: 0; + z-index: 100; + left: 0; + right: 10em; + width: 100%; + background: #e0e0e0; +} + +.member_invited { + color: blue; +} + +.member_joined { + +} + +.member_left { + color: gray; +} diff --git a/webclient/app.js b/webclient/app.js new file mode 100644 index 0000000000..2a75560edd --- /dev/null +++ b/webclient/app.js @@ -0,0 +1,57 @@ +var matrixWebClient = angular.module('matrixWebClient', [ + 'ngRoute', + 'MatrixWebClientController', + 'LoginController', + 'RoomController', + 'RoomsController', + 'matrixService' +]); + +matrixWebClient.config(['$routeProvider', + function($routeProvider) { + $routeProvider. + when('/login', { + templateUrl: 'login/login.html', + controller: 'LoginController' + }). + when('/room/:room_id', { + templateUrl: 'room/room.html', + controller: 'RoomController' + }). + when('/rooms', { + templateUrl: 'rooms/rooms.html', + controller: 'RoomsController' + }). + otherwise({ + redirectTo: '/rooms' + }); + }]); + +matrixWebClient.run(['$location', 'matrixService' , function($location, matrixService) { + // If we have no persistent login information, go to the login page + var config = matrixService.config(); + if (!config || !config.access_token) { + $location.path("login"); + } +}]); + +matrixWebClient + .directive('ngEnter', function () { + return function (scope, element, attrs) { + element.bind("keydown keypress", function (event) { + if(event.which === 13) { + scope.$apply(function () { + scope.$eval(attrs.ngEnter); + }); + event.preventDefault(); + } + }); + }; + }) + .directive('ngFocus', ['$timeout', function($timeout) { + return { + link: function(scope, element, attr) { + $timeout(function() { element[0].focus() }, 0); + } + }; + }]); diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js new file mode 100644 index 0000000000..bbfffe75e7 --- /dev/null +++ b/webclient/components/matrix/matrix-service.js @@ -0,0 +1,307 @@ +'use strict'; + +angular.module('matrixService', []) +.factory('matrixService', ['$http', '$q', function($http, $q) { + + /* + * Permanent storage of user information + * The config contains: + * - homeserver url + * - Identity server url + * - user_id + * - access_token + * - version: the version of this cache + */ + var config; + + // Current version of permanent storage + var configVersion = 0; + var prefixPath = "/matrix/client/api/v1"; + var MAPPING_PREFIX = "alias_for_"; + + var doRequest = function(method, path, params, data) { + // Inject the access token + if (!params) { + params = {}; + } + params.access_token = config.access_token; + + return doBaseRequest(config.homeserver, method, path, params, data, undefined); + }; + + var doBaseRequest = function(baseUrl, method, path, params, data, headers) { + if (path.indexOf(prefixPath) !== 0) { + path = prefixPath + path; + } + // Do not directly return the $http instance but return a promise + // with enriched or cleaned information + var deferred = $q.defer(); + $http({ + method: method, + url: baseUrl + path, + params: params, + data: data, + headers: headers + }) + .success(function(data, status, headers, config) { + // @TODO: We could detect a bad access token here and make an automatic logout + deferred.resolve(data, status, headers, config); + }) + .error(function(data, status, headers, config) { + // Enrich the error callback with an human readable error reason + var reason = data.error; + if (!data.error) { + reason = JSON.stringify(data); + } + deferred.reject(reason, data, status, headers, config); + }); + + return deferred.promise; + }; + + + return { + /****** Home server API ******/ + prefix: prefixPath, + + // Register an user + register: function(user_name, password) { + // The REST path spec + var path = "/register"; + + return doRequest("POST", path, undefined, { + user_id: user_name, + password: password + }); + }, + + // Create a room + create: function(room_id, visibility) { + // The REST path spec + var path = "/rooms"; + + return doRequest("POST", path, undefined, { + visibility: visibility, + room_alias_name: room_id + }); + }, + + // List all rooms joined or been invited to + rooms: function(from, to, limit) { + // The REST path spec + var path = "/im/sync"; + + return doRequest("GET", path); + }, + + // Joins a room + join: function(room_id) { + // The REST path spec + var path = "/rooms/$room_id/members/$user_id/state"; + + // Like the cmd client, escape room ids + room_id = encodeURIComponent(room_id); + + // Customize it + path = path.replace("$room_id", room_id); + path = path.replace("$user_id", config.user_id); + + return doRequest("PUT", path, undefined, { + membership: "join" + }); + }, + + // Invite a user to a room + invite: function(room_id, user_id) { + // The REST path spec + var path = "/rooms/$room_id/members/$user_id/state"; + + // Like the cmd client, escape room ids + room_id = encodeURIComponent(room_id); + + // Customize it + path = path.replace("$room_id", room_id); + path = path.replace("$user_id", user_id); + + return doRequest("PUT", path, undefined, { + membership: "invite" + }); + }, + + // Leaves a room + leave: function(room_id) { + // The REST path spec + var path = "/rooms/$room_id/members/$user_id/state"; + + // Like the cmd client, escape room ids + room_id = encodeURIComponent(room_id); + + // Customize it + path = path.replace("$room_id", room_id); + path = path.replace("$user_id", config.user_id); + + return doRequest("DELETE", path, undefined, undefined); + }, + + sendMessage: function(room_id, msg_id, content) { + // The REST path spec + var path = "/rooms/$room_id/messages/$from/$msg_id"; + + if (!msg_id) { + msg_id = "m" + new Date().getTime(); + } + + // Like the cmd client, escape room ids + room_id = encodeURIComponent(room_id); + + // Customize it + path = path.replace("$room_id", room_id); + path = path.replace("$from", config.user_id); + path = path.replace("$msg_id", msg_id); + + return doRequest("PUT", path, undefined, content); + }, + + // Send a text message + sendTextMessage: function(room_id, body, msg_id) { + var content = { + msgtype: "m.text", + body: body + }; + + return this.sendMessage(room_id, msg_id, content); + }, + + // Send an emote message + sendEmoteMessage: function(room_id, body, msg_id) { + var content = { + msgtype: "m.emote", + body: body + }; + + return this.sendMessage(room_id, msg_id, content); + }, + + // get a snapshot of the members in a room. + getMemberList: function(room_id) { + // Like the cmd client, escape room ids + room_id = encodeURIComponent(room_id); + + var path = "/rooms/$room_id/members/list"; + path = path.replace("$room_id", room_id); + return doRequest("GET", path); + }, + + // get a list of public rooms on your home server + publicRooms: function() { + var path = "/public/rooms" + return doRequest("GET", path); + }, + + // get a display name for this user ID + getDisplayName: function(userId) { + return this.getProfileInfo(userId, "displayname"); + }, + + // get the profile picture url for this user ID + getProfilePictureUrl: function(userId) { + return this.getProfileInfo(userId, "avatar_url"); + }, + + // update your display name + setDisplayName: function(newName) { + var content = { + displayname: newName + }; + return this.setProfileInfo(content, "displayname"); + }, + + // update your profile picture url + setProfilePictureUrl: function(newUrl) { + var content = { + avatar_url: newUrl + }; + return this.setProfileInfo(content, "avatar_url"); + }, + + setProfileInfo: function(data, info_segment) { + var path = "/profile/$user/" + info_segment; + path = path.replace("$user", config.user_id); + return doRequest("PUT", path, undefined, data); + }, + + getProfileInfo: function(userId, info_segment) { + var path = "/profile/$user_id/" + info_segment; + path = path.replace("$user_id", userId); + return doRequest("GET", path); + }, + + login: function(userId, password) { + // TODO We should be checking to make sure the client can support + // logging in to this HS, else use the fallback. + var path = "/login"; + var data = { + "type": "m.login.password", + "user": userId, + "password": password + }; + return doRequest("POST", path, undefined, data); + }, + + // hit the Identity Server for a 3PID request. + linkEmail: function(email) { + var path = "/matrix/identity/api/v1/validate/email/requestToken" + var data = "clientSecret=abc123&email=" + encodeURIComponent(email); + var headers = {}; + headers["Content-Type"] = "application/x-www-form-urlencoded"; + return doBaseRequest(config.identityServer, "POST", path, {}, data, headers); + }, + + authEmail: function(userId, tokenId, code) { + var path = "/matrix/identity/api/v1/validate/email/submitToken"; + var data = "token="+code+"&mxId="+encodeURIComponent(userId)+"&tokenId="+tokenId; + var headers = {}; + headers["Content-Type"] = "application/x-www-form-urlencoded"; + return doBaseRequest(config.identityServer, "POST", path, {}, data, headers); + }, + + /****** Permanent storage of user information ******/ + + // Returns the current config + config: function() { + if (!config) { + config = localStorage.getItem("config"); + if (config) { + config = JSON.parse(config); + + // Reset the cache if the version loaded is not the expected one + if (configVersion !== config.version) { + config = undefined; + this.saveConfig(); + } + } + } + return config; + }, + + // Set a new config (Use saveConfig to actually store it permanently) + setConfig: function(newConfig) { + config = newConfig; + }, + + // Commits config into permanent storage + saveConfig: function() { + config.version = configVersion; + localStorage.setItem("config", JSON.stringify(config)); + }, + + createRoomIdToAliasMapping: function(roomId, alias) { + localStorage.setItem(MAPPING_PREFIX+roomId, alias); + }, + + getRoomIdToAliasMapping: function(roomId) { + return localStorage.getItem(MAPPING_PREFIX+roomId); + } + + }; +}]); diff --git a/webclient/favicon.ico b/webclient/favicon.ico new file mode 100644 index 0000000000..ba193fabc8 Binary files /dev/null and b/webclient/favicon.ico differ diff --git a/webclient/img/default-profile.jpg b/webclient/img/default-profile.jpg new file mode 100644 index 0000000000..20f2a2b085 Binary files /dev/null and b/webclient/img/default-profile.jpg differ diff --git a/webclient/img/default-profile.png b/webclient/img/default-profile.png new file mode 100644 index 0000000000..90629d4fd0 Binary files /dev/null and b/webclient/img/default-profile.png differ diff --git a/webclient/img/gradient.png b/webclient/img/gradient.png new file mode 100644 index 0000000000..8ac9e2193f Binary files /dev/null and b/webclient/img/gradient.png differ diff --git a/webclient/index.html b/webclient/index.html new file mode 100644 index 0000000000..ddc9ab5e32 --- /dev/null +++ b/webclient/index.html @@ -0,0 +1,41 @@ + + + + [matrix] + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +

[matrix]

+
+ +
+
Home server: {{ config.homeserver }}
+
User ID: {{ config.user_id }}
+
Access token: {{ config.access_token }}
+
+ + +
+ + + diff --git a/webclient/js/angular-animate.js b/webclient/js/angular-animate.js new file mode 100644 index 0000000000..bea4bc5232 --- /dev/null +++ b/webclient/js/angular-animate.js @@ -0,0 +1,1689 @@ +/** + * @license AngularJS v1.2.20 + * (c) 2010-2014 Google, Inc. http://angularjs.org + * License: MIT + */ +(function(window, angular, undefined) {'use strict'; + +/* jshint maxlen: false */ + +/** + * @ngdoc module + * @name ngAnimate + * @description + * + * # ngAnimate + * + * The `ngAnimate` module provides support for JavaScript, CSS3 transition and CSS3 keyframe animation hooks within existing core and custom directives. + * + * + *
+ * + * # Usage + * + * To see animations in action, all that is required is to define the appropriate CSS classes + * or to register a JavaScript animation via the myModule.animation() function. The directives that support animation automatically are: + * `ngRepeat`, `ngInclude`, `ngIf`, `ngSwitch`, `ngShow`, `ngHide`, `ngView` and `ngClass`. Custom directives can take advantage of animation + * by using the `$animate` service. + * + * Below is a more detailed breakdown of the supported animation events provided by pre-existing ng directives: + * + * | Directive | Supported Animations | + * |---------------------------------------------------------- |----------------------------------------------------| + * | {@link ng.directive:ngRepeat#usage_animations ngRepeat} | enter, leave and move | + * | {@link ngRoute.directive:ngView#usage_animations ngView} | enter and leave | + * | {@link ng.directive:ngInclude#usage_animations ngInclude} | enter and leave | + * | {@link ng.directive:ngSwitch#usage_animations ngSwitch} | enter and leave | + * | {@link ng.directive:ngIf#usage_animations ngIf} | enter and leave | + * | {@link ng.directive:ngClass#usage_animations ngClass} | add and remove | + * | {@link ng.directive:ngShow#usage_animations ngShow & ngHide} | add and remove (the ng-hide class value) | + * | {@link ng.directive:form#usage_animations form} | add and remove (dirty, pristine, valid, invalid & all other validations) | + * | {@link ng.directive:ngModel#usage_animations ngModel} | add and remove (dirty, pristine, valid, invalid & all other validations) | + * + * You can find out more information about animations upon visiting each directive page. + * + * Below is an example of how to apply animations to a directive that supports animation hooks: + * + * ```html + * + * + * + * + * ``` + * + * Keep in mind that, by default, if an animation is running, any child elements cannot be animated + * until the parent element's animation has completed. This blocking feature can be overridden by + * placing the `ng-animate-children` attribute on a parent container tag. + * + * ```html + *
+ *
+ *
+ * ... + *
+ *
+ *
+ * ``` + * + * When the `on` expression value changes and an animation is triggered then each of the elements within + * will all animate without the block being applied to child elements. + * + *

CSS-defined Animations

+ * The animate service will automatically apply two CSS classes to the animated element and these two CSS classes + * are designed to contain the start and end CSS styling. Both CSS transitions and keyframe animations are supported + * and can be used to play along with this naming structure. + * + * The following code below demonstrates how to perform animations using **CSS transitions** with Angular: + * + * ```html + * + * + *
+ *
+ *
+ * ``` + * + * The following code below demonstrates how to perform animations using **CSS animations** with Angular: + * + * ```html + * + * + *
+ *
+ *
+ * ``` + * + * Both CSS3 animations and transitions can be used together and the animate service will figure out the correct duration and delay timing. + * + * Upon DOM mutation, the event class is added first (something like `ng-enter`), then the browser prepares itself to add + * the active class (in this case `ng-enter-active`) which then triggers the animation. The animation module will automatically + * detect the CSS code to determine when the animation ends. Once the animation is over then both CSS classes will be + * removed from the DOM. If a browser does not support CSS transitions or CSS animations then the animation will start and end + * immediately resulting in a DOM element that is at its final state. This final state is when the DOM element + * has no CSS transition/animation classes applied to it. + * + *

CSS Staggering Animations

+ * A Staggering animation is a collection of animations that are issued with a slight delay in between each successive operation resulting in a + * curtain-like effect. The ngAnimate module, as of 1.2.0, supports staggering animations and the stagger effect can be + * performed by creating a **ng-EVENT-stagger** CSS class and attaching that class to the base CSS class used for + * the animation. The style property expected within the stagger class can either be a **transition-delay** or an + * **animation-delay** property (or both if your animation contains both transitions and keyframe animations). + * + * ```css + * .my-animation.ng-enter { + * /* standard transition code */ + * -webkit-transition: 1s linear all; + * transition: 1s linear all; + * opacity:0; + * } + * .my-animation.ng-enter-stagger { + * /* this will have a 100ms delay between each successive leave animation */ + * -webkit-transition-delay: 0.1s; + * transition-delay: 0.1s; + * + * /* in case the stagger doesn't work then these two values + * must be set to 0 to avoid an accidental CSS inheritance */ + * -webkit-transition-duration: 0s; + * transition-duration: 0s; + * } + * .my-animation.ng-enter.ng-enter-active { + * /* standard transition styles */ + * opacity:1; + * } + * ``` + * + * Staggering animations work by default in ngRepeat (so long as the CSS class is defined). Outside of ngRepeat, to use staggering animations + * on your own, they can be triggered by firing multiple calls to the same event on $animate. However, the restrictions surrounding this + * are that each of the elements must have the same CSS className value as well as the same parent element. A stagger operation + * will also be reset if more than 10ms has passed after the last animation has been fired. + * + * The following code will issue the **ng-leave-stagger** event on the element provided: + * + * ```js + * var kids = parent.children(); + * + * $animate.leave(kids[0]); //stagger index=0 + * $animate.leave(kids[1]); //stagger index=1 + * $animate.leave(kids[2]); //stagger index=2 + * $animate.leave(kids[3]); //stagger index=3 + * $animate.leave(kids[4]); //stagger index=4 + * + * $timeout(function() { + * //stagger has reset itself + * $animate.leave(kids[5]); //stagger index=0 + * $animate.leave(kids[6]); //stagger index=1 + * }, 100, false); + * ``` + * + * Stagger animations are currently only supported within CSS-defined animations. + * + *

JavaScript-defined Animations

+ * In the event that you do not want to use CSS3 transitions or CSS3 animations or if you wish to offer animations on browsers that do not + * yet support CSS transitions/animations, then you can make use of JavaScript animations defined inside of your AngularJS module. + * + * ```js + * //!annotate="YourApp" Your AngularJS Module|Replace this or ngModule with the module that you used to define your application. + * var ngModule = angular.module('YourApp', ['ngAnimate']); + * ngModule.animation('.my-crazy-animation', function() { + * return { + * enter: function(element, done) { + * //run the animation here and call done when the animation is complete + * return function(cancelled) { + * //this (optional) function will be called when the animation + * //completes or when the animation is cancelled (the cancelled + * //flag will be set to true if cancelled). + * }; + * }, + * leave: function(element, done) { }, + * move: function(element, done) { }, + * + * //animation that can be triggered before the class is added + * beforeAddClass: function(element, className, done) { }, + * + * //animation that can be triggered after the class is added + * addClass: function(element, className, done) { }, + * + * //animation that can be triggered before the class is removed + * beforeRemoveClass: function(element, className, done) { }, + * + * //animation that can be triggered after the class is removed + * removeClass: function(element, className, done) { } + * }; + * }); + * ``` + * + * JavaScript-defined animations are created with a CSS-like class selector and a collection of events which are set to run + * a javascript callback function. When an animation is triggered, $animate will look for a matching animation which fits + * the element's CSS class attribute value and then run the matching animation event function (if found). + * In other words, if the CSS classes present on the animated element match any of the JavaScript animations then the callback function will + * be executed. It should be also noted that only simple, single class selectors are allowed (compound class selectors are not supported). + * + * Within a JavaScript animation, an object containing various event callback animation functions is expected to be returned. + * As explained above, these callbacks are triggered based on the animation event. Therefore if an enter animation is run, + * and the JavaScript animation is found, then the enter callback will handle that animation (in addition to the CSS keyframe animation + * or transition code that is defined via a stylesheet). + * + */ + +angular.module('ngAnimate', ['ng']) + + /** + * @ngdoc provider + * @name $animateProvider + * @description + * + * The `$animateProvider` allows developers to register JavaScript animation event handlers directly inside of a module. + * When an animation is triggered, the $animate service will query the $animate service to find any animations that match + * the provided name value. + * + * Requires the {@link ngAnimate `ngAnimate`} module to be installed. + * + * Please visit the {@link ngAnimate `ngAnimate`} module overview page learn more about how to use animations in your application. + * + */ + .directive('ngAnimateChildren', function() { + var NG_ANIMATE_CHILDREN = '$$ngAnimateChildren'; + return function(scope, element, attrs) { + var val = attrs.ngAnimateChildren; + if(angular.isString(val) && val.length === 0) { //empty attribute + element.data(NG_ANIMATE_CHILDREN, true); + } else { + scope.$watch(val, function(value) { + element.data(NG_ANIMATE_CHILDREN, !!value); + }); + } + }; + }) + + //this private service is only used within CSS-enabled animations + //IE8 + IE9 do not support rAF natively, but that is fine since they + //also don't support transitions and keyframes which means that the code + //below will never be used by the two browsers. + .factory('$$animateReflow', ['$$rAF', '$document', function($$rAF, $document) { + var bod = $document[0].body; + return function(fn) { + //the returned function acts as the cancellation function + return $$rAF(function() { + //the line below will force the browser to perform a repaint + //so that all the animated elements within the animation frame + //will be properly updated and drawn on screen. This is + //required to perform multi-class CSS based animations with + //Firefox. DO NOT REMOVE THIS LINE. + var a = bod.offsetWidth + 1; + fn(); + }); + }; + }]) + + .config(['$provide', '$animateProvider', function($provide, $animateProvider) { + var noop = angular.noop; + var forEach = angular.forEach; + var selectors = $animateProvider.$$selectors; + + var ELEMENT_NODE = 1; + var NG_ANIMATE_STATE = '$$ngAnimateState'; + var NG_ANIMATE_CHILDREN = '$$ngAnimateChildren'; + var NG_ANIMATE_CLASS_NAME = 'ng-animate'; + var rootAnimateState = {running: true}; + + function extractElementNode(element) { + for(var i = 0; i < element.length; i++) { + var elm = element[i]; + if(elm.nodeType == ELEMENT_NODE) { + return elm; + } + } + } + + function prepareElement(element) { + return element && angular.element(element); + } + + function stripCommentsFromElement(element) { + return angular.element(extractElementNode(element)); + } + + function isMatchingElement(elm1, elm2) { + return extractElementNode(elm1) == extractElementNode(elm2); + } + + $provide.decorator('$animate', ['$delegate', '$injector', '$sniffer', '$rootElement', '$$asyncCallback', '$rootScope', '$document', + function($delegate, $injector, $sniffer, $rootElement, $$asyncCallback, $rootScope, $document) { + + var globalAnimationCounter = 0; + $rootElement.data(NG_ANIMATE_STATE, rootAnimateState); + + // disable animations during bootstrap, but once we bootstrapped, wait again + // for another digest until enabling animations. The reason why we digest twice + // is because all structural animations (enter, leave and move) all perform a + // post digest operation before animating. If we only wait for a single digest + // to pass then the structural animation would render its animation on page load. + // (which is what we're trying to avoid when the application first boots up.) + $rootScope.$$postDigest(function() { + $rootScope.$$postDigest(function() { + rootAnimateState.running = false; + }); + }); + + var classNameFilter = $animateProvider.classNameFilter(); + var isAnimatableClassName = !classNameFilter + ? function() { return true; } + : function(className) { + return classNameFilter.test(className); + }; + + function blockElementAnimations(element) { + var data = element.data(NG_ANIMATE_STATE) || {}; + data.running = true; + element.data(NG_ANIMATE_STATE, data); + } + + function lookup(name) { + if (name) { + var matches = [], + flagMap = {}, + classes = name.substr(1).split('.'); + + //the empty string value is the default animation + //operation which performs CSS transition and keyframe + //animations sniffing. This is always included for each + //element animation procedure if the browser supports + //transitions and/or keyframe animations. The default + //animation is added to the top of the list to prevent + //any previous animations from affecting the element styling + //prior to the element being animated. + if ($sniffer.transitions || $sniffer.animations) { + matches.push($injector.get(selectors[''])); + } + + for(var i=0; i < classes.length; i++) { + var klass = classes[i], + selectorFactoryName = selectors[klass]; + if(selectorFactoryName && !flagMap[klass]) { + matches.push($injector.get(selectorFactoryName)); + flagMap[klass] = true; + } + } + return matches; + } + } + + function animationRunner(element, animationEvent, className) { + //transcluded directives may sometimes fire an animation using only comment nodes + //best to catch this early on to prevent any animation operations from occurring + var node = element[0]; + if(!node) { + return; + } + + var isSetClassOperation = animationEvent == 'setClass'; + var isClassBased = isSetClassOperation || + animationEvent == 'addClass' || + animationEvent == 'removeClass'; + + var classNameAdd, classNameRemove; + if(angular.isArray(className)) { + classNameAdd = className[0]; + classNameRemove = className[1]; + className = classNameAdd + ' ' + classNameRemove; + } + + var currentClassName = element.attr('class'); + var classes = currentClassName + ' ' + className; + if(!isAnimatableClassName(classes)) { + return; + } + + var beforeComplete = noop, + beforeCancel = [], + before = [], + afterComplete = noop, + afterCancel = [], + after = []; + + var animationLookup = (' ' + classes).replace(/\s+/g,'.'); + forEach(lookup(animationLookup), function(animationFactory) { + var created = registerAnimation(animationFactory, animationEvent); + if(!created && isSetClassOperation) { + registerAnimation(animationFactory, 'addClass'); + registerAnimation(animationFactory, 'removeClass'); + } + }); + + function registerAnimation(animationFactory, event) { + var afterFn = animationFactory[event]; + var beforeFn = animationFactory['before' + event.charAt(0).toUpperCase() + event.substr(1)]; + if(afterFn || beforeFn) { + if(event == 'leave') { + beforeFn = afterFn; + //when set as null then animation knows to skip this phase + afterFn = null; + } + after.push({ + event : event, fn : afterFn + }); + before.push({ + event : event, fn : beforeFn + }); + return true; + } + } + + function run(fns, cancellations, allCompleteFn) { + var animations = []; + forEach(fns, function(animation) { + animation.fn && animations.push(animation); + }); + + var count = 0; + function afterAnimationComplete(index) { + if(cancellations) { + (cancellations[index] || noop)(); + if(++count < animations.length) return; + cancellations = null; + } + allCompleteFn(); + } + + //The code below adds directly to the array in order to work with + //both sync and async animations. Sync animations are when the done() + //operation is called right away. DO NOT REFACTOR! + forEach(animations, function(animation, index) { + var progress = function() { + afterAnimationComplete(index); + }; + switch(animation.event) { + case 'setClass': + cancellations.push(animation.fn(element, classNameAdd, classNameRemove, progress)); + break; + case 'addClass': + cancellations.push(animation.fn(element, classNameAdd || className, progress)); + break; + case 'removeClass': + cancellations.push(animation.fn(element, classNameRemove || className, progress)); + break; + default: + cancellations.push(animation.fn(element, progress)); + break; + } + }); + + if(cancellations && cancellations.length === 0) { + allCompleteFn(); + } + } + + return { + node : node, + event : animationEvent, + className : className, + isClassBased : isClassBased, + isSetClassOperation : isSetClassOperation, + before : function(allCompleteFn) { + beforeComplete = allCompleteFn; + run(before, beforeCancel, function() { + beforeComplete = noop; + allCompleteFn(); + }); + }, + after : function(allCompleteFn) { + afterComplete = allCompleteFn; + run(after, afterCancel, function() { + afterComplete = noop; + allCompleteFn(); + }); + }, + cancel : function() { + if(beforeCancel) { + forEach(beforeCancel, function(cancelFn) { + (cancelFn || noop)(true); + }); + beforeComplete(true); + } + if(afterCancel) { + forEach(afterCancel, function(cancelFn) { + (cancelFn || noop)(true); + }); + afterComplete(true); + } + } + }; + } + + /** + * @ngdoc service + * @name $animate + * @kind function + * + * @description + * The `$animate` service provides animation detection support while performing DOM operations (enter, leave and move) as well as during addClass and removeClass operations. + * When any of these operations are run, the $animate service + * will examine any JavaScript-defined animations (which are defined by using the $animateProvider provider object) + * as well as any CSS-defined animations against the CSS classes present on the element once the DOM operation is run. + * + * The `$animate` service is used behind the scenes with pre-existing directives and animation with these directives + * will work out of the box without any extra configuration. + * + * Requires the {@link ngAnimate `ngAnimate`} module to be installed. + * + * Please visit the {@link ngAnimate `ngAnimate`} module overview page learn more about how to use animations in your application. + * + */ + return { + /** + * @ngdoc method + * @name $animate#enter + * @kind function + * + * @description + * Appends the element to the parentElement element that resides in the document and then runs the enter animation. Once + * the animation is started, the following CSS classes will be present on the element for the duration of the animation: + * + * Below is a breakdown of each step that occurs during enter animation: + * + * | Animation Step | What the element class attribute looks like | + * |----------------------------------------------------------------------------------------------|---------------------------------------------| + * | 1. $animate.enter(...) is called | class="my-animation" | + * | 2. element is inserted into the parentElement element or beside the afterElement element | class="my-animation" | + * | 3. $animate runs any JavaScript-defined animations on the element | class="my-animation ng-animate" | + * | 4. the .ng-enter class is added to the element | class="my-animation ng-animate ng-enter" | + * | 5. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation ng-animate ng-enter" | + * | 6. $animate waits for 10ms (this performs a reflow) | class="my-animation ng-animate ng-enter" | + * | 7. the .ng-enter-active and .ng-animate-active classes are added (this triggers the CSS transition/animation) | class="my-animation ng-animate ng-animate-active ng-enter ng-enter-active" | + * | 8. $animate waits for X milliseconds for the animation to complete | class="my-animation ng-animate ng-animate-active ng-enter ng-enter-active" | + * | 9. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | + * | 10. The doneCallback() callback is fired (if provided) | class="my-animation" | + * + * @param {DOMElement} element the element that will be the focus of the enter animation + * @param {DOMElement} parentElement the parent element of the element that will be the focus of the enter animation + * @param {DOMElement} afterElement the sibling element (which is the previous element) of the element that will be the focus of the enter animation + * @param {function()=} doneCallback the callback function that will be called once the animation is complete + */ + enter : function(element, parentElement, afterElement, doneCallback) { + element = angular.element(element); + parentElement = prepareElement(parentElement); + afterElement = prepareElement(afterElement); + + blockElementAnimations(element); + $delegate.enter(element, parentElement, afterElement); + $rootScope.$$postDigest(function() { + element = stripCommentsFromElement(element); + performAnimation('enter', 'ng-enter', element, parentElement, afterElement, noop, doneCallback); + }); + }, + + /** + * @ngdoc method + * @name $animate#leave + * @kind function + * + * @description + * Runs the leave animation operation and, upon completion, removes the element from the DOM. Once + * the animation is started, the following CSS classes will be added for the duration of the animation: + * + * Below is a breakdown of each step that occurs during leave animation: + * + * | Animation Step | What the element class attribute looks like | + * |----------------------------------------------------------------------------------------------|---------------------------------------------| + * | 1. $animate.leave(...) is called | class="my-animation" | + * | 2. $animate runs any JavaScript-defined animations on the element | class="my-animation ng-animate" | + * | 3. the .ng-leave class is added to the element | class="my-animation ng-animate ng-leave" | + * | 4. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation ng-animate ng-leave" | + * | 5. $animate waits for 10ms (this performs a reflow) | class="my-animation ng-animate ng-leave" | + * | 6. the .ng-leave-active and .ng-animate-active classes is added (this triggers the CSS transition/animation) | class="my-animation ng-animate ng-animate-active ng-leave ng-leave-active" | + * | 7. $animate waits for X milliseconds for the animation to complete | class="my-animation ng-animate ng-animate-active ng-leave ng-leave-active" | + * | 8. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | + * | 9. The element is removed from the DOM | ... | + * | 10. The doneCallback() callback is fired (if provided) | ... | + * + * @param {DOMElement} element the element that will be the focus of the leave animation + * @param {function()=} doneCallback the callback function that will be called once the animation is complete + */ + leave : function(element, doneCallback) { + element = angular.element(element); + cancelChildAnimations(element); + blockElementAnimations(element); + $rootScope.$$postDigest(function() { + performAnimation('leave', 'ng-leave', stripCommentsFromElement(element), null, null, function() { + $delegate.leave(element); + }, doneCallback); + }); + }, + + /** + * @ngdoc method + * @name $animate#move + * @kind function + * + * @description + * Fires the move DOM operation. Just before the animation starts, the animate service will either append it into the parentElement container or + * add the element directly after the afterElement element if present. Then the move animation will be run. Once + * the animation is started, the following CSS classes will be added for the duration of the animation: + * + * Below is a breakdown of each step that occurs during move animation: + * + * | Animation Step | What the element class attribute looks like | + * |----------------------------------------------------------------------------------------------|---------------------------------------------| + * | 1. $animate.move(...) is called | class="my-animation" | + * | 2. element is moved into the parentElement element or beside the afterElement element | class="my-animation" | + * | 3. $animate runs any JavaScript-defined animations on the element | class="my-animation ng-animate" | + * | 4. the .ng-move class is added to the element | class="my-animation ng-animate ng-move" | + * | 5. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation ng-animate ng-move" | + * | 6. $animate waits for 10ms (this performs a reflow) | class="my-animation ng-animate ng-move" | + * | 7. the .ng-move-active and .ng-animate-active classes is added (this triggers the CSS transition/animation) | class="my-animation ng-animate ng-animate-active ng-move ng-move-active" | + * | 8. $animate waits for X milliseconds for the animation to complete | class="my-animation ng-animate ng-animate-active ng-move ng-move-active" | + * | 9. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | + * | 10. The doneCallback() callback is fired (if provided) | class="my-animation" | + * + * @param {DOMElement} element the element that will be the focus of the move animation + * @param {DOMElement} parentElement the parentElement element of the element that will be the focus of the move animation + * @param {DOMElement} afterElement the sibling element (which is the previous element) of the element that will be the focus of the move animation + * @param {function()=} doneCallback the callback function that will be called once the animation is complete + */ + move : function(element, parentElement, afterElement, doneCallback) { + element = angular.element(element); + parentElement = prepareElement(parentElement); + afterElement = prepareElement(afterElement); + + cancelChildAnimations(element); + blockElementAnimations(element); + $delegate.move(element, parentElement, afterElement); + $rootScope.$$postDigest(function() { + element = stripCommentsFromElement(element); + performAnimation('move', 'ng-move', element, parentElement, afterElement, noop, doneCallback); + }); + }, + + /** + * @ngdoc method + * @name $animate#addClass + * + * @description + * Triggers a custom animation event based off the className variable and then attaches the className value to the element as a CSS class. + * Unlike the other animation methods, the animate service will suffix the className value with {@type -add} in order to provide + * the animate service the setup and active CSS classes in order to trigger the animation (this will be skipped if no CSS transitions + * or keyframes are defined on the -add or base CSS class). + * + * Below is a breakdown of each step that occurs during addClass animation: + * + * | Animation Step | What the element class attribute looks like | + * |------------------------------------------------------------------------------------------------|---------------------------------------------| + * | 1. $animate.addClass(element, 'super') is called | class="my-animation" | + * | 2. $animate runs any JavaScript-defined animations on the element | class="my-animation ng-animate" | + * | 3. the .super-add class are added to the element | class="my-animation ng-animate super-add" | + * | 4. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation ng-animate super-add" | + * | 5. $animate waits for 10ms (this performs a reflow) | class="my-animation ng-animate super-add" | + * | 6. the .super, .super-add-active and .ng-animate-active classes are added (this triggers the CSS transition/animation) | class="my-animation ng-animate ng-animate-active super super-add super-add-active" | + * | 7. $animate waits for X milliseconds for the animation to complete | class="my-animation super super-add super-add-active" | + * | 8. The animation ends and all generated CSS classes are removed from the element | class="my-animation super" | + * | 9. The super class is kept on the element | class="my-animation super" | + * | 10. The doneCallback() callback is fired (if provided) | class="my-animation super" | + * + * @param {DOMElement} element the element that will be animated + * @param {string} className the CSS class that will be added to the element and then animated + * @param {function()=} doneCallback the callback function that will be called once the animation is complete + */ + addClass : function(element, className, doneCallback) { + element = angular.element(element); + element = stripCommentsFromElement(element); + performAnimation('addClass', className, element, null, null, function() { + $delegate.addClass(element, className); + }, doneCallback); + }, + + /** + * @ngdoc method + * @name $animate#removeClass + * + * @description + * Triggers a custom animation event based off the className variable and then removes the CSS class provided by the className value + * from the element. Unlike the other animation methods, the animate service will suffix the className value with {@type -remove} in + * order to provide the animate service the setup and active CSS classes in order to trigger the animation (this will be skipped if + * no CSS transitions or keyframes are defined on the -remove or base CSS classes). + * + * Below is a breakdown of each step that occurs during removeClass animation: + * + * | Animation Step | What the element class attribute looks like | + * |-----------------------------------------------------------------------------------------------|---------------------------------------------| + * | 1. $animate.removeClass(element, 'super') is called | class="my-animation super" | + * | 2. $animate runs any JavaScript-defined animations on the element | class="my-animation super ng-animate" | + * | 3. the .super-remove class are added to the element | class="my-animation super ng-animate super-remove"| + * | 4. $animate scans the element styles to get the CSS transition/animation duration and delay | class="my-animation super ng-animate super-remove" | + * | 5. $animate waits for 10ms (this performs a reflow) | class="my-animation super ng-animate super-remove" | + * | 6. the .super-remove-active and .ng-animate-active classes are added and .super is removed (this triggers the CSS transition/animation) | class="my-animation ng-animate ng-animate-active super-remove super-remove-active" | + * | 7. $animate waits for X milliseconds for the animation to complete | class="my-animation ng-animate ng-animate-active super-remove super-remove-active" | + * | 8. The animation ends and all generated CSS classes are removed from the element | class="my-animation" | + * | 9. The doneCallback() callback is fired (if provided) | class="my-animation" | + * + * + * @param {DOMElement} element the element that will be animated + * @param {string} className the CSS class that will be animated and then removed from the element + * @param {function()=} doneCallback the callback function that will be called once the animation is complete + */ + removeClass : function(element, className, doneCallback) { + element = angular.element(element); + element = stripCommentsFromElement(element); + performAnimation('removeClass', className, element, null, null, function() { + $delegate.removeClass(element, className); + }, doneCallback); + }, + + /** + * + * @ngdoc function + * @name $animate#setClass + * @function + * @description Adds and/or removes the given CSS classes to and from the element. + * Once complete, the done() callback will be fired (if provided). + * @param {DOMElement} element the element which will its CSS classes changed + * removed from it + * @param {string} add the CSS classes which will be added to the element + * @param {string} remove the CSS class which will be removed from the element + * @param {Function=} done the callback function (if provided) that will be fired after the + * CSS classes have been set on the element + */ + setClass : function(element, add, remove, doneCallback) { + element = angular.element(element); + element = stripCommentsFromElement(element); + performAnimation('setClass', [add, remove], element, null, null, function() { + $delegate.setClass(element, add, remove); + }, doneCallback); + }, + + /** + * @ngdoc method + * @name $animate#enabled + * @kind function + * + * @param {boolean=} value If provided then set the animation on or off. + * @param {DOMElement=} element If provided then the element will be used to represent the enable/disable operation + * @return {boolean} Current animation state. + * + * @description + * Globally enables/disables animations. + * + */ + enabled : function(value, element) { + switch(arguments.length) { + case 2: + if(value) { + cleanup(element); + } else { + var data = element.data(NG_ANIMATE_STATE) || {}; + data.disabled = true; + element.data(NG_ANIMATE_STATE, data); + } + break; + + case 1: + rootAnimateState.disabled = !value; + break; + + default: + value = !rootAnimateState.disabled; + break; + } + return !!value; + } + }; + + /* + all animations call this shared animation triggering function internally. + The animationEvent variable refers to the JavaScript animation event that will be triggered + and the className value is the name of the animation that will be applied within the + CSS code. Element, parentElement and afterElement are provided DOM elements for the animation + and the onComplete callback will be fired once the animation is fully complete. + */ + function performAnimation(animationEvent, className, element, parentElement, afterElement, domOperation, doneCallback) { + + var runner = animationRunner(element, animationEvent, className); + if(!runner) { + fireDOMOperation(); + fireBeforeCallbackAsync(); + fireAfterCallbackAsync(); + closeAnimation(); + return; + } + + className = runner.className; + var elementEvents = angular.element._data(runner.node); + elementEvents = elementEvents && elementEvents.events; + + if (!parentElement) { + parentElement = afterElement ? afterElement.parent() : element.parent(); + } + + var ngAnimateState = element.data(NG_ANIMATE_STATE) || {}; + var runningAnimations = ngAnimateState.active || {}; + var totalActiveAnimations = ngAnimateState.totalActive || 0; + var lastAnimation = ngAnimateState.last; + + //only allow animations if the currently running animation is not structural + //or if there is no animation running at all + var skipAnimations; + if (runner.isClassBased) { + skipAnimations = ngAnimateState.running || + ngAnimateState.disabled || + (lastAnimation && !lastAnimation.isClassBased); + } + + //skip the animation if animations are disabled, a parent is already being animated, + //the element is not currently attached to the document body or then completely close + //the animation if any matching animations are not found at all. + //NOTE: IE8 + IE9 should close properly (run closeAnimation()) in case an animation was found. + if (skipAnimations || animationsDisabled(element, parentElement)) { + fireDOMOperation(); + fireBeforeCallbackAsync(); + fireAfterCallbackAsync(); + closeAnimation(); + return; + } + + var skipAnimation = false; + if(totalActiveAnimations > 0) { + var animationsToCancel = []; + if(!runner.isClassBased) { + if(animationEvent == 'leave' && runningAnimations['ng-leave']) { + skipAnimation = true; + } else { + //cancel all animations when a structural animation takes place + for(var klass in runningAnimations) { + animationsToCancel.push(runningAnimations[klass]); + cleanup(element, klass); + } + runningAnimations = {}; + totalActiveAnimations = 0; + } + } else if(lastAnimation.event == 'setClass') { + animationsToCancel.push(lastAnimation); + cleanup(element, className); + } + else if(runningAnimations[className]) { + var current = runningAnimations[className]; + if(current.event == animationEvent) { + skipAnimation = true; + } else { + animationsToCancel.push(current); + cleanup(element, className); + } + } + + if(animationsToCancel.length > 0) { + forEach(animationsToCancel, function(operation) { + operation.cancel(); + }); + } + } + + if(runner.isClassBased && !runner.isSetClassOperation && !skipAnimation) { + skipAnimation = (animationEvent == 'addClass') == element.hasClass(className); //opposite of XOR + } + + if(skipAnimation) { + fireDOMOperation(); + fireBeforeCallbackAsync(); + fireAfterCallbackAsync(); + fireDoneCallbackAsync(); + return; + } + + if(animationEvent == 'leave') { + //there's no need to ever remove the listener since the element + //will be removed (destroyed) after the leave animation ends or + //is cancelled midway + element.one('$destroy', function(e) { + var element = angular.element(this); + var state = element.data(NG_ANIMATE_STATE); + if(state) { + var activeLeaveAnimation = state.active['ng-leave']; + if(activeLeaveAnimation) { + activeLeaveAnimation.cancel(); + cleanup(element, 'ng-leave'); + } + } + }); + } + + //the ng-animate class does nothing, but it's here to allow for + //parent animations to find and cancel child animations when needed + element.addClass(NG_ANIMATE_CLASS_NAME); + + var localAnimationCount = globalAnimationCounter++; + totalActiveAnimations++; + runningAnimations[className] = runner; + + element.data(NG_ANIMATE_STATE, { + last : runner, + active : runningAnimations, + index : localAnimationCount, + totalActive : totalActiveAnimations + }); + + //first we run the before animations and when all of those are complete + //then we perform the DOM operation and run the next set of animations + fireBeforeCallbackAsync(); + runner.before(function(cancelled) { + var data = element.data(NG_ANIMATE_STATE); + cancelled = cancelled || + !data || !data.active[className] || + (runner.isClassBased && data.active[className].event != animationEvent); + + fireDOMOperation(); + if(cancelled === true) { + closeAnimation(); + } else { + fireAfterCallbackAsync(); + runner.after(closeAnimation); + } + }); + + function fireDOMCallback(animationPhase) { + var eventName = '$animate:' + animationPhase; + if(elementEvents && elementEvents[eventName] && elementEvents[eventName].length > 0) { + $$asyncCallback(function() { + element.triggerHandler(eventName, { + event : animationEvent, + className : className + }); + }); + } + } + + function fireBeforeCallbackAsync() { + fireDOMCallback('before'); + } + + function fireAfterCallbackAsync() { + fireDOMCallback('after'); + } + + function fireDoneCallbackAsync() { + fireDOMCallback('close'); + if(doneCallback) { + $$asyncCallback(function() { + doneCallback(); + }); + } + } + + //it is less complicated to use a flag than managing and canceling + //timeouts containing multiple callbacks. + function fireDOMOperation() { + if(!fireDOMOperation.hasBeenRun) { + fireDOMOperation.hasBeenRun = true; + domOperation(); + } + } + + function closeAnimation() { + if(!closeAnimation.hasBeenRun) { + closeAnimation.hasBeenRun = true; + var data = element.data(NG_ANIMATE_STATE); + if(data) { + /* only structural animations wait for reflow before removing an + animation, but class-based animations don't. An example of this + failing would be when a parent HTML tag has a ng-class attribute + causing ALL directives below to skip animations during the digest */ + if(runner && runner.isClassBased) { + cleanup(element, className); + } else { + $$asyncCallback(function() { + var data = element.data(NG_ANIMATE_STATE) || {}; + if(localAnimationCount == data.index) { + cleanup(element, className, animationEvent); + } + }); + element.data(NG_ANIMATE_STATE, data); + } + } + fireDoneCallbackAsync(); + } + } + } + + function cancelChildAnimations(element) { + var node = extractElementNode(element); + if (node) { + var nodes = angular.isFunction(node.getElementsByClassName) ? + node.getElementsByClassName(NG_ANIMATE_CLASS_NAME) : + node.querySelectorAll('.' + NG_ANIMATE_CLASS_NAME); + forEach(nodes, function(element) { + element = angular.element(element); + var data = element.data(NG_ANIMATE_STATE); + if(data && data.active) { + forEach(data.active, function(runner) { + runner.cancel(); + }); + } + }); + } + } + + function cleanup(element, className) { + if(isMatchingElement(element, $rootElement)) { + if(!rootAnimateState.disabled) { + rootAnimateState.running = false; + rootAnimateState.structural = false; + } + } else if(className) { + var data = element.data(NG_ANIMATE_STATE) || {}; + + var removeAnimations = className === true; + if(!removeAnimations && data.active && data.active[className]) { + data.totalActive--; + delete data.active[className]; + } + + if(removeAnimations || !data.totalActive) { + element.removeClass(NG_ANIMATE_CLASS_NAME); + element.removeData(NG_ANIMATE_STATE); + } + } + } + + function animationsDisabled(element, parentElement) { + if (rootAnimateState.disabled) { + return true; + } + + if (isMatchingElement(element, $rootElement)) { + return rootAnimateState.running; + } + + var allowChildAnimations, parentRunningAnimation, hasParent; + do { + //the element did not reach the root element which means that it + //is not apart of the DOM. Therefore there is no reason to do + //any animations on it + if (parentElement.length === 0) break; + + var isRoot = isMatchingElement(parentElement, $rootElement); + var state = isRoot ? rootAnimateState : (parentElement.data(NG_ANIMATE_STATE) || {}); + if (state.disabled) { + return true; + } + + //no matter what, for an animation to work it must reach the root element + //this implies that the element is attached to the DOM when the animation is run + if (isRoot) { + hasParent = true; + } + + //once a flag is found that is strictly false then everything before + //it will be discarded and all child animations will be restricted + if (allowChildAnimations !== false) { + var animateChildrenFlag = parentElement.data(NG_ANIMATE_CHILDREN); + if(angular.isDefined(animateChildrenFlag)) { + allowChildAnimations = animateChildrenFlag; + } + } + + parentRunningAnimation = parentRunningAnimation || + state.running || + (state.last && !state.last.isClassBased); + } + while(parentElement = parentElement.parent()); + + return !hasParent || (!allowChildAnimations && parentRunningAnimation); + } + }]); + + $animateProvider.register('', ['$window', '$sniffer', '$timeout', '$$animateReflow', + function($window, $sniffer, $timeout, $$animateReflow) { + // Detect proper transitionend/animationend event names. + var CSS_PREFIX = '', TRANSITION_PROP, TRANSITIONEND_EVENT, ANIMATION_PROP, ANIMATIONEND_EVENT; + + // If unprefixed events are not supported but webkit-prefixed are, use the latter. + // Otherwise, just use W3C names, browsers not supporting them at all will just ignore them. + // Note: Chrome implements `window.onwebkitanimationend` and doesn't implement `window.onanimationend` + // but at the same time dispatches the `animationend` event and not `webkitAnimationEnd`. + // Register both events in case `window.onanimationend` is not supported because of that, + // do the same for `transitionend` as Safari is likely to exhibit similar behavior. + // Also, the only modern browser that uses vendor prefixes for transitions/keyframes is webkit + // therefore there is no reason to test anymore for other vendor prefixes: http://caniuse.com/#search=transition + if (window.ontransitionend === undefined && window.onwebkittransitionend !== undefined) { + CSS_PREFIX = '-webkit-'; + TRANSITION_PROP = 'WebkitTransition'; + TRANSITIONEND_EVENT = 'webkitTransitionEnd transitionend'; + } else { + TRANSITION_PROP = 'transition'; + TRANSITIONEND_EVENT = 'transitionend'; + } + + if (window.onanimationend === undefined && window.onwebkitanimationend !== undefined) { + CSS_PREFIX = '-webkit-'; + ANIMATION_PROP = 'WebkitAnimation'; + ANIMATIONEND_EVENT = 'webkitAnimationEnd animationend'; + } else { + ANIMATION_PROP = 'animation'; + ANIMATIONEND_EVENT = 'animationend'; + } + + var DURATION_KEY = 'Duration'; + var PROPERTY_KEY = 'Property'; + var DELAY_KEY = 'Delay'; + var ANIMATION_ITERATION_COUNT_KEY = 'IterationCount'; + var NG_ANIMATE_PARENT_KEY = '$$ngAnimateKey'; + var NG_ANIMATE_CSS_DATA_KEY = '$$ngAnimateCSS3Data'; + var NG_ANIMATE_BLOCK_CLASS_NAME = 'ng-animate-block-transitions'; + var ELAPSED_TIME_MAX_DECIMAL_PLACES = 3; + var CLOSING_TIME_BUFFER = 1.5; + var ONE_SECOND = 1000; + + var lookupCache = {}; + var parentCounter = 0; + var animationReflowQueue = []; + var cancelAnimationReflow; + function afterReflow(element, callback) { + if(cancelAnimationReflow) { + cancelAnimationReflow(); + } + animationReflowQueue.push(callback); + cancelAnimationReflow = $$animateReflow(function() { + forEach(animationReflowQueue, function(fn) { + fn(); + }); + + animationReflowQueue = []; + cancelAnimationReflow = null; + lookupCache = {}; + }); + } + + var closingTimer = null; + var closingTimestamp = 0; + var animationElementQueue = []; + function animationCloseHandler(element, totalTime) { + var node = extractElementNode(element); + element = angular.element(node); + + //this item will be garbage collected by the closing + //animation timeout + animationElementQueue.push(element); + + //but it may not need to cancel out the existing timeout + //if the timestamp is less than the previous one + var futureTimestamp = Date.now() + totalTime; + if(futureTimestamp <= closingTimestamp) { + return; + } + + $timeout.cancel(closingTimer); + + closingTimestamp = futureTimestamp; + closingTimer = $timeout(function() { + closeAllAnimations(animationElementQueue); + animationElementQueue = []; + }, totalTime, false); + } + + function closeAllAnimations(elements) { + forEach(elements, function(element) { + var elementData = element.data(NG_ANIMATE_CSS_DATA_KEY); + if(elementData) { + (elementData.closeAnimationFn || noop)(); + } + }); + } + + function getElementAnimationDetails(element, cacheKey) { + var data = cacheKey ? lookupCache[cacheKey] : null; + if(!data) { + var transitionDuration = 0; + var transitionDelay = 0; + var animationDuration = 0; + var animationDelay = 0; + var transitionDelayStyle; + var animationDelayStyle; + var transitionDurationStyle; + var transitionPropertyStyle; + + //we want all the styles defined before and after + forEach(element, function(element) { + if (element.nodeType == ELEMENT_NODE) { + var elementStyles = $window.getComputedStyle(element) || {}; + + transitionDurationStyle = elementStyles[TRANSITION_PROP + DURATION_KEY]; + + transitionDuration = Math.max(parseMaxTime(transitionDurationStyle), transitionDuration); + + transitionPropertyStyle = elementStyles[TRANSITION_PROP + PROPERTY_KEY]; + + transitionDelayStyle = elementStyles[TRANSITION_PROP + DELAY_KEY]; + + transitionDelay = Math.max(parseMaxTime(transitionDelayStyle), transitionDelay); + + animationDelayStyle = elementStyles[ANIMATION_PROP + DELAY_KEY]; + + animationDelay = Math.max(parseMaxTime(animationDelayStyle), animationDelay); + + var aDuration = parseMaxTime(elementStyles[ANIMATION_PROP + DURATION_KEY]); + + if(aDuration > 0) { + aDuration *= parseInt(elementStyles[ANIMATION_PROP + ANIMATION_ITERATION_COUNT_KEY], 10) || 1; + } + + animationDuration = Math.max(aDuration, animationDuration); + } + }); + data = { + total : 0, + transitionPropertyStyle: transitionPropertyStyle, + transitionDurationStyle: transitionDurationStyle, + transitionDelayStyle: transitionDelayStyle, + transitionDelay: transitionDelay, + transitionDuration: transitionDuration, + animationDelayStyle: animationDelayStyle, + animationDelay: animationDelay, + animationDuration: animationDuration + }; + if(cacheKey) { + lookupCache[cacheKey] = data; + } + } + return data; + } + + function parseMaxTime(str) { + var maxValue = 0; + var values = angular.isString(str) ? + str.split(/\s*,\s*/) : + []; + forEach(values, function(value) { + maxValue = Math.max(parseFloat(value) || 0, maxValue); + }); + return maxValue; + } + + function getCacheKey(element) { + var parentElement = element.parent(); + var parentID = parentElement.data(NG_ANIMATE_PARENT_KEY); + if(!parentID) { + parentElement.data(NG_ANIMATE_PARENT_KEY, ++parentCounter); + parentID = parentCounter; + } + return parentID + '-' + extractElementNode(element).getAttribute('class'); + } + + function animateSetup(animationEvent, element, className, calculationDecorator) { + var cacheKey = getCacheKey(element); + var eventCacheKey = cacheKey + ' ' + className; + var itemIndex = lookupCache[eventCacheKey] ? ++lookupCache[eventCacheKey].total : 0; + + var stagger = {}; + if(itemIndex > 0) { + var staggerClassName = className + '-stagger'; + var staggerCacheKey = cacheKey + ' ' + staggerClassName; + var applyClasses = !lookupCache[staggerCacheKey]; + + applyClasses && element.addClass(staggerClassName); + + stagger = getElementAnimationDetails(element, staggerCacheKey); + + applyClasses && element.removeClass(staggerClassName); + } + + /* the animation itself may need to add/remove special CSS classes + * before calculating the anmation styles */ + calculationDecorator = calculationDecorator || + function(fn) { return fn(); }; + + element.addClass(className); + + var formerData = element.data(NG_ANIMATE_CSS_DATA_KEY) || {}; + + var timings = calculationDecorator(function() { + return getElementAnimationDetails(element, eventCacheKey); + }); + + var transitionDuration = timings.transitionDuration; + var animationDuration = timings.animationDuration; + if(transitionDuration === 0 && animationDuration === 0) { + element.removeClass(className); + return false; + } + + element.data(NG_ANIMATE_CSS_DATA_KEY, { + running : formerData.running || 0, + itemIndex : itemIndex, + stagger : stagger, + timings : timings, + closeAnimationFn : noop + }); + + //temporarily disable the transition so that the enter styles + //don't animate twice (this is here to avoid a bug in Chrome/FF). + var isCurrentlyAnimating = formerData.running > 0 || animationEvent == 'setClass'; + if(transitionDuration > 0) { + blockTransitions(element, className, isCurrentlyAnimating); + } + + //staggering keyframe animations work by adjusting the `animation-delay` CSS property + //on the given element, however, the delay value can only calculated after the reflow + //since by that time $animate knows how many elements are being animated. Therefore, + //until the reflow occurs the element needs to be blocked (where the keyframe animation + //is set to `none 0s`). This blocking mechanism should only be set for when a stagger + //animation is detected and when the element item index is greater than 0. + if(animationDuration > 0 && stagger.animationDelay > 0 && stagger.animationDuration === 0) { + blockKeyframeAnimations(element); + } + + return true; + } + + function isStructuralAnimation(className) { + return className == 'ng-enter' || className == 'ng-move' || className == 'ng-leave'; + } + + function blockTransitions(element, className, isAnimating) { + if(isStructuralAnimation(className) || !isAnimating) { + extractElementNode(element).style[TRANSITION_PROP + PROPERTY_KEY] = 'none'; + } else { + element.addClass(NG_ANIMATE_BLOCK_CLASS_NAME); + } + } + + function blockKeyframeAnimations(element) { + extractElementNode(element).style[ANIMATION_PROP] = 'none 0s'; + } + + function unblockTransitions(element, className) { + var prop = TRANSITION_PROP + PROPERTY_KEY; + var node = extractElementNode(element); + if(node.style[prop] && node.style[prop].length > 0) { + node.style[prop] = ''; + } + element.removeClass(NG_ANIMATE_BLOCK_CLASS_NAME); + } + + function unblockKeyframeAnimations(element) { + var prop = ANIMATION_PROP; + var node = extractElementNode(element); + if(node.style[prop] && node.style[prop].length > 0) { + node.style[prop] = ''; + } + } + + function animateRun(animationEvent, element, className, activeAnimationComplete) { + var node = extractElementNode(element); + var elementData = element.data(NG_ANIMATE_CSS_DATA_KEY); + if(node.getAttribute('class').indexOf(className) == -1 || !elementData) { + activeAnimationComplete(); + return; + } + + var activeClassName = ''; + forEach(className.split(' '), function(klass, i) { + activeClassName += (i > 0 ? ' ' : '') + klass + '-active'; + }); + + var stagger = elementData.stagger; + var timings = elementData.timings; + var itemIndex = elementData.itemIndex; + var maxDuration = Math.max(timings.transitionDuration, timings.animationDuration); + var maxDelay = Math.max(timings.transitionDelay, timings.animationDelay); + var maxDelayTime = maxDelay * ONE_SECOND; + + var startTime = Date.now(); + var css3AnimationEvents = ANIMATIONEND_EVENT + ' ' + TRANSITIONEND_EVENT; + + var style = '', appliedStyles = []; + if(timings.transitionDuration > 0) { + var propertyStyle = timings.transitionPropertyStyle; + if(propertyStyle.indexOf('all') == -1) { + style += CSS_PREFIX + 'transition-property: ' + propertyStyle + ';'; + style += CSS_PREFIX + 'transition-duration: ' + timings.transitionDurationStyle + ';'; + appliedStyles.push(CSS_PREFIX + 'transition-property'); + appliedStyles.push(CSS_PREFIX + 'transition-duration'); + } + } + + if(itemIndex > 0) { + if(stagger.transitionDelay > 0 && stagger.transitionDuration === 0) { + var delayStyle = timings.transitionDelayStyle; + style += CSS_PREFIX + 'transition-delay: ' + + prepareStaggerDelay(delayStyle, stagger.transitionDelay, itemIndex) + '; '; + appliedStyles.push(CSS_PREFIX + 'transition-delay'); + } + + if(stagger.animationDelay > 0 && stagger.animationDuration === 0) { + style += CSS_PREFIX + 'animation-delay: ' + + prepareStaggerDelay(timings.animationDelayStyle, stagger.animationDelay, itemIndex) + '; '; + appliedStyles.push(CSS_PREFIX + 'animation-delay'); + } + } + + if(appliedStyles.length > 0) { + //the element being animated may sometimes contain comment nodes in + //the jqLite object, so we're safe to use a single variable to house + //the styles since there is always only one element being animated + var oldStyle = node.getAttribute('style') || ''; + node.setAttribute('style', oldStyle + '; ' + style); + } + + element.on(css3AnimationEvents, onAnimationProgress); + element.addClass(activeClassName); + elementData.closeAnimationFn = function() { + onEnd(); + activeAnimationComplete(); + }; + + var staggerTime = itemIndex * (Math.max(stagger.animationDelay, stagger.transitionDelay) || 0); + var animationTime = (maxDelay + maxDuration) * CLOSING_TIME_BUFFER; + var totalTime = (staggerTime + animationTime) * ONE_SECOND; + + elementData.running++; + animationCloseHandler(element, totalTime); + return onEnd; + + // This will automatically be called by $animate so + // there is no need to attach this internally to the + // timeout done method. + function onEnd(cancelled) { + element.off(css3AnimationEvents, onAnimationProgress); + element.removeClass(activeClassName); + animateClose(element, className); + var node = extractElementNode(element); + for (var i in appliedStyles) { + node.style.removeProperty(appliedStyles[i]); + } + } + + function onAnimationProgress(event) { + event.stopPropagation(); + var ev = event.originalEvent || event; + var timeStamp = ev.$manualTimeStamp || ev.timeStamp || Date.now(); + + /* Firefox (or possibly just Gecko) likes to not round values up + * when a ms measurement is used for the animation */ + var elapsedTime = parseFloat(ev.elapsedTime.toFixed(ELAPSED_TIME_MAX_DECIMAL_PLACES)); + + /* $manualTimeStamp is a mocked timeStamp value which is set + * within browserTrigger(). This is only here so that tests can + * mock animations properly. Real events fallback to event.timeStamp, + * or, if they don't, then a timeStamp is automatically created for them. + * We're checking to see if the timeStamp surpasses the expected delay, + * but we're using elapsedTime instead of the timeStamp on the 2nd + * pre-condition since animations sometimes close off early */ + if(Math.max(timeStamp - startTime, 0) >= maxDelayTime && elapsedTime >= maxDuration) { + activeAnimationComplete(); + } + } + } + + function prepareStaggerDelay(delayStyle, staggerDelay, index) { + var style = ''; + forEach(delayStyle.split(','), function(val, i) { + style += (i > 0 ? ',' : '') + + (index * staggerDelay + parseInt(val, 10)) + 's'; + }); + return style; + } + + function animateBefore(animationEvent, element, className, calculationDecorator) { + if(animateSetup(animationEvent, element, className, calculationDecorator)) { + return function(cancelled) { + cancelled && animateClose(element, className); + }; + } + } + + function animateAfter(animationEvent, element, className, afterAnimationComplete) { + if(element.data(NG_ANIMATE_CSS_DATA_KEY)) { + return animateRun(animationEvent, element, className, afterAnimationComplete); + } else { + animateClose(element, className); + afterAnimationComplete(); + } + } + + function animate(animationEvent, element, className, animationComplete) { + //If the animateSetup function doesn't bother returning a + //cancellation function then it means that there is no animation + //to perform at all + var preReflowCancellation = animateBefore(animationEvent, element, className); + if(!preReflowCancellation) { + animationComplete(); + return; + } + + //There are two cancellation functions: one is before the first + //reflow animation and the second is during the active state + //animation. The first function will take care of removing the + //data from the element which will not make the 2nd animation + //happen in the first place + var cancel = preReflowCancellation; + afterReflow(element, function() { + unblockTransitions(element, className); + unblockKeyframeAnimations(element); + //once the reflow is complete then we point cancel to + //the new cancellation function which will remove all of the + //animation properties from the active animation + cancel = animateAfter(animationEvent, element, className, animationComplete); + }); + + return function(cancelled) { + (cancel || noop)(cancelled); + }; + } + + function animateClose(element, className) { + element.removeClass(className); + var data = element.data(NG_ANIMATE_CSS_DATA_KEY); + if(data) { + if(data.running) { + data.running--; + } + if(!data.running || data.running === 0) { + element.removeData(NG_ANIMATE_CSS_DATA_KEY); + } + } + } + + return { + enter : function(element, animationCompleted) { + return animate('enter', element, 'ng-enter', animationCompleted); + }, + + leave : function(element, animationCompleted) { + return animate('leave', element, 'ng-leave', animationCompleted); + }, + + move : function(element, animationCompleted) { + return animate('move', element, 'ng-move', animationCompleted); + }, + + beforeSetClass : function(element, add, remove, animationCompleted) { + var className = suffixClasses(remove, '-remove') + ' ' + + suffixClasses(add, '-add'); + var cancellationMethod = animateBefore('setClass', element, className, function(fn) { + /* when classes are removed from an element then the transition style + * that is applied is the transition defined on the element without the + * CSS class being there. This is how CSS3 functions outside of ngAnimate. + * http://plnkr.co/edit/j8OzgTNxHTb4n3zLyjGW?p=preview */ + var klass = element.attr('class'); + element.removeClass(remove); + element.addClass(add); + var timings = fn(); + element.attr('class', klass); + return timings; + }); + + if(cancellationMethod) { + afterReflow(element, function() { + unblockTransitions(element, className); + unblockKeyframeAnimations(element); + animationCompleted(); + }); + return cancellationMethod; + } + animationCompleted(); + }, + + beforeAddClass : function(element, className, animationCompleted) { + var cancellationMethod = animateBefore('addClass', element, suffixClasses(className, '-add'), function(fn) { + + /* when a CSS class is added to an element then the transition style that + * is applied is the transition defined on the element when the CSS class + * is added at the time of the animation. This is how CSS3 functions + * outside of ngAnimate. */ + element.addClass(className); + var timings = fn(); + element.removeClass(className); + return timings; + }); + + if(cancellationMethod) { + afterReflow(element, function() { + unblockTransitions(element, className); + unblockKeyframeAnimations(element); + animationCompleted(); + }); + return cancellationMethod; + } + animationCompleted(); + }, + + setClass : function(element, add, remove, animationCompleted) { + remove = suffixClasses(remove, '-remove'); + add = suffixClasses(add, '-add'); + var className = remove + ' ' + add; + return animateAfter('setClass', element, className, animationCompleted); + }, + + addClass : function(element, className, animationCompleted) { + return animateAfter('addClass', element, suffixClasses(className, '-add'), animationCompleted); + }, + + beforeRemoveClass : function(element, className, animationCompleted) { + var cancellationMethod = animateBefore('removeClass', element, suffixClasses(className, '-remove'), function(fn) { + /* when classes are removed from an element then the transition style + * that is applied is the transition defined on the element without the + * CSS class being there. This is how CSS3 functions outside of ngAnimate. + * http://plnkr.co/edit/j8OzgTNxHTb4n3zLyjGW?p=preview */ + var klass = element.attr('class'); + element.removeClass(className); + var timings = fn(); + element.attr('class', klass); + return timings; + }); + + if(cancellationMethod) { + afterReflow(element, function() { + unblockTransitions(element, className); + unblockKeyframeAnimations(element); + animationCompleted(); + }); + return cancellationMethod; + } + animationCompleted(); + }, + + removeClass : function(element, className, animationCompleted) { + return animateAfter('removeClass', element, suffixClasses(className, '-remove'), animationCompleted); + } + }; + + function suffixClasses(classes, suffix) { + var className = ''; + classes = angular.isArray(classes) ? classes : classes.split(/\s+/); + forEach(classes, function(klass, i) { + if(klass && klass.length > 0) { + className += (i > 0 ? ' ' : '') + klass + suffix; + } + }); + return className; + } + }]); + }]); + + +})(window, window.angular); diff --git a/webclient/js/angular-route.js b/webclient/js/angular-route.js new file mode 100644 index 0000000000..c4da03a645 --- /dev/null +++ b/webclient/js/angular-route.js @@ -0,0 +1,927 @@ +/** + * @license AngularJS v1.2.20 + * (c) 2010-2014 Google, Inc. http://angularjs.org + * License: MIT + */ +(function(window, angular, undefined) {'use strict'; + +/** + * @ngdoc module + * @name ngRoute + * @description + * + * # ngRoute + * + * The `ngRoute` module provides routing and deeplinking services and directives for angular apps. + * + * ## Example + * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`. + * + * + *
+ */ + /* global -ngRouteModule */ +var ngRouteModule = angular.module('ngRoute', ['ng']). + provider('$route', $RouteProvider); + +/** + * @ngdoc provider + * @name $routeProvider + * @kind function + * + * @description + * + * Used for configuring routes. + * + * ## Example + * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`. + * + * ## Dependencies + * Requires the {@link ngRoute `ngRoute`} module to be installed. + */ +function $RouteProvider(){ + function inherit(parent, extra) { + return angular.extend(new (angular.extend(function() {}, {prototype:parent}))(), extra); + } + + var routes = {}; + + /** + * @ngdoc method + * @name $routeProvider#when + * + * @param {string} path Route path (matched against `$location.path`). If `$location.path` + * contains redundant trailing slash or is missing one, the route will still match and the + * `$location.path` will be updated to add or drop the trailing slash to exactly match the + * route definition. + * + * * `path` can contain named groups starting with a colon: e.g. `:name`. All characters up + * to the next slash are matched and stored in `$routeParams` under the given `name` + * when the route matches. + * * `path` can contain named groups starting with a colon and ending with a star: + * e.g.`:name*`. All characters are eagerly stored in `$routeParams` under the given `name` + * when the route matches. + * * `path` can contain optional named groups with a question mark: e.g.`:name?`. + * + * For example, routes like `/color/:color/largecode/:largecode*\/edit` will match + * `/color/brown/largecode/code/with/slashes/edit` and extract: + * + * * `color: brown` + * * `largecode: code/with/slashes`. + * + * + * @param {Object} route Mapping information to be assigned to `$route.current` on route + * match. + * + * Object properties: + * + * - `controller` – `{(string|function()=}` – Controller fn that should be associated with + * newly created scope or the name of a {@link angular.Module#controller registered + * controller} if passed as a string. + * - `controllerAs` – `{string=}` – A controller alias name. If present the controller will be + * published to scope under the `controllerAs` name. + * - `template` – `{string=|function()=}` – html template as a string or a function that + * returns an html template as a string which should be used by {@link + * ngRoute.directive:ngView ngView} or {@link ng.directive:ngInclude ngInclude} directives. + * This property takes precedence over `templateUrl`. + * + * If `template` is a function, it will be called with the following parameters: + * + * - `{Array.}` - route parameters extracted from the current + * `$location.path()` by applying the current route + * + * - `templateUrl` – `{string=|function()=}` – path or function that returns a path to an html + * template that should be used by {@link ngRoute.directive:ngView ngView}. + * + * If `templateUrl` is a function, it will be called with the following parameters: + * + * - `{Array.}` - route parameters extracted from the current + * `$location.path()` by applying the current route + * + * - `resolve` - `{Object.=}` - An optional map of dependencies which should + * be injected into the controller. If any of these dependencies are promises, the router + * will wait for them all to be resolved or one to be rejected before the controller is + * instantiated. + * If all the promises are resolved successfully, the values of the resolved promises are + * injected and {@link ngRoute.$route#$routeChangeSuccess $routeChangeSuccess} event is + * fired. If any of the promises are rejected the + * {@link ngRoute.$route#$routeChangeError $routeChangeError} event is fired. The map object + * is: + * + * - `key` – `{string}`: a name of a dependency to be injected into the controller. + * - `factory` - `{string|function}`: If `string` then it is an alias for a service. + * Otherwise if function, then it is {@link auto.$injector#invoke injected} + * and the return value is treated as the dependency. If the result is a promise, it is + * resolved before its value is injected into the controller. Be aware that + * `ngRoute.$routeParams` will still refer to the previous route within these resolve + * functions. Use `$route.current.params` to access the new route parameters, instead. + * + * - `redirectTo` – {(string|function())=} – value to update + * {@link ng.$location $location} path with and trigger route redirection. + * + * If `redirectTo` is a function, it will be called with the following parameters: + * + * - `{Object.}` - route parameters extracted from the current + * `$location.path()` by applying the current route templateUrl. + * - `{string}` - current `$location.path()` + * - `{Object}` - current `$location.search()` + * + * The custom `redirectTo` function is expected to return a string which will be used + * to update `$location.path()` and `$location.search()`. + * + * - `[reloadOnSearch=true]` - {boolean=} - reload route when only `$location.search()` + * or `$location.hash()` changes. + * + * If the option is set to `false` and url in the browser changes, then + * `$routeUpdate` event is broadcasted on the root scope. + * + * - `[caseInsensitiveMatch=false]` - {boolean=} - match routes without being case sensitive + * + * If the option is set to `true`, then the particular route can be matched without being + * case sensitive + * + * @returns {Object} self + * + * @description + * Adds a new route definition to the `$route` service. + */ + this.when = function(path, route) { + routes[path] = angular.extend( + {reloadOnSearch: true}, + route, + path && pathRegExp(path, route) + ); + + // create redirection for trailing slashes + if (path) { + var redirectPath = (path[path.length-1] == '/') + ? path.substr(0, path.length-1) + : path +'/'; + + routes[redirectPath] = angular.extend( + {redirectTo: path}, + pathRegExp(redirectPath, route) + ); + } + + return this; + }; + + /** + * @param path {string} path + * @param opts {Object} options + * @return {?Object} + * + * @description + * Normalizes the given path, returning a regular expression + * and the original path. + * + * Inspired by pathRexp in visionmedia/express/lib/utils.js. + */ + function pathRegExp(path, opts) { + var insensitive = opts.caseInsensitiveMatch, + ret = { + originalPath: path, + regexp: path + }, + keys = ret.keys = []; + + path = path + .replace(/([().])/g, '\\$1') + .replace(/(\/)?:(\w+)([\?\*])?/g, function(_, slash, key, option){ + var optional = option === '?' ? option : null; + var star = option === '*' ? option : null; + keys.push({ name: key, optional: !!optional }); + slash = slash || ''; + return '' + + (optional ? '' : slash) + + '(?:' + + (optional ? slash : '') + + (star && '(.+?)' || '([^/]+)') + + (optional || '') + + ')' + + (optional || ''); + }) + .replace(/([\/$\*])/g, '\\$1'); + + ret.regexp = new RegExp('^' + path + '$', insensitive ? 'i' : ''); + return ret; + } + + /** + * @ngdoc method + * @name $routeProvider#otherwise + * + * @description + * Sets route definition that will be used on route change when no other route definition + * is matched. + * + * @param {Object} params Mapping information to be assigned to `$route.current`. + * @returns {Object} self + */ + this.otherwise = function(params) { + this.when(null, params); + return this; + }; + + + this.$get = ['$rootScope', + '$location', + '$routeParams', + '$q', + '$injector', + '$http', + '$templateCache', + '$sce', + function($rootScope, $location, $routeParams, $q, $injector, $http, $templateCache, $sce) { + + /** + * @ngdoc service + * @name $route + * @requires $location + * @requires $routeParams + * + * @property {Object} current Reference to the current route definition. + * The route definition contains: + * + * - `controller`: The controller constructor as define in route definition. + * - `locals`: A map of locals which is used by {@link ng.$controller $controller} service for + * controller instantiation. The `locals` contain + * the resolved values of the `resolve` map. Additionally the `locals` also contain: + * + * - `$scope` - The current route scope. + * - `$template` - The current route template HTML. + * + * @property {Object} routes Object with all route configuration Objects as its properties. + * + * @description + * `$route` is used for deep-linking URLs to controllers and views (HTML partials). + * It watches `$location.url()` and tries to map the path to an existing route definition. + * + * Requires the {@link ngRoute `ngRoute`} module to be installed. + * + * You can define routes through {@link ngRoute.$routeProvider $routeProvider}'s API. + * + * The `$route` service is typically used in conjunction with the + * {@link ngRoute.directive:ngView `ngView`} directive and the + * {@link ngRoute.$routeParams `$routeParams`} service. + * + * @example + * This example shows how changing the URL hash causes the `$route` to match a route against the + * URL, and the `ngView` pulls in the partial. + * + * Note that this example is using {@link ng.directive:script inlined templates} + * to get it working on jsfiddle as well. + * + * + * + *
+ * Choose: + * Moby | + * Moby: Ch1 | + * Gatsby | + * Gatsby: Ch4 | + * Scarlet Letter
+ * + *
+ * + *
+ * + *
$location.path() = {{$location.path()}}
+ *
$route.current.templateUrl = {{$route.current.templateUrl}}
+ *
$route.current.params = {{$route.current.params}}
+ *
$route.current.scope.name = {{$route.current.scope.name}}
+ *
$routeParams = {{$routeParams}}
+ *
+ *
+ * + * + * controller: {{name}}
+ * Book Id: {{params.bookId}}
+ *
+ * + * + * controller: {{name}}
+ * Book Id: {{params.bookId}}
+ * Chapter Id: {{params.chapterId}} + *
+ * + * + * angular.module('ngRouteExample', ['ngRoute']) + * + * .controller('MainController', function($scope, $route, $routeParams, $location) { + * $scope.$route = $route; + * $scope.$location = $location; + * $scope.$routeParams = $routeParams; + * }) + * + * .controller('BookController', function($scope, $routeParams) { + * $scope.name = "BookController"; + * $scope.params = $routeParams; + * }) + * + * .controller('ChapterController', function($scope, $routeParams) { + * $scope.name = "ChapterController"; + * $scope.params = $routeParams; + * }) + * + * .config(function($routeProvider, $locationProvider) { + * $routeProvider + * .when('/Book/:bookId', { + * templateUrl: 'book.html', + * controller: 'BookController', + * resolve: { + * // I will cause a 1 second delay + * delay: function($q, $timeout) { + * var delay = $q.defer(); + * $timeout(delay.resolve, 1000); + * return delay.promise; + * } + * } + * }) + * .when('/Book/:bookId/ch/:chapterId', { + * templateUrl: 'chapter.html', + * controller: 'ChapterController' + * }); + * + * // configure html5 to get links working on jsfiddle + * $locationProvider.html5Mode(true); + * }); + * + * + * + * + * it('should load and compile correct template', function() { + * element(by.linkText('Moby: Ch1')).click(); + * var content = element(by.css('[ng-view]')).getText(); + * expect(content).toMatch(/controller\: ChapterController/); + * expect(content).toMatch(/Book Id\: Moby/); + * expect(content).toMatch(/Chapter Id\: 1/); + * + * element(by.partialLinkText('Scarlet')).click(); + * + * content = element(by.css('[ng-view]')).getText(); + * expect(content).toMatch(/controller\: BookController/); + * expect(content).toMatch(/Book Id\: Scarlet/); + * }); + * + *
+ */ + + /** + * @ngdoc event + * @name $route#$routeChangeStart + * @eventType broadcast on root scope + * @description + * Broadcasted before a route change. At this point the route services starts + * resolving all of the dependencies needed for the route change to occur. + * Typically this involves fetching the view template as well as any dependencies + * defined in `resolve` route property. Once all of the dependencies are resolved + * `$routeChangeSuccess` is fired. + * + * @param {Object} angularEvent Synthetic event object. + * @param {Route} next Future route information. + * @param {Route} current Current route information. + */ + + /** + * @ngdoc event + * @name $route#$routeChangeSuccess + * @eventType broadcast on root scope + * @description + * Broadcasted after a route dependencies are resolved. + * {@link ngRoute.directive:ngView ngView} listens for the directive + * to instantiate the controller and render the view. + * + * @param {Object} angularEvent Synthetic event object. + * @param {Route} current Current route information. + * @param {Route|Undefined} previous Previous route information, or undefined if current is + * first route entered. + */ + + /** + * @ngdoc event + * @name $route#$routeChangeError + * @eventType broadcast on root scope + * @description + * Broadcasted if any of the resolve promises are rejected. + * + * @param {Object} angularEvent Synthetic event object + * @param {Route} current Current route information. + * @param {Route} previous Previous route information. + * @param {Route} rejection Rejection of the promise. Usually the error of the failed promise. + */ + + /** + * @ngdoc event + * @name $route#$routeUpdate + * @eventType broadcast on root scope + * @description + * + * The `reloadOnSearch` property has been set to false, and we are reusing the same + * instance of the Controller. + */ + + var forceReload = false, + $route = { + routes: routes, + + /** + * @ngdoc method + * @name $route#reload + * + * @description + * Causes `$route` service to reload the current route even if + * {@link ng.$location $location} hasn't changed. + * + * As a result of that, {@link ngRoute.directive:ngView ngView} + * creates new scope, reinstantiates the controller. + */ + reload: function() { + forceReload = true; + $rootScope.$evalAsync(updateRoute); + } + }; + + $rootScope.$on('$locationChangeSuccess', updateRoute); + + return $route; + + ///////////////////////////////////////////////////// + + /** + * @param on {string} current url + * @param route {Object} route regexp to match the url against + * @return {?Object} + * + * @description + * Check if the route matches the current url. + * + * Inspired by match in + * visionmedia/express/lib/router/router.js. + */ + function switchRouteMatcher(on, route) { + var keys = route.keys, + params = {}; + + if (!route.regexp) return null; + + var m = route.regexp.exec(on); + if (!m) return null; + + for (var i = 1, len = m.length; i < len; ++i) { + var key = keys[i - 1]; + + var val = 'string' == typeof m[i] + ? decodeURIComponent(m[i]) + : m[i]; + + if (key && val) { + params[key.name] = val; + } + } + return params; + } + + function updateRoute() { + var next = parseRoute(), + last = $route.current; + + if (next && last && next.$$route === last.$$route + && angular.equals(next.pathParams, last.pathParams) + && !next.reloadOnSearch && !forceReload) { + last.params = next.params; + angular.copy(last.params, $routeParams); + $rootScope.$broadcast('$routeUpdate', last); + } else if (next || last) { + forceReload = false; + $rootScope.$broadcast('$routeChangeStart', next, last); + $route.current = next; + if (next) { + if (next.redirectTo) { + if (angular.isString(next.redirectTo)) { + $location.path(interpolate(next.redirectTo, next.params)).search(next.params) + .replace(); + } else { + $location.url(next.redirectTo(next.pathParams, $location.path(), $location.search())) + .replace(); + } + } + } + + $q.when(next). + then(function() { + if (next) { + var locals = angular.extend({}, next.resolve), + template, templateUrl; + + angular.forEach(locals, function(value, key) { + locals[key] = angular.isString(value) ? + $injector.get(value) : $injector.invoke(value); + }); + + if (angular.isDefined(template = next.template)) { + if (angular.isFunction(template)) { + template = template(next.params); + } + } else if (angular.isDefined(templateUrl = next.templateUrl)) { + if (angular.isFunction(templateUrl)) { + templateUrl = templateUrl(next.params); + } + templateUrl = $sce.getTrustedResourceUrl(templateUrl); + if (angular.isDefined(templateUrl)) { + next.loadedTemplateUrl = templateUrl; + template = $http.get(templateUrl, {cache: $templateCache}). + then(function(response) { return response.data; }); + } + } + if (angular.isDefined(template)) { + locals['$template'] = template; + } + return $q.all(locals); + } + }). + // after route change + then(function(locals) { + if (next == $route.current) { + if (next) { + next.locals = locals; + angular.copy(next.params, $routeParams); + } + $rootScope.$broadcast('$routeChangeSuccess', next, last); + } + }, function(error) { + if (next == $route.current) { + $rootScope.$broadcast('$routeChangeError', next, last, error); + } + }); + } + } + + + /** + * @returns {Object} the current active route, by matching it against the URL + */ + function parseRoute() { + // Match a route + var params, match; + angular.forEach(routes, function(route, path) { + if (!match && (params = switchRouteMatcher($location.path(), route))) { + match = inherit(route, { + params: angular.extend({}, $location.search(), params), + pathParams: params}); + match.$$route = route; + } + }); + // No route matched; fallback to "otherwise" route + return match || routes[null] && inherit(routes[null], {params: {}, pathParams:{}}); + } + + /** + * @returns {string} interpolation of the redirect path with the parameters + */ + function interpolate(string, params) { + var result = []; + angular.forEach((string||'').split(':'), function(segment, i) { + if (i === 0) { + result.push(segment); + } else { + var segmentMatch = segment.match(/(\w+)(.*)/); + var key = segmentMatch[1]; + result.push(params[key]); + result.push(segmentMatch[2] || ''); + delete params[key]; + } + }); + return result.join(''); + } + }]; +} + +ngRouteModule.provider('$routeParams', $RouteParamsProvider); + + +/** + * @ngdoc service + * @name $routeParams + * @requires $route + * + * @description + * The `$routeParams` service allows you to retrieve the current set of route parameters. + * + * Requires the {@link ngRoute `ngRoute`} module to be installed. + * + * The route parameters are a combination of {@link ng.$location `$location`}'s + * {@link ng.$location#search `search()`} and {@link ng.$location#path `path()`}. + * The `path` parameters are extracted when the {@link ngRoute.$route `$route`} path is matched. + * + * In case of parameter name collision, `path` params take precedence over `search` params. + * + * The service guarantees that the identity of the `$routeParams` object will remain unchanged + * (but its properties will likely change) even when a route change occurs. + * + * Note that the `$routeParams` are only updated *after* a route change completes successfully. + * This means that you cannot rely on `$routeParams` being correct in route resolve functions. + * Instead you can use `$route.current.params` to access the new route's parameters. + * + * @example + * ```js + * // Given: + * // URL: http://server.com/index.html#/Chapter/1/Section/2?search=moby + * // Route: /Chapter/:chapterId/Section/:sectionId + * // + * // Then + * $routeParams ==> {chapterId:'1', sectionId:'2', search:'moby'} + * ``` + */ +function $RouteParamsProvider() { + this.$get = function() { return {}; }; +} + +ngRouteModule.directive('ngView', ngViewFactory); +ngRouteModule.directive('ngView', ngViewFillContentFactory); + + +/** + * @ngdoc directive + * @name ngView + * @restrict ECA + * + * @description + * # Overview + * `ngView` is a directive that complements the {@link ngRoute.$route $route} service by + * including the rendered template of the current route into the main layout (`index.html`) file. + * Every time the current route changes, the included view changes with it according to the + * configuration of the `$route` service. + * + * Requires the {@link ngRoute `ngRoute`} module to be installed. + * + * @animations + * enter - animation is used to bring new content into the browser. + * leave - animation is used to animate existing content away. + * + * The enter and leave animation occur concurrently. + * + * @scope + * @priority 400 + * @param {string=} onload Expression to evaluate whenever the view updates. + * + * @param {string=} autoscroll Whether `ngView` should call {@link ng.$anchorScroll + * $anchorScroll} to scroll the viewport after the view is updated. + * + * - If the attribute is not set, disable scrolling. + * - If the attribute is set without value, enable scrolling. + * - Otherwise enable scrolling only if the `autoscroll` attribute value evaluated + * as an expression yields a truthy value. + * @example + + +
+ Choose: + Moby | + Moby: Ch1 | + Gatsby | + Gatsby: Ch4 | + Scarlet Letter
+ +
+
+
+
+ +
$location.path() = {{main.$location.path()}}
+
$route.current.templateUrl = {{main.$route.current.templateUrl}}
+
$route.current.params = {{main.$route.current.params}}
+
$route.current.scope.name = {{main.$route.current.scope.name}}
+
$routeParams = {{main.$routeParams}}
+
+
+ + +
+ controller: {{book.name}}
+ Book Id: {{book.params.bookId}}
+
+
+ + +
+ controller: {{chapter.name}}
+ Book Id: {{chapter.params.bookId}}
+ Chapter Id: {{chapter.params.chapterId}} +
+
+ + + .view-animate-container { + position:relative; + height:100px!important; + position:relative; + background:white; + border:1px solid black; + height:40px; + overflow:hidden; + } + + .view-animate { + padding:10px; + } + + .view-animate.ng-enter, .view-animate.ng-leave { + -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s; + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s; + + display:block; + width:100%; + border-left:1px solid black; + + position:absolute; + top:0; + left:0; + right:0; + bottom:0; + padding:10px; + } + + .view-animate.ng-enter { + left:100%; + } + .view-animate.ng-enter.ng-enter-active { + left:0; + } + .view-animate.ng-leave.ng-leave-active { + left:-100%; + } + + + + angular.module('ngViewExample', ['ngRoute', 'ngAnimate']) + .config(['$routeProvider', '$locationProvider', + function($routeProvider, $locationProvider) { + $routeProvider + .when('/Book/:bookId', { + templateUrl: 'book.html', + controller: 'BookCtrl', + controllerAs: 'book' + }) + .when('/Book/:bookId/ch/:chapterId', { + templateUrl: 'chapter.html', + controller: 'ChapterCtrl', + controllerAs: 'chapter' + }); + + // configure html5 to get links working on jsfiddle + $locationProvider.html5Mode(true); + }]) + .controller('MainCtrl', ['$route', '$routeParams', '$location', + function($route, $routeParams, $location) { + this.$route = $route; + this.$location = $location; + this.$routeParams = $routeParams; + }]) + .controller('BookCtrl', ['$routeParams', function($routeParams) { + this.name = "BookCtrl"; + this.params = $routeParams; + }]) + .controller('ChapterCtrl', ['$routeParams', function($routeParams) { + this.name = "ChapterCtrl"; + this.params = $routeParams; + }]); + + + + + it('should load and compile correct template', function() { + element(by.linkText('Moby: Ch1')).click(); + var content = element(by.css('[ng-view]')).getText(); + expect(content).toMatch(/controller\: ChapterCtrl/); + expect(content).toMatch(/Book Id\: Moby/); + expect(content).toMatch(/Chapter Id\: 1/); + + element(by.partialLinkText('Scarlet')).click(); + + content = element(by.css('[ng-view]')).getText(); + expect(content).toMatch(/controller\: BookCtrl/); + expect(content).toMatch(/Book Id\: Scarlet/); + }); + +
+ */ + + +/** + * @ngdoc event + * @name ngView#$viewContentLoaded + * @eventType emit on the current ngView scope + * @description + * Emitted every time the ngView content is reloaded. + */ +ngViewFactory.$inject = ['$route', '$anchorScroll', '$animate']; +function ngViewFactory( $route, $anchorScroll, $animate) { + return { + restrict: 'ECA', + terminal: true, + priority: 400, + transclude: 'element', + link: function(scope, $element, attr, ctrl, $transclude) { + var currentScope, + currentElement, + previousElement, + autoScrollExp = attr.autoscroll, + onloadExp = attr.onload || ''; + + scope.$on('$routeChangeSuccess', update); + update(); + + function cleanupLastView() { + if(previousElement) { + previousElement.remove(); + previousElement = null; + } + if(currentScope) { + currentScope.$destroy(); + currentScope = null; + } + if(currentElement) { + $animate.leave(currentElement, function() { + previousElement = null; + }); + previousElement = currentElement; + currentElement = null; + } + } + + function update() { + var locals = $route.current && $route.current.locals, + template = locals && locals.$template; + + if (angular.isDefined(template)) { + var newScope = scope.$new(); + var current = $route.current; + + // Note: This will also link all children of ng-view that were contained in the original + // html. If that content contains controllers, ... they could pollute/change the scope. + // However, using ng-view on an element with additional content does not make sense... + // Note: We can't remove them in the cloneAttchFn of $transclude as that + // function is called before linking the content, which would apply child + // directives to non existing elements. + var clone = $transclude(newScope, function(clone) { + $animate.enter(clone, null, currentElement || $element, function onNgViewEnter () { + if (angular.isDefined(autoScrollExp) + && (!autoScrollExp || scope.$eval(autoScrollExp))) { + $anchorScroll(); + } + }); + cleanupLastView(); + }); + + currentElement = clone; + currentScope = current.scope = newScope; + currentScope.$emit('$viewContentLoaded'); + currentScope.$eval(onloadExp); + } else { + cleanupLastView(); + } + } + } + }; +} + +// This directive is called during the $transclude call of the first `ngView` directive. +// It will replace and compile the content of the element with the loaded template. +// We need this directive so that the element content is already filled when +// the link function of another directive on the same element as ngView +// is called. +ngViewFillContentFactory.$inject = ['$compile', '$controller', '$route']; +function ngViewFillContentFactory($compile, $controller, $route) { + return { + restrict: 'ECA', + priority: -400, + link: function(scope, $element) { + var current = $route.current, + locals = current.locals; + + $element.html(locals.$template); + + var link = $compile($element.contents()); + + if (current.controller) { + locals.$scope = scope; + var controller = $controller(current.controller, locals); + if (current.controllerAs) { + scope[current.controllerAs] = controller; + } + $element.data('$ngControllerController', controller); + $element.children().data('$ngControllerController', controller); + } + + link(scope); + } + }; +} + + +})(window, window.angular); diff --git a/webclient/js/angular.js b/webclient/js/angular.js new file mode 100644 index 0000000000..217c4036fc --- /dev/null +++ b/webclient/js/angular.js @@ -0,0 +1,21822 @@ +/** + * @license AngularJS v1.2.20 + * (c) 2010-2014 Google, Inc. http://angularjs.org + * License: MIT + */ +(function(window, document, undefined) {'use strict'; + +/** + * @description + * + * This object provides a utility for producing rich Error messages within + * Angular. It can be called as follows: + * + * var exampleMinErr = minErr('example'); + * throw exampleMinErr('one', 'This {0} is {1}', foo, bar); + * + * The above creates an instance of minErr in the example namespace. The + * resulting error will have a namespaced error code of example.one. The + * resulting error will replace {0} with the value of foo, and {1} with the + * value of bar. The object is not restricted in the number of arguments it can + * take. + * + * If fewer arguments are specified than necessary for interpolation, the extra + * interpolation markers will be preserved in the final string. + * + * Since data will be parsed statically during a build step, some restrictions + * are applied with respect to how minErr instances are created and called. + * Instances should have names of the form namespaceMinErr for a minErr created + * using minErr('namespace') . Error codes, namespaces and template strings + * should all be static strings, not variables or general expressions. + * + * @param {string} module The namespace to use for the new minErr instance. + * @returns {function(code:string, template:string, ...templateArgs): Error} minErr instance + */ + +function minErr(module) { + return function () { + var code = arguments[0], + prefix = '[' + (module ? module + ':' : '') + code + '] ', + template = arguments[1], + templateArgs = arguments, + stringify = function (obj) { + if (typeof obj === 'function') { + return obj.toString().replace(/ \{[\s\S]*$/, ''); + } else if (typeof obj === 'undefined') { + return 'undefined'; + } else if (typeof obj !== 'string') { + return JSON.stringify(obj); + } + return obj; + }, + message, i; + + message = prefix + template.replace(/\{\d+\}/g, function (match) { + var index = +match.slice(1, -1), arg; + + if (index + 2 < templateArgs.length) { + arg = templateArgs[index + 2]; + if (typeof arg === 'function') { + return arg.toString().replace(/ ?\{[\s\S]*$/, ''); + } else if (typeof arg === 'undefined') { + return 'undefined'; + } else if (typeof arg !== 'string') { + return toJson(arg); + } + return arg; + } + return match; + }); + + message = message + '\nhttp://errors.angularjs.org/1.2.20/' + + (module ? module + '/' : '') + code; + for (i = 2; i < arguments.length; i++) { + message = message + (i == 2 ? '?' : '&') + 'p' + (i-2) + '=' + + encodeURIComponent(stringify(arguments[i])); + } + + return new Error(message); + }; +} + +/* We need to tell jshint what variables are being exported */ +/* global + -angular, + -msie, + -jqLite, + -jQuery, + -slice, + -push, + -toString, + -ngMinErr, + -angularModule, + -nodeName_, + -uid, + -VALIDITY_STATE_PROPERTY, + + -lowercase, + -uppercase, + -manualLowercase, + -manualUppercase, + -nodeName_, + -isArrayLike, + -forEach, + -sortedKeys, + -forEachSorted, + -reverseParams, + -nextUid, + -setHashKey, + -extend, + -int, + -inherit, + -noop, + -identity, + -valueFn, + -isUndefined, + -isDefined, + -isObject, + -isString, + -isNumber, + -isDate, + -isArray, + -isFunction, + -isRegExp, + -isWindow, + -isScope, + -isFile, + -isBlob, + -isBoolean, + -trim, + -isElement, + -makeMap, + -map, + -size, + -includes, + -indexOf, + -arrayRemove, + -isLeafNode, + -copy, + -shallowCopy, + -equals, + -csp, + -concat, + -sliceArgs, + -bind, + -toJsonReplacer, + -toJson, + -fromJson, + -toBoolean, + -startingTag, + -tryDecodeURIComponent, + -parseKeyValue, + -toKeyValue, + -encodeUriSegment, + -encodeUriQuery, + -angularInit, + -bootstrap, + -snake_case, + -bindJQuery, + -assertArg, + -assertArgFn, + -assertNotHasOwnProperty, + -getter, + -getBlockElements, + -hasOwnProperty, + +*/ + +//////////////////////////////////// + +/** + * @ngdoc module + * @name ng + * @module ng + * @description + * + * # ng (core module) + * The ng module is loaded by default when an AngularJS application is started. The module itself + * contains the essential components for an AngularJS application to function. The table below + * lists a high level breakdown of each of the services/factories, filters, directives and testing + * components available within this core module. + * + *
+ */ + +// The name of a form control's ValidityState property. +// This is used so that it's possible for internal tests to create mock ValidityStates. +var VALIDITY_STATE_PROPERTY = 'validity'; + +/** + * @ngdoc function + * @name angular.lowercase + * @module ng + * @kind function + * + * @description Converts the specified string to lowercase. + * @param {string} string String to be converted to lowercase. + * @returns {string} Lowercased string. + */ +var lowercase = function(string){return isString(string) ? string.toLowerCase() : string;}; +var hasOwnProperty = Object.prototype.hasOwnProperty; + +/** + * @ngdoc function + * @name angular.uppercase + * @module ng + * @kind function + * + * @description Converts the specified string to uppercase. + * @param {string} string String to be converted to uppercase. + * @returns {string} Uppercased string. + */ +var uppercase = function(string){return isString(string) ? string.toUpperCase() : string;}; + + +var manualLowercase = function(s) { + /* jshint bitwise: false */ + return isString(s) + ? s.replace(/[A-Z]/g, function(ch) {return String.fromCharCode(ch.charCodeAt(0) | 32);}) + : s; +}; +var manualUppercase = function(s) { + /* jshint bitwise: false */ + return isString(s) + ? s.replace(/[a-z]/g, function(ch) {return String.fromCharCode(ch.charCodeAt(0) & ~32);}) + : s; +}; + + +// String#toLowerCase and String#toUpperCase don't produce correct results in browsers with Turkish +// locale, for this reason we need to detect this case and redefine lowercase/uppercase methods +// with correct but slower alternatives. +if ('i' !== 'I'.toLowerCase()) { + lowercase = manualLowercase; + uppercase = manualUppercase; +} + + +var /** holds major version number for IE or NaN for real browsers */ + msie, + jqLite, // delay binding since jQuery could be loaded after us. + jQuery, // delay binding + slice = [].slice, + push = [].push, + toString = Object.prototype.toString, + ngMinErr = minErr('ng'), + + /** @name angular */ + angular = window.angular || (window.angular = {}), + angularModule, + nodeName_, + uid = ['0', '0', '0']; + +/** + * IE 11 changed the format of the UserAgent string. + * See http://msdn.microsoft.com/en-us/library/ms537503.aspx + */ +msie = int((/msie (\d+)/.exec(lowercase(navigator.userAgent)) || [])[1]); +if (isNaN(msie)) { + msie = int((/trident\/.*; rv:(\d+)/.exec(lowercase(navigator.userAgent)) || [])[1]); +} + + +/** + * @private + * @param {*} obj + * @return {boolean} Returns true if `obj` is an array or array-like object (NodeList, Arguments, + * String ...) + */ +function isArrayLike(obj) { + if (obj == null || isWindow(obj)) { + return false; + } + + var length = obj.length; + + if (obj.nodeType === 1 && length) { + return true; + } + + return isString(obj) || isArray(obj) || length === 0 || + typeof length === 'number' && length > 0 && (length - 1) in obj; +} + +/** + * @ngdoc function + * @name angular.forEach + * @module ng + * @kind function + * + * @description + * Invokes the `iterator` function once for each item in `obj` collection, which can be either an + * object or an array. The `iterator` function is invoked with `iterator(value, key)`, where `value` + * is the value of an object property or an array element and `key` is the object property key or + * array element index. Specifying a `context` for the function is optional. + * + * It is worth noting that `.forEach` does not iterate over inherited properties because it filters + * using the `hasOwnProperty` method. + * + ```js + var values = {name: 'misko', gender: 'male'}; + var log = []; + angular.forEach(values, function(value, key) { + this.push(key + ': ' + value); + }, log); + expect(log).toEqual(['name: misko', 'gender: male']); + ``` + * + * @param {Object|Array} obj Object to iterate over. + * @param {Function} iterator Iterator function. + * @param {Object=} context Object to become context (`this`) for the iterator function. + * @returns {Object|Array} Reference to `obj`. + */ +function forEach(obj, iterator, context) { + var key; + if (obj) { + if (isFunction(obj)) { + for (key in obj) { + // Need to check if hasOwnProperty exists, + // as on IE8 the result of querySelectorAll is an object without a hasOwnProperty function + if (key != 'prototype' && key != 'length' && key != 'name' && (!obj.hasOwnProperty || obj.hasOwnProperty(key))) { + iterator.call(context, obj[key], key); + } + } + } else if (obj.forEach && obj.forEach !== forEach) { + obj.forEach(iterator, context); + } else if (isArrayLike(obj)) { + for (key = 0; key < obj.length; key++) + iterator.call(context, obj[key], key); + } else { + for (key in obj) { + if (obj.hasOwnProperty(key)) { + iterator.call(context, obj[key], key); + } + } + } + } + return obj; +} + +function sortedKeys(obj) { + var keys = []; + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + keys.push(key); + } + } + return keys.sort(); +} + +function forEachSorted(obj, iterator, context) { + var keys = sortedKeys(obj); + for ( var i = 0; i < keys.length; i++) { + iterator.call(context, obj[keys[i]], keys[i]); + } + return keys; +} + + +/** + * when using forEach the params are value, key, but it is often useful to have key, value. + * @param {function(string, *)} iteratorFn + * @returns {function(*, string)} + */ +function reverseParams(iteratorFn) { + return function(value, key) { iteratorFn(key, value); }; +} + +/** + * A consistent way of creating unique IDs in angular. The ID is a sequence of alpha numeric + * characters such as '012ABC'. The reason why we are not using simply a number counter is that + * the number string gets longer over time, and it can also overflow, where as the nextId + * will grow much slower, it is a string, and it will never overflow. + * + * @returns {string} an unique alpha-numeric string + */ +function nextUid() { + var index = uid.length; + var digit; + + while(index) { + index--; + digit = uid[index].charCodeAt(0); + if (digit == 57 /*'9'*/) { + uid[index] = 'A'; + return uid.join(''); + } + if (digit == 90 /*'Z'*/) { + uid[index] = '0'; + } else { + uid[index] = String.fromCharCode(digit + 1); + return uid.join(''); + } + } + uid.unshift('0'); + return uid.join(''); +} + + +/** + * Set or clear the hashkey for an object. + * @param obj object + * @param h the hashkey (!truthy to delete the hashkey) + */ +function setHashKey(obj, h) { + if (h) { + obj.$$hashKey = h; + } + else { + delete obj.$$hashKey; + } +} + +/** + * @ngdoc function + * @name angular.extend + * @module ng + * @kind function + * + * @description + * Extends the destination object `dst` by copying all of the properties from the `src` object(s) + * to `dst`. You can specify multiple `src` objects. + * + * @param {Object} dst Destination object. + * @param {...Object} src Source object(s). + * @returns {Object} Reference to `dst`. + */ +function extend(dst) { + var h = dst.$$hashKey; + forEach(arguments, function(obj) { + if (obj !== dst) { + forEach(obj, function(value, key) { + dst[key] = value; + }); + } + }); + + setHashKey(dst,h); + return dst; +} + +function int(str) { + return parseInt(str, 10); +} + + +function inherit(parent, extra) { + return extend(new (extend(function() {}, {prototype:parent}))(), extra); +} + +/** + * @ngdoc function + * @name angular.noop + * @module ng + * @kind function + * + * @description + * A function that performs no operations. This function can be useful when writing code in the + * functional style. + ```js + function foo(callback) { + var result = calculateResult(); + (callback || angular.noop)(result); + } + ``` + */ +function noop() {} +noop.$inject = []; + + +/** + * @ngdoc function + * @name angular.identity + * @module ng + * @kind function + * + * @description + * A function that returns its first argument. This function is useful when writing code in the + * functional style. + * + ```js + function transformer(transformationFn, value) { + return (transformationFn || angular.identity)(value); + }; + ``` + */ +function identity($) {return $;} +identity.$inject = []; + + +function valueFn(value) {return function() {return value;};} + +/** + * @ngdoc function + * @name angular.isUndefined + * @module ng + * @kind function + * + * @description + * Determines if a reference is undefined. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is undefined. + */ +function isUndefined(value){return typeof value === 'undefined';} + + +/** + * @ngdoc function + * @name angular.isDefined + * @module ng + * @kind function + * + * @description + * Determines if a reference is defined. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is defined. + */ +function isDefined(value){return typeof value !== 'undefined';} + + +/** + * @ngdoc function + * @name angular.isObject + * @module ng + * @kind function + * + * @description + * Determines if a reference is an `Object`. Unlike `typeof` in JavaScript, `null`s are not + * considered to be objects. Note that JavaScript arrays are objects. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is an `Object` but not `null`. + */ +function isObject(value){return value != null && typeof value === 'object';} + + +/** + * @ngdoc function + * @name angular.isString + * @module ng + * @kind function + * + * @description + * Determines if a reference is a `String`. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is a `String`. + */ +function isString(value){return typeof value === 'string';} + + +/** + * @ngdoc function + * @name angular.isNumber + * @module ng + * @kind function + * + * @description + * Determines if a reference is a `Number`. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is a `Number`. + */ +function isNumber(value){return typeof value === 'number';} + + +/** + * @ngdoc function + * @name angular.isDate + * @module ng + * @kind function + * + * @description + * Determines if a value is a date. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is a `Date`. + */ +function isDate(value) { + return toString.call(value) === '[object Date]'; +} + + +/** + * @ngdoc function + * @name angular.isArray + * @module ng + * @kind function + * + * @description + * Determines if a reference is an `Array`. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is an `Array`. + */ +var isArray = (function() { + if (!isFunction(Array.isArray)) { + return function(value) { + return toString.call(value) === '[object Array]'; + }; + } + return Array.isArray; +})(); + +/** + * @ngdoc function + * @name angular.isFunction + * @module ng + * @kind function + * + * @description + * Determines if a reference is a `Function`. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is a `Function`. + */ +function isFunction(value){return typeof value === 'function';} + + +/** + * Determines if a value is a regular expression object. + * + * @private + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is a `RegExp`. + */ +function isRegExp(value) { + return toString.call(value) === '[object RegExp]'; +} + + +/** + * Checks if `obj` is a window object. + * + * @private + * @param {*} obj Object to check + * @returns {boolean} True if `obj` is a window obj. + */ +function isWindow(obj) { + return obj && obj.document && obj.location && obj.alert && obj.setInterval; +} + + +function isScope(obj) { + return obj && obj.$evalAsync && obj.$watch; +} + + +function isFile(obj) { + return toString.call(obj) === '[object File]'; +} + + +function isBlob(obj) { + return toString.call(obj) === '[object Blob]'; +} + + +function isBoolean(value) { + return typeof value === 'boolean'; +} + + +var trim = (function() { + // native trim is way faster: http://jsperf.com/angular-trim-test + // but IE doesn't have it... :-( + // TODO: we should move this into IE/ES5 polyfill + if (!String.prototype.trim) { + return function(value) { + return isString(value) ? value.replace(/^\s\s*/, '').replace(/\s\s*$/, '') : value; + }; + } + return function(value) { + return isString(value) ? value.trim() : value; + }; +})(); + + +/** + * @ngdoc function + * @name angular.isElement + * @module ng + * @kind function + * + * @description + * Determines if a reference is a DOM element (or wrapped jQuery element). + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is a DOM element (or wrapped jQuery element). + */ +function isElement(node) { + return !!(node && + (node.nodeName // we are a direct element + || (node.prop && node.attr && node.find))); // we have an on and find method part of jQuery API +} + +/** + * @param str 'key1,key2,...' + * @returns {object} in the form of {key1:true, key2:true, ...} + */ +function makeMap(str) { + var obj = {}, items = str.split(","), i; + for ( i = 0; i < items.length; i++ ) + obj[ items[i] ] = true; + return obj; +} + + +if (msie < 9) { + nodeName_ = function(element) { + element = element.nodeName ? element : element[0]; + return (element.scopeName && element.scopeName != 'HTML') + ? uppercase(element.scopeName + ':' + element.nodeName) : element.nodeName; + }; +} else { + nodeName_ = function(element) { + return element.nodeName ? element.nodeName : element[0].nodeName; + }; +} + + +function map(obj, iterator, context) { + var results = []; + forEach(obj, function(value, index, list) { + results.push(iterator.call(context, value, index, list)); + }); + return results; +} + + +/** + * @description + * Determines the number of elements in an array, the number of properties an object has, or + * the length of a string. + * + * Note: This function is used to augment the Object type in Angular expressions. See + * {@link angular.Object} for more information about Angular arrays. + * + * @param {Object|Array|string} obj Object, array, or string to inspect. + * @param {boolean} [ownPropsOnly=false] Count only "own" properties in an object + * @returns {number} The size of `obj` or `0` if `obj` is neither an object nor an array. + */ +function size(obj, ownPropsOnly) { + var count = 0, key; + + if (isArray(obj) || isString(obj)) { + return obj.length; + } else if (isObject(obj)) { + for (key in obj) + if (!ownPropsOnly || obj.hasOwnProperty(key)) + count++; + } + + return count; +} + + +function includes(array, obj) { + return indexOf(array, obj) != -1; +} + +function indexOf(array, obj) { + if (array.indexOf) return array.indexOf(obj); + + for (var i = 0; i < array.length; i++) { + if (obj === array[i]) return i; + } + return -1; +} + +function arrayRemove(array, value) { + var index = indexOf(array, value); + if (index >=0) + array.splice(index, 1); + return value; +} + +function isLeafNode (node) { + if (node) { + switch (node.nodeName) { + case "OPTION": + case "PRE": + case "TITLE": + return true; + } + } + return false; +} + +/** + * @ngdoc function + * @name angular.copy + * @module ng + * @kind function + * + * @description + * Creates a deep copy of `source`, which should be an object or an array. + * + * * If no destination is supplied, a copy of the object or array is created. + * * If a destination is provided, all of its elements (for array) or properties (for objects) + * are deleted and then all elements/properties from the source are copied to it. + * * If `source` is not an object or array (inc. `null` and `undefined`), `source` is returned. + * * If `source` is identical to 'destination' an exception will be thrown. + * + * @param {*} source The source that will be used to make a copy. + * Can be any type, including primitives, `null`, and `undefined`. + * @param {(Object|Array)=} destination Destination into which the source is copied. If + * provided, must be of the same type as `source`. + * @returns {*} The copy or updated `destination`, if `destination` was specified. + * + * @example + + +
+
+ Name:
+ E-mail:
+ Gender: male + female
+ + +
+
form = {{user | json}}
+
master = {{master | json}}
+
+ + +
+
+ */ +function copy(source, destination, stackSource, stackDest) { + if (isWindow(source) || isScope(source)) { + throw ngMinErr('cpws', + "Can't copy! Making copies of Window or Scope instances is not supported."); + } + + if (!destination) { + destination = source; + if (source) { + if (isArray(source)) { + destination = copy(source, [], stackSource, stackDest); + } else if (isDate(source)) { + destination = new Date(source.getTime()); + } else if (isRegExp(source)) { + destination = new RegExp(source.source); + } else if (isObject(source)) { + destination = copy(source, {}, stackSource, stackDest); + } + } + } else { + if (source === destination) throw ngMinErr('cpi', + "Can't copy! Source and destination are identical."); + + stackSource = stackSource || []; + stackDest = stackDest || []; + + if (isObject(source)) { + var index = indexOf(stackSource, source); + if (index !== -1) return stackDest[index]; + + stackSource.push(source); + stackDest.push(destination); + } + + var result; + if (isArray(source)) { + destination.length = 0; + for ( var i = 0; i < source.length; i++) { + result = copy(source[i], null, stackSource, stackDest); + if (isObject(source[i])) { + stackSource.push(source[i]); + stackDest.push(result); + } + destination.push(result); + } + } else { + var h = destination.$$hashKey; + forEach(destination, function(value, key) { + delete destination[key]; + }); + for ( var key in source) { + result = copy(source[key], null, stackSource, stackDest); + if (isObject(source[key])) { + stackSource.push(source[key]); + stackDest.push(result); + } + destination[key] = result; + } + setHashKey(destination,h); + } + + } + return destination; +} + +/** + * Creates a shallow copy of an object, an array or a primitive + */ +function shallowCopy(src, dst) { + if (isArray(src)) { + dst = dst || []; + + for ( var i = 0; i < src.length; i++) { + dst[i] = src[i]; + } + } else if (isObject(src)) { + dst = dst || {}; + + for (var key in src) { + if (hasOwnProperty.call(src, key) && !(key.charAt(0) === '$' && key.charAt(1) === '$')) { + dst[key] = src[key]; + } + } + } + + return dst || src; +} + + +/** + * @ngdoc function + * @name angular.equals + * @module ng + * @kind function + * + * @description + * Determines if two objects or two values are equivalent. Supports value types, regular + * expressions, arrays and objects. + * + * Two objects or values are considered equivalent if at least one of the following is true: + * + * * Both objects or values pass `===` comparison. + * * Both objects or values are of the same type and all of their properties are equal by + * comparing them with `angular.equals`. + * * Both values are NaN. (In JavaScript, NaN == NaN => false. But we consider two NaN as equal) + * * Both values represent the same regular expression (In JavaScript, + * /abc/ == /abc/ => false. But we consider two regular expressions as equal when their textual + * representation matches). + * + * During a property comparison, properties of `function` type and properties with names + * that begin with `$` are ignored. + * + * Scope and DOMWindow objects are being compared only by identify (`===`). + * + * @param {*} o1 Object or value to compare. + * @param {*} o2 Object or value to compare. + * @returns {boolean} True if arguments are equal. + */ +function equals(o1, o2) { + if (o1 === o2) return true; + if (o1 === null || o2 === null) return false; + if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN + var t1 = typeof o1, t2 = typeof o2, length, key, keySet; + if (t1 == t2) { + if (t1 == 'object') { + if (isArray(o1)) { + if (!isArray(o2)) return false; + if ((length = o1.length) == o2.length) { + for(key=0; key 2 ? sliceArgs(arguments, 2) : []; + if (isFunction(fn) && !(fn instanceof RegExp)) { + return curryArgs.length + ? function() { + return arguments.length + ? fn.apply(self, curryArgs.concat(slice.call(arguments, 0))) + : fn.apply(self, curryArgs); + } + : function() { + return arguments.length + ? fn.apply(self, arguments) + : fn.call(self); + }; + } else { + // in IE, native methods are not functions so they cannot be bound (note: they don't need to be) + return fn; + } +} + + +function toJsonReplacer(key, value) { + var val = value; + + if (typeof key === 'string' && key.charAt(0) === '$') { + val = undefined; + } else if (isWindow(value)) { + val = '$WINDOW'; + } else if (value && document === value) { + val = '$DOCUMENT'; + } else if (isScope(value)) { + val = '$SCOPE'; + } + + return val; +} + + +/** + * @ngdoc function + * @name angular.toJson + * @module ng + * @kind function + * + * @description + * Serializes input into a JSON-formatted string. Properties with leading $ characters will be + * stripped since angular uses this notation internally. + * + * @param {Object|Array|Date|string|number} obj Input to be serialized into JSON. + * @param {boolean=} pretty If set to true, the JSON output will contain newlines and whitespace. + * @returns {string|undefined} JSON-ified string representing `obj`. + */ +function toJson(obj, pretty) { + if (typeof obj === 'undefined') return undefined; + return JSON.stringify(obj, toJsonReplacer, pretty ? ' ' : null); +} + + +/** + * @ngdoc function + * @name angular.fromJson + * @module ng + * @kind function + * + * @description + * Deserializes a JSON string. + * + * @param {string} json JSON string to deserialize. + * @returns {Object|Array|string|number} Deserialized thingy. + */ +function fromJson(json) { + return isString(json) + ? JSON.parse(json) + : json; +} + + +function toBoolean(value) { + if (typeof value === 'function') { + value = true; + } else if (value && value.length !== 0) { + var v = lowercase("" + value); + value = !(v == 'f' || v == '0' || v == 'false' || v == 'no' || v == 'n' || v == '[]'); + } else { + value = false; + } + return value; +} + +/** + * @returns {string} Returns the string representation of the element. + */ +function startingTag(element) { + element = jqLite(element).clone(); + try { + // turns out IE does not let you set .html() on elements which + // are not allowed to have children. So we just ignore it. + element.empty(); + } catch(e) {} + // As Per DOM Standards + var TEXT_NODE = 3; + var elemHtml = jqLite('
').append(element).html(); + try { + return element[0].nodeType === TEXT_NODE ? lowercase(elemHtml) : + elemHtml. + match(/^(<[^>]+>)/)[1]. + replace(/^<([\w\-]+)/, function(match, nodeName) { return '<' + lowercase(nodeName); }); + } catch(e) { + return lowercase(elemHtml); + } + +} + + +///////////////////////////////////////////////// + +/** + * Tries to decode the URI component without throwing an exception. + * + * @private + * @param str value potential URI component to check. + * @returns {boolean} True if `value` can be decoded + * with the decodeURIComponent function. + */ +function tryDecodeURIComponent(value) { + try { + return decodeURIComponent(value); + } catch(e) { + // Ignore any invalid uri component + } +} + + +/** + * Parses an escaped url query string into key-value pairs. + * @returns {Object.} + */ +function parseKeyValue(/**string*/keyValue) { + var obj = {}, key_value, key; + forEach((keyValue || "").split('&'), function(keyValue) { + if ( keyValue ) { + key_value = keyValue.split('='); + key = tryDecodeURIComponent(key_value[0]); + if ( isDefined(key) ) { + var val = isDefined(key_value[1]) ? tryDecodeURIComponent(key_value[1]) : true; + if (!hasOwnProperty.call(obj, key)) { + obj[key] = val; + } else if(isArray(obj[key])) { + obj[key].push(val); + } else { + obj[key] = [obj[key],val]; + } + } + } + }); + return obj; +} + +function toKeyValue(obj) { + var parts = []; + forEach(obj, function(value, key) { + if (isArray(value)) { + forEach(value, function(arrayValue) { + parts.push(encodeUriQuery(key, true) + + (arrayValue === true ? '' : '=' + encodeUriQuery(arrayValue, true))); + }); + } else { + parts.push(encodeUriQuery(key, true) + + (value === true ? '' : '=' + encodeUriQuery(value, true))); + } + }); + return parts.length ? parts.join('&') : ''; +} + + +/** + * We need our custom method because encodeURIComponent is too aggressive and doesn't follow + * http://www.ietf.org/rfc/rfc3986.txt with regards to the character set (pchar) allowed in path + * segments: + * segment = *pchar + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * pct-encoded = "%" HEXDIG HEXDIG + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + */ +function encodeUriSegment(val) { + return encodeUriQuery(val, true). + replace(/%26/gi, '&'). + replace(/%3D/gi, '='). + replace(/%2B/gi, '+'); +} + + +/** + * This method is intended for encoding *key* or *value* parts of query component. We need a custom + * method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be + * encoded per http://tools.ietf.org/html/rfc3986: + * query = *( pchar / "/" / "?" ) + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * pct-encoded = "%" HEXDIG HEXDIG + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + */ +function encodeUriQuery(val, pctEncodeSpaces) { + return encodeURIComponent(val). + replace(/%40/gi, '@'). + replace(/%3A/gi, ':'). + replace(/%24/g, '$'). + replace(/%2C/gi, ','). + replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); +} + + +/** + * @ngdoc directive + * @name ngApp + * @module ng + * + * @element ANY + * @param {angular.Module} ngApp an optional application + * {@link angular.module module} name to load. + * + * @description + * + * Use this directive to **auto-bootstrap** an AngularJS application. The `ngApp` directive + * designates the **root element** of the application and is typically placed near the root element + * of the page - e.g. on the `` or `` tags. + * + * Only one AngularJS application can be auto-bootstrapped per HTML document. The first `ngApp` + * found in the document will be used to define the root element to auto-bootstrap as an + * application. To run multiple applications in an HTML document you must manually bootstrap them using + * {@link angular.bootstrap} instead. AngularJS applications cannot be nested within each other. + * + * You can specify an **AngularJS module** to be used as the root module for the application. This + * module will be loaded into the {@link auto.$injector} when the application is bootstrapped and + * should contain the application code needed or have dependencies on other modules that will + * contain the code. See {@link angular.module} for more information. + * + * In the example below if the `ngApp` directive were not placed on the `html` element then the + * document would not be compiled, the `AppController` would not be instantiated and the `{{ a+b }}` + * would not be resolved to `3`. + * + * `ngApp` is the easiest, and most common, way to bootstrap an application. + * + + +
+ I can add: {{a}} + {{b}} = {{ a+b }} +
+
+ + angular.module('ngAppDemo', []).controller('ngAppDemoController', function($scope) { + $scope.a = 1; + $scope.b = 2; + }); + +
+ * + */ +function angularInit(element, bootstrap) { + var elements = [element], + appElement, + module, + names = ['ng:app', 'ng-app', 'x-ng-app', 'data-ng-app'], + NG_APP_CLASS_REGEXP = /\sng[:\-]app(:\s*([\w\d_]+);?)?\s/; + + function append(element) { + element && elements.push(element); + } + + forEach(names, function(name) { + names[name] = true; + append(document.getElementById(name)); + name = name.replace(':', '\\:'); + if (element.querySelectorAll) { + forEach(element.querySelectorAll('.' + name), append); + forEach(element.querySelectorAll('.' + name + '\\:'), append); + forEach(element.querySelectorAll('[' + name + ']'), append); + } + }); + + forEach(elements, function(element) { + if (!appElement) { + var className = ' ' + element.className + ' '; + var match = NG_APP_CLASS_REGEXP.exec(className); + if (match) { + appElement = element; + module = (match[2] || '').replace(/\s+/g, ','); + } else { + forEach(element.attributes, function(attr) { + if (!appElement && names[attr.name]) { + appElement = element; + module = attr.value; + } + }); + } + } + }); + if (appElement) { + bootstrap(appElement, module ? [module] : []); + } +} + +/** + * @ngdoc function + * @name angular.bootstrap + * @module ng + * @description + * Use this function to manually start up angular application. + * + * See: {@link guide/bootstrap Bootstrap} + * + * Note that ngScenario-based end-to-end tests cannot use this function to bootstrap manually. + * They must use {@link ng.directive:ngApp ngApp}. + * + * Angular will detect if it has been loaded into the browser more than once and only allow the + * first loaded script to be bootstrapped and will report a warning to the browser console for + * each of the subsequent scripts. This prevents strange results in applications, where otherwise + * multiple instances of Angular try to work on the DOM. + * + * + * + * + *
+ * + * + * + * + * + * + * + *
{{heading}}
{{fill}}
+ *
+ *
+ * + * var app = angular.module('multi-bootstrap', []) + * + * .controller('BrokenTable', function($scope) { + * $scope.headings = ['One', 'Two', 'Three']; + * $scope.fillings = [[1, 2, 3], ['A', 'B', 'C'], [7, 8, 9]]; + * }); + * + * + * it('should only insert one table cell for each item in $scope.fillings', function() { + * expect(element.all(by.css('td')).count()) + * .toBe(9); + * }); + * + *
+ * + * @param {DOMElement} element DOM element which is the root of angular application. + * @param {Array=} modules an array of modules to load into the application. + * Each item in the array should be the name of a predefined module or a (DI annotated) + * function that will be invoked by the injector as a run block. + * See: {@link angular.module modules} + * @returns {auto.$injector} Returns the newly created injector for this app. + */ +function bootstrap(element, modules) { + var doBootstrap = function() { + element = jqLite(element); + + if (element.injector()) { + var tag = (element[0] === document) ? 'document' : startingTag(element); + throw ngMinErr('btstrpd', "App Already Bootstrapped with this Element '{0}'", tag); + } + + modules = modules || []; + modules.unshift(['$provide', function($provide) { + $provide.value('$rootElement', element); + }]); + modules.unshift('ng'); + var injector = createInjector(modules); + injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', '$animate', + function(scope, element, compile, injector, animate) { + scope.$apply(function() { + element.data('$injector', injector); + compile(element)(scope); + }); + }] + ); + return injector; + }; + + var NG_DEFER_BOOTSTRAP = /^NG_DEFER_BOOTSTRAP!/; + + if (window && !NG_DEFER_BOOTSTRAP.test(window.name)) { + return doBootstrap(); + } + + window.name = window.name.replace(NG_DEFER_BOOTSTRAP, ''); + angular.resumeBootstrap = function(extraModules) { + forEach(extraModules, function(module) { + modules.push(module); + }); + doBootstrap(); + }; +} + +var SNAKE_CASE_REGEXP = /[A-Z]/g; +function snake_case(name, separator) { + separator = separator || '_'; + return name.replace(SNAKE_CASE_REGEXP, function(letter, pos) { + return (pos ? separator : '') + letter.toLowerCase(); + }); +} + +function bindJQuery() { + // bind to jQuery if present; + jQuery = window.jQuery; + // Use jQuery if it exists with proper functionality, otherwise default to us. + // Angular 1.2+ requires jQuery 1.7.1+ for on()/off() support. + if (jQuery && jQuery.fn.on) { + jqLite = jQuery; + extend(jQuery.fn, { + scope: JQLitePrototype.scope, + isolateScope: JQLitePrototype.isolateScope, + controller: JQLitePrototype.controller, + injector: JQLitePrototype.injector, + inheritedData: JQLitePrototype.inheritedData + }); + // Method signature: + // jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) + jqLitePatchJQueryRemove('remove', true, true, false); + jqLitePatchJQueryRemove('empty', false, false, false); + jqLitePatchJQueryRemove('html', false, false, true); + } else { + jqLite = JQLite; + } + angular.element = jqLite; +} + +/** + * throw error if the argument is falsy. + */ +function assertArg(arg, name, reason) { + if (!arg) { + throw ngMinErr('areq', "Argument '{0}' is {1}", (name || '?'), (reason || "required")); + } + return arg; +} + +function assertArgFn(arg, name, acceptArrayAnnotation) { + if (acceptArrayAnnotation && isArray(arg)) { + arg = arg[arg.length - 1]; + } + + assertArg(isFunction(arg), name, 'not a function, got ' + + (arg && typeof arg === 'object' ? arg.constructor.name || 'Object' : typeof arg)); + return arg; +} + +/** + * throw error if the name given is hasOwnProperty + * @param {String} name the name to test + * @param {String} context the context in which the name is used, such as module or directive + */ +function assertNotHasOwnProperty(name, context) { + if (name === 'hasOwnProperty') { + throw ngMinErr('badname', "hasOwnProperty is not a valid {0} name", context); + } +} + +/** + * Return the value accessible from the object by path. Any undefined traversals are ignored + * @param {Object} obj starting object + * @param {String} path path to traverse + * @param {boolean} [bindFnToScope=true] + * @returns {Object} value as accessible by path + */ +//TODO(misko): this function needs to be removed +function getter(obj, path, bindFnToScope) { + if (!path) return obj; + var keys = path.split('.'); + var key; + var lastInstance = obj; + var len = keys.length; + + for (var i = 0; i < len; i++) { + key = keys[i]; + if (obj) { + obj = (lastInstance = obj)[key]; + } + } + if (!bindFnToScope && isFunction(obj)) { + return bind(lastInstance, obj); + } + return obj; +} + +/** + * Return the DOM siblings between the first and last node in the given array. + * @param {Array} array like object + * @returns {DOMElement} object containing the elements + */ +function getBlockElements(nodes) { + var startNode = nodes[0], + endNode = nodes[nodes.length - 1]; + if (startNode === endNode) { + return jqLite(startNode); + } + + var element = startNode; + var elements = [element]; + + do { + element = element.nextSibling; + if (!element) break; + elements.push(element); + } while (element !== endNode); + + return jqLite(elements); +} + +/** + * @ngdoc type + * @name angular.Module + * @module ng + * @description + * + * Interface for configuring angular {@link angular.module modules}. + */ + +function setupModuleLoader(window) { + + var $injectorMinErr = minErr('$injector'); + var ngMinErr = minErr('ng'); + + function ensure(obj, name, factory) { + return obj[name] || (obj[name] = factory()); + } + + var angular = ensure(window, 'angular', Object); + + // We need to expose `angular.$$minErr` to modules such as `ngResource` that reference it during bootstrap + angular.$$minErr = angular.$$minErr || minErr; + + return ensure(angular, 'module', function() { + /** @type {Object.} */ + var modules = {}; + + /** + * @ngdoc function + * @name angular.module + * @module ng + * @description + * + * The `angular.module` is a global place for creating, registering and retrieving Angular + * modules. + * All modules (angular core or 3rd party) that should be available to an application must be + * registered using this mechanism. + * + * When passed two or more arguments, a new module is created. If passed only one argument, an + * existing module (the name passed as the first argument to `module`) is retrieved. + * + * + * # Module + * + * A module is a collection of services, directives, controllers, filters, and configuration information. + * `angular.module` is used to configure the {@link auto.$injector $injector}. + * + * ```js + * // Create a new module + * var myModule = angular.module('myModule', []); + * + * // register a new service + * myModule.value('appName', 'MyCoolApp'); + * + * // configure existing services inside initialization blocks. + * myModule.config(['$locationProvider', function($locationProvider) { + * // Configure existing providers + * $locationProvider.hashPrefix('!'); + * }]); + * ``` + * + * Then you can create an injector and load your modules like this: + * + * ```js + * var injector = angular.injector(['ng', 'myModule']) + * ``` + * + * However it's more likely that you'll just use + * {@link ng.directive:ngApp ngApp} or + * {@link angular.bootstrap} to simplify this process for you. + * + * @param {!string} name The name of the module to create or retrieve. + * @param {!Array.=} requires If specified then new module is being created. If + * unspecified then the module is being retrieved for further configuration. + * @param {Function=} configFn Optional configuration function for the module. Same as + * {@link angular.Module#config Module#config()}. + * @returns {module} new module with the {@link angular.Module} api. + */ + return function module(name, requires, configFn) { + var assertNotHasOwnProperty = function(name, context) { + if (name === 'hasOwnProperty') { + throw ngMinErr('badname', 'hasOwnProperty is not a valid {0} name', context); + } + }; + + assertNotHasOwnProperty(name, 'module'); + if (requires && modules.hasOwnProperty(name)) { + modules[name] = null; + } + return ensure(modules, name, function() { + if (!requires) { + throw $injectorMinErr('nomod', "Module '{0}' is not available! You either misspelled " + + "the module name or forgot to load it. If registering a module ensure that you " + + "specify the dependencies as the second argument.", name); + } + + /** @type {!Array.>} */ + var invokeQueue = []; + + /** @type {!Array.} */ + var runBlocks = []; + + var config = invokeLater('$injector', 'invoke'); + + /** @type {angular.Module} */ + var moduleInstance = { + // Private state + _invokeQueue: invokeQueue, + _runBlocks: runBlocks, + + /** + * @ngdoc property + * @name angular.Module#requires + * @module ng + * @returns {Array.} List of module names which must be loaded before this module. + * @description + * Holds the list of modules which the injector will load before the current module is + * loaded. + */ + requires: requires, + + /** + * @ngdoc property + * @name angular.Module#name + * @module ng + * @returns {string} Name of the module. + * @description + */ + name: name, + + + /** + * @ngdoc method + * @name angular.Module#provider + * @module ng + * @param {string} name service name + * @param {Function} providerType Construction function for creating new instance of the + * service. + * @description + * See {@link auto.$provide#provider $provide.provider()}. + */ + provider: invokeLater('$provide', 'provider'), + + /** + * @ngdoc method + * @name angular.Module#factory + * @module ng + * @param {string} name service name + * @param {Function} providerFunction Function for creating new instance of the service. + * @description + * See {@link auto.$provide#factory $provide.factory()}. + */ + factory: invokeLater('$provide', 'factory'), + + /** + * @ngdoc method + * @name angular.Module#service + * @module ng + * @param {string} name service name + * @param {Function} constructor A constructor function that will be instantiated. + * @description + * See {@link auto.$provide#service $provide.service()}. + */ + service: invokeLater('$provide', 'service'), + + /** + * @ngdoc method + * @name angular.Module#value + * @module ng + * @param {string} name service name + * @param {*} object Service instance object. + * @description + * See {@link auto.$provide#value $provide.value()}. + */ + value: invokeLater('$provide', 'value'), + + /** + * @ngdoc method + * @name angular.Module#constant + * @module ng + * @param {string} name constant name + * @param {*} object Constant value. + * @description + * Because the constant are fixed, they get applied before other provide methods. + * See {@link auto.$provide#constant $provide.constant()}. + */ + constant: invokeLater('$provide', 'constant', 'unshift'), + + /** + * @ngdoc method + * @name angular.Module#animation + * @module ng + * @param {string} name animation name + * @param {Function} animationFactory Factory function for creating new instance of an + * animation. + * @description + * + * **NOTE**: animations take effect only if the **ngAnimate** module is loaded. + * + * + * Defines an animation hook that can be later used with + * {@link ngAnimate.$animate $animate} service and directives that use this service. + * + * ```js + * module.animation('.animation-name', function($inject1, $inject2) { + * return { + * eventName : function(element, done) { + * //code to run the animation + * //once complete, then run done() + * return function cancellationFunction(element) { + * //code to cancel the animation + * } + * } + * } + * }) + * ``` + * + * See {@link ngAnimate.$animateProvider#register $animateProvider.register()} and + * {@link ngAnimate ngAnimate module} for more information. + */ + animation: invokeLater('$animateProvider', 'register'), + + /** + * @ngdoc method + * @name angular.Module#filter + * @module ng + * @param {string} name Filter name. + * @param {Function} filterFactory Factory function for creating new instance of filter. + * @description + * See {@link ng.$filterProvider#register $filterProvider.register()}. + */ + filter: invokeLater('$filterProvider', 'register'), + + /** + * @ngdoc method + * @name angular.Module#controller + * @module ng + * @param {string|Object} name Controller name, or an object map of controllers where the + * keys are the names and the values are the constructors. + * @param {Function} constructor Controller constructor function. + * @description + * See {@link ng.$controllerProvider#register $controllerProvider.register()}. + */ + controller: invokeLater('$controllerProvider', 'register'), + + /** + * @ngdoc method + * @name angular.Module#directive + * @module ng + * @param {string|Object} name Directive name, or an object map of directives where the + * keys are the names and the values are the factories. + * @param {Function} directiveFactory Factory function for creating new instance of + * directives. + * @description + * See {@link ng.$compileProvider#directive $compileProvider.directive()}. + */ + directive: invokeLater('$compileProvider', 'directive'), + + /** + * @ngdoc method + * @name angular.Module#config + * @module ng + * @param {Function} configFn Execute this function on module load. Useful for service + * configuration. + * @description + * Use this method to register work which needs to be performed on module loading. + * For more about how to configure services, see + * {@link providers#providers_provider-recipe Provider Recipe}. + */ + config: config, + + /** + * @ngdoc method + * @name angular.Module#run + * @module ng + * @param {Function} initializationFn Execute this function after injector creation. + * Useful for application initialization. + * @description + * Use this method to register work which should be performed when the injector is done + * loading all modules. + */ + run: function(block) { + runBlocks.push(block); + return this; + } + }; + + if (configFn) { + config(configFn); + } + + return moduleInstance; + + /** + * @param {string} provider + * @param {string} method + * @param {String=} insertMethod + * @returns {angular.Module} + */ + function invokeLater(provider, method, insertMethod) { + return function() { + invokeQueue[insertMethod || 'push']([provider, method, arguments]); + return moduleInstance; + }; + } + }); + }; + }); + +} + +/* global + angularModule: true, + version: true, + + $LocaleProvider, + $CompileProvider, + + htmlAnchorDirective, + inputDirective, + inputDirective, + formDirective, + scriptDirective, + selectDirective, + styleDirective, + optionDirective, + ngBindDirective, + ngBindHtmlDirective, + ngBindTemplateDirective, + ngClassDirective, + ngClassEvenDirective, + ngClassOddDirective, + ngCspDirective, + ngCloakDirective, + ngControllerDirective, + ngFormDirective, + ngHideDirective, + ngIfDirective, + ngIncludeDirective, + ngIncludeFillContentDirective, + ngInitDirective, + ngNonBindableDirective, + ngPluralizeDirective, + ngRepeatDirective, + ngShowDirective, + ngStyleDirective, + ngSwitchDirective, + ngSwitchWhenDirective, + ngSwitchDefaultDirective, + ngOptionsDirective, + ngTranscludeDirective, + ngModelDirective, + ngListDirective, + ngChangeDirective, + requiredDirective, + requiredDirective, + ngValueDirective, + ngAttributeAliasDirectives, + ngEventDirectives, + + $AnchorScrollProvider, + $AnimateProvider, + $BrowserProvider, + $CacheFactoryProvider, + $ControllerProvider, + $DocumentProvider, + $ExceptionHandlerProvider, + $FilterProvider, + $InterpolateProvider, + $IntervalProvider, + $HttpProvider, + $HttpBackendProvider, + $LocationProvider, + $LogProvider, + $ParseProvider, + $RootScopeProvider, + $QProvider, + $$SanitizeUriProvider, + $SceProvider, + $SceDelegateProvider, + $SnifferProvider, + $TemplateCacheProvider, + $TimeoutProvider, + $$RAFProvider, + $$AsyncCallbackProvider, + $WindowProvider +*/ + + +/** + * @ngdoc object + * @name angular.version + * @module ng + * @description + * An object that contains information about the current AngularJS version. This object has the + * following properties: + * + * - `full` – `{string}` – Full version string, such as "0.9.18". + * - `major` – `{number}` – Major version number, such as "0". + * - `minor` – `{number}` – Minor version number, such as "9". + * - `dot` – `{number}` – Dot version number, such as "18". + * - `codeName` – `{string}` – Code name of the release, such as "jiggling-armfat". + */ +var version = { + full: '1.2.20', // all of these placeholder strings will be replaced by grunt's + major: 1, // package task + minor: 2, + dot: 20, + codeName: 'accidental-beautification' +}; + + +function publishExternalAPI(angular){ + extend(angular, { + 'bootstrap': bootstrap, + 'copy': copy, + 'extend': extend, + 'equals': equals, + 'element': jqLite, + 'forEach': forEach, + 'injector': createInjector, + 'noop':noop, + 'bind':bind, + 'toJson': toJson, + 'fromJson': fromJson, + 'identity':identity, + 'isUndefined': isUndefined, + 'isDefined': isDefined, + 'isString': isString, + 'isFunction': isFunction, + 'isObject': isObject, + 'isNumber': isNumber, + 'isElement': isElement, + 'isArray': isArray, + 'version': version, + 'isDate': isDate, + 'lowercase': lowercase, + 'uppercase': uppercase, + 'callbacks': {counter: 0}, + '$$minErr': minErr, + '$$csp': csp + }); + + angularModule = setupModuleLoader(window); + try { + angularModule('ngLocale'); + } catch (e) { + angularModule('ngLocale', []).provider('$locale', $LocaleProvider); + } + + angularModule('ng', ['ngLocale'], ['$provide', + function ngModule($provide) { + // $$sanitizeUriProvider needs to be before $compileProvider as it is used by it. + $provide.provider({ + $$sanitizeUri: $$SanitizeUriProvider + }); + $provide.provider('$compile', $CompileProvider). + directive({ + a: htmlAnchorDirective, + input: inputDirective, + textarea: inputDirective, + form: formDirective, + script: scriptDirective, + select: selectDirective, + style: styleDirective, + option: optionDirective, + ngBind: ngBindDirective, + ngBindHtml: ngBindHtmlDirective, + ngBindTemplate: ngBindTemplateDirective, + ngClass: ngClassDirective, + ngClassEven: ngClassEvenDirective, + ngClassOdd: ngClassOddDirective, + ngCloak: ngCloakDirective, + ngController: ngControllerDirective, + ngForm: ngFormDirective, + ngHide: ngHideDirective, + ngIf: ngIfDirective, + ngInclude: ngIncludeDirective, + ngInit: ngInitDirective, + ngNonBindable: ngNonBindableDirective, + ngPluralize: ngPluralizeDirective, + ngRepeat: ngRepeatDirective, + ngShow: ngShowDirective, + ngStyle: ngStyleDirective, + ngSwitch: ngSwitchDirective, + ngSwitchWhen: ngSwitchWhenDirective, + ngSwitchDefault: ngSwitchDefaultDirective, + ngOptions: ngOptionsDirective, + ngTransclude: ngTranscludeDirective, + ngModel: ngModelDirective, + ngList: ngListDirective, + ngChange: ngChangeDirective, + required: requiredDirective, + ngRequired: requiredDirective, + ngValue: ngValueDirective + }). + directive({ + ngInclude: ngIncludeFillContentDirective + }). + directive(ngAttributeAliasDirectives). + directive(ngEventDirectives); + $provide.provider({ + $anchorScroll: $AnchorScrollProvider, + $animate: $AnimateProvider, + $browser: $BrowserProvider, + $cacheFactory: $CacheFactoryProvider, + $controller: $ControllerProvider, + $document: $DocumentProvider, + $exceptionHandler: $ExceptionHandlerProvider, + $filter: $FilterProvider, + $interpolate: $InterpolateProvider, + $interval: $IntervalProvider, + $http: $HttpProvider, + $httpBackend: $HttpBackendProvider, + $location: $LocationProvider, + $log: $LogProvider, + $parse: $ParseProvider, + $rootScope: $RootScopeProvider, + $q: $QProvider, + $sce: $SceProvider, + $sceDelegate: $SceDelegateProvider, + $sniffer: $SnifferProvider, + $templateCache: $TemplateCacheProvider, + $timeout: $TimeoutProvider, + $window: $WindowProvider, + $$rAF: $$RAFProvider, + $$asyncCallback : $$AsyncCallbackProvider + }); + } + ]); +} + +/* global + + -JQLitePrototype, + -addEventListenerFn, + -removeEventListenerFn, + -BOOLEAN_ATTR +*/ + +////////////////////////////////// +//JQLite +////////////////////////////////// + +/** + * @ngdoc function + * @name angular.element + * @module ng + * @kind function + * + * @description + * Wraps a raw DOM element or HTML string as a [jQuery](http://jquery.com) element. + * + * If jQuery is available, `angular.element` is an alias for the + * [jQuery](http://api.jquery.com/jQuery/) function. If jQuery is not available, `angular.element` + * delegates to Angular's built-in subset of jQuery, called "jQuery lite" or "jqLite." + * + *
jqLite is a tiny, API-compatible subset of jQuery that allows + * Angular to manipulate the DOM in a cross-browser compatible way. **jqLite** implements only the most + * commonly needed functionality with the goal of having a very small footprint.
+ * + * To use jQuery, simply load it before `DOMContentLoaded` event fired. + * + *
**Note:** all element references in Angular are always wrapped with jQuery or + * jqLite; they are never raw DOM references.
+ * + * ## Angular's jqLite + * jqLite provides only the following jQuery methods: + * + * - [`addClass()`](http://api.jquery.com/addClass/) + * - [`after()`](http://api.jquery.com/after/) + * - [`append()`](http://api.jquery.com/append/) + * - [`attr()`](http://api.jquery.com/attr/) + * - [`bind()`](http://api.jquery.com/bind/) - Does not support namespaces, selectors or eventData + * - [`children()`](http://api.jquery.com/children/) - Does not support selectors + * - [`clone()`](http://api.jquery.com/clone/) + * - [`contents()`](http://api.jquery.com/contents/) + * - [`css()`](http://api.jquery.com/css/) + * - [`data()`](http://api.jquery.com/data/) + * - [`empty()`](http://api.jquery.com/empty/) + * - [`eq()`](http://api.jquery.com/eq/) + * - [`find()`](http://api.jquery.com/find/) - Limited to lookups by tag name + * - [`hasClass()`](http://api.jquery.com/hasClass/) + * - [`html()`](http://api.jquery.com/html/) + * - [`next()`](http://api.jquery.com/next/) - Does not support selectors + * - [`on()`](http://api.jquery.com/on/) - Does not support namespaces, selectors or eventData + * - [`off()`](http://api.jquery.com/off/) - Does not support namespaces or selectors + * - [`one()`](http://api.jquery.com/one/) - Does not support namespaces or selectors + * - [`parent()`](http://api.jquery.com/parent/) - Does not support selectors + * - [`prepend()`](http://api.jquery.com/prepend/) + * - [`prop()`](http://api.jquery.com/prop/) + * - [`ready()`](http://api.jquery.com/ready/) + * - [`remove()`](http://api.jquery.com/remove/) + * - [`removeAttr()`](http://api.jquery.com/removeAttr/) + * - [`removeClass()`](http://api.jquery.com/removeClass/) + * - [`removeData()`](http://api.jquery.com/removeData/) + * - [`replaceWith()`](http://api.jquery.com/replaceWith/) + * - [`text()`](http://api.jquery.com/text/) + * - [`toggleClass()`](http://api.jquery.com/toggleClass/) + * - [`triggerHandler()`](http://api.jquery.com/triggerHandler/) - Passes a dummy event object to handlers. + * - [`unbind()`](http://api.jquery.com/unbind/) - Does not support namespaces + * - [`val()`](http://api.jquery.com/val/) + * - [`wrap()`](http://api.jquery.com/wrap/) + * + * ## jQuery/jqLite Extras + * Angular also provides the following additional methods and events to both jQuery and jqLite: + * + * ### Events + * - `$destroy` - AngularJS intercepts all jqLite/jQuery's DOM destruction apis and fires this event + * on all DOM nodes being removed. This can be used to clean up any 3rd party bindings to the DOM + * element before it is removed. + * + * ### Methods + * - `controller(name)` - retrieves the controller of the current element or its parent. By default + * retrieves controller associated with the `ngController` directive. If `name` is provided as + * camelCase directive name, then the controller for this directive will be retrieved (e.g. + * `'ngModel'`). + * - `injector()` - retrieves the injector of the current element or its parent. + * - `scope()` - retrieves the {@link ng.$rootScope.Scope scope} of the current + * element or its parent. + * - `isolateScope()` - retrieves an isolate {@link ng.$rootScope.Scope scope} if one is attached directly to the + * current element. This getter should be used only on elements that contain a directive which starts a new isolate + * scope. Calling `scope()` on this element always returns the original non-isolate scope. + * - `inheritedData()` - same as `data()`, but walks up the DOM until a value is found or the top + * parent element is reached. + * + * @param {string|DOMElement} element HTML string or DOMElement to be wrapped into jQuery. + * @returns {Object} jQuery object. + */ + +JQLite.expando = 'ng339'; + +var jqCache = JQLite.cache = {}, + jqId = 1, + addEventListenerFn = (window.document.addEventListener + ? function(element, type, fn) {element.addEventListener(type, fn, false);} + : function(element, type, fn) {element.attachEvent('on' + type, fn);}), + removeEventListenerFn = (window.document.removeEventListener + ? function(element, type, fn) {element.removeEventListener(type, fn, false); } + : function(element, type, fn) {element.detachEvent('on' + type, fn); }); + +/* + * !!! This is an undocumented "private" function !!! + */ +var jqData = JQLite._data = function(node) { + //jQuery always returns an object on cache miss + return this.cache[node[this.expando]] || {}; +}; + +function jqNextId() { return ++jqId; } + + +var SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g; +var MOZ_HACK_REGEXP = /^moz([A-Z])/; +var jqLiteMinErr = minErr('jqLite'); + +/** + * Converts snake_case to camelCase. + * Also there is special case for Moz prefix starting with upper case letter. + * @param name Name to normalize + */ +function camelCase(name) { + return name. + replace(SPECIAL_CHARS_REGEXP, function(_, separator, letter, offset) { + return offset ? letter.toUpperCase() : letter; + }). + replace(MOZ_HACK_REGEXP, 'Moz$1'); +} + +///////////////////////////////////////////// +// jQuery mutation patch +// +// In conjunction with bindJQuery intercepts all jQuery's DOM destruction apis and fires a +// $destroy event on all DOM nodes being removed. +// +///////////////////////////////////////////// + +function jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) { + var originalJqFn = jQuery.fn[name]; + originalJqFn = originalJqFn.$original || originalJqFn; + removePatch.$original = originalJqFn; + jQuery.fn[name] = removePatch; + + function removePatch(param) { + // jshint -W040 + var list = filterElems && param ? [this.filter(param)] : [this], + fireEvent = dispatchThis, + set, setIndex, setLength, + element, childIndex, childLength, children; + + if (!getterIfNoArguments || param != null) { + while(list.length) { + set = list.shift(); + for(setIndex = 0, setLength = set.length; setIndex < setLength; setIndex++) { + element = jqLite(set[setIndex]); + if (fireEvent) { + element.triggerHandler('$destroy'); + } else { + fireEvent = !fireEvent; + } + for(childIndex = 0, childLength = (children = element.children()).length; + childIndex < childLength; + childIndex++) { + list.push(jQuery(children[childIndex])); + } + } + } + } + return originalJqFn.apply(this, arguments); + } +} + +var SINGLE_TAG_REGEXP = /^<(\w+)\s*\/?>(?:<\/\1>|)$/; +var HTML_REGEXP = /<|&#?\w+;/; +var TAG_NAME_REGEXP = /<([\w:]+)/; +var XHTML_TAG_REGEXP = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi; + +var wrapMap = { + 'option': [1, ''], + + 'thead': [1, '', '
'], + 'col': [2, '', '
'], + 'tr': [2, '', '
'], + 'td': [3, '', '
'], + '_default': [0, "", ""] +}; + +wrapMap.optgroup = wrapMap.option; +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + +function jqLiteIsTextNode(html) { + return !HTML_REGEXP.test(html); +} + +function jqLiteBuildFragment(html, context) { + var elem, tmp, tag, wrap, + fragment = context.createDocumentFragment(), + nodes = [], i, j, jj; + + if (jqLiteIsTextNode(html)) { + // Convert non-html into a text node + nodes.push(context.createTextNode(html)); + } else { + tmp = fragment.appendChild(context.createElement('div')); + // Convert html into DOM nodes + tag = (TAG_NAME_REGEXP.exec(html) || ["", ""])[1].toLowerCase(); + wrap = wrapMap[tag] || wrapMap._default; + tmp.innerHTML = '
 
' + + wrap[1] + html.replace(XHTML_TAG_REGEXP, "<$1>") + wrap[2]; + tmp.removeChild(tmp.firstChild); + + // Descend through wrappers to the right content + i = wrap[0]; + while (i--) { + tmp = tmp.lastChild; + } + + for (j=0, jj=tmp.childNodes.length; j -1); +} + +function jqLiteRemoveClass(element, cssClasses) { + if (cssClasses && element.setAttribute) { + forEach(cssClasses.split(' '), function(cssClass) { + element.setAttribute('class', trim( + (" " + (element.getAttribute('class') || '') + " ") + .replace(/[\n\t]/g, " ") + .replace(" " + trim(cssClass) + " ", " ")) + ); + }); + } +} + +function jqLiteAddClass(element, cssClasses) { + if (cssClasses && element.setAttribute) { + var existingClasses = (' ' + (element.getAttribute('class') || '') + ' ') + .replace(/[\n\t]/g, " "); + + forEach(cssClasses.split(' '), function(cssClass) { + cssClass = trim(cssClass); + if (existingClasses.indexOf(' ' + cssClass + ' ') === -1) { + existingClasses += cssClass + ' '; + } + }); + + element.setAttribute('class', trim(existingClasses)); + } +} + +function jqLiteAddNodes(root, elements) { + if (elements) { + elements = (!elements.nodeName && isDefined(elements.length) && !isWindow(elements)) + ? elements + : [ elements ]; + for(var i=0; i < elements.length; i++) { + root.push(elements[i]); + } + } +} + +function jqLiteController(element, name) { + return jqLiteInheritedData(element, '$' + (name || 'ngController' ) + 'Controller'); +} + +function jqLiteInheritedData(element, name, value) { + element = jqLite(element); + + // if element is the document object work with the html element instead + // this makes $(document).scope() possible + if(element[0].nodeType == 9) { + element = element.find('html'); + } + var names = isArray(name) ? name : [name]; + + while (element.length) { + var node = element[0]; + for (var i = 0, ii = names.length; i < ii; i++) { + if ((value = element.data(names[i])) !== undefined) return value; + } + + // If dealing with a document fragment node with a host element, and no parent, use the host + // element as the parent. This enables directives within a Shadow DOM or polyfilled Shadow DOM + // to lookup parent controllers. + element = jqLite(node.parentNode || (node.nodeType === 11 && node.host)); + } +} + +function jqLiteEmpty(element) { + for (var i = 0, childNodes = element.childNodes; i < childNodes.length; i++) { + jqLiteDealoc(childNodes[i]); + } + while (element.firstChild) { + element.removeChild(element.firstChild); + } +} + +////////////////////////////////////////// +// Functions which are declared directly. +////////////////////////////////////////// +var JQLitePrototype = JQLite.prototype = { + ready: function(fn) { + var fired = false; + + function trigger() { + if (fired) return; + fired = true; + fn(); + } + + // check if document already is loaded + if (document.readyState === 'complete'){ + setTimeout(trigger); + } else { + this.on('DOMContentLoaded', trigger); // works for modern browsers and IE9 + // we can not use jqLite since we are not done loading and jQuery could be loaded later. + // jshint -W064 + JQLite(window).on('load', trigger); // fallback to window.onload for others + // jshint +W064 + } + }, + toString: function() { + var value = []; + forEach(this, function(e){ value.push('' + e);}); + return '[' + value.join(', ') + ']'; + }, + + eq: function(index) { + return (index >= 0) ? jqLite(this[index]) : jqLite(this[this.length + index]); + }, + + length: 0, + push: push, + sort: [].sort, + splice: [].splice +}; + +////////////////////////////////////////// +// Functions iterating getter/setters. +// these functions return self on setter and +// value on get. +////////////////////////////////////////// +var BOOLEAN_ATTR = {}; +forEach('multiple,selected,checked,disabled,readOnly,required,open'.split(','), function(value) { + BOOLEAN_ATTR[lowercase(value)] = value; +}); +var BOOLEAN_ELEMENTS = {}; +forEach('input,select,option,textarea,button,form,details'.split(','), function(value) { + BOOLEAN_ELEMENTS[uppercase(value)] = true; +}); + +function getBooleanAttrName(element, name) { + // check dom last since we will most likely fail on name + var booleanAttr = BOOLEAN_ATTR[name.toLowerCase()]; + + // booleanAttr is here twice to minimize DOM access + return booleanAttr && BOOLEAN_ELEMENTS[element.nodeName] && booleanAttr; +} + +forEach({ + data: jqLiteData, + inheritedData: jqLiteInheritedData, + + scope: function(element) { + // Can't use jqLiteData here directly so we stay compatible with jQuery! + return jqLite(element).data('$scope') || jqLiteInheritedData(element.parentNode || element, ['$isolateScope', '$scope']); + }, + + isolateScope: function(element) { + // Can't use jqLiteData here directly so we stay compatible with jQuery! + return jqLite(element).data('$isolateScope') || jqLite(element).data('$isolateScopeNoTemplate'); + }, + + controller: jqLiteController, + + injector: function(element) { + return jqLiteInheritedData(element, '$injector'); + }, + + removeAttr: function(element,name) { + element.removeAttribute(name); + }, + + hasClass: jqLiteHasClass, + + css: function(element, name, value) { + name = camelCase(name); + + if (isDefined(value)) { + element.style[name] = value; + } else { + var val; + + if (msie <= 8) { + // this is some IE specific weirdness that jQuery 1.6.4 does not sure why + val = element.currentStyle && element.currentStyle[name]; + if (val === '') val = 'auto'; + } + + val = val || element.style[name]; + + if (msie <= 8) { + // jquery weirdness :-/ + val = (val === '') ? undefined : val; + } + + return val; + } + }, + + attr: function(element, name, value){ + var lowercasedName = lowercase(name); + if (BOOLEAN_ATTR[lowercasedName]) { + if (isDefined(value)) { + if (!!value) { + element[name] = true; + element.setAttribute(name, lowercasedName); + } else { + element[name] = false; + element.removeAttribute(lowercasedName); + } + } else { + return (element[name] || + (element.attributes.getNamedItem(name)|| noop).specified) + ? lowercasedName + : undefined; + } + } else if (isDefined(value)) { + element.setAttribute(name, value); + } else if (element.getAttribute) { + // the extra argument "2" is to get the right thing for a.href in IE, see jQuery code + // some elements (e.g. Document) don't have get attribute, so return undefined + var ret = element.getAttribute(name, 2); + // normalize non-existing attributes to undefined (as jQuery) + return ret === null ? undefined : ret; + } + }, + + prop: function(element, name, value) { + if (isDefined(value)) { + element[name] = value; + } else { + return element[name]; + } + }, + + text: (function() { + var NODE_TYPE_TEXT_PROPERTY = []; + if (msie < 9) { + NODE_TYPE_TEXT_PROPERTY[1] = 'innerText'; /** Element **/ + NODE_TYPE_TEXT_PROPERTY[3] = 'nodeValue'; /** Text **/ + } else { + NODE_TYPE_TEXT_PROPERTY[1] = /** Element **/ + NODE_TYPE_TEXT_PROPERTY[3] = 'textContent'; /** Text **/ + } + getText.$dv = ''; + return getText; + + function getText(element, value) { + var textProp = NODE_TYPE_TEXT_PROPERTY[element.nodeType]; + if (isUndefined(value)) { + return textProp ? element[textProp] : ''; + } + element[textProp] = value; + } + })(), + + val: function(element, value) { + if (isUndefined(value)) { + if (nodeName_(element) === 'SELECT' && element.multiple) { + var result = []; + forEach(element.options, function (option) { + if (option.selected) { + result.push(option.value || option.text); + } + }); + return result.length === 0 ? null : result; + } + return element.value; + } + element.value = value; + }, + + html: function(element, value) { + if (isUndefined(value)) { + return element.innerHTML; + } + for (var i = 0, childNodes = element.childNodes; i < childNodes.length; i++) { + jqLiteDealoc(childNodes[i]); + } + element.innerHTML = value; + }, + + empty: jqLiteEmpty +}, function(fn, name){ + /** + * Properties: writes return selection, reads return first value + */ + JQLite.prototype[name] = function(arg1, arg2) { + var i, key; + var nodeCount = this.length; + + // jqLiteHasClass has only two arguments, but is a getter-only fn, so we need to special-case it + // in a way that survives minification. + // jqLiteEmpty takes no arguments but is a setter. + if (fn !== jqLiteEmpty && + (((fn.length == 2 && (fn !== jqLiteHasClass && fn !== jqLiteController)) ? arg1 : arg2) === undefined)) { + if (isObject(arg1)) { + + // we are a write, but the object properties are the key/values + for (i = 0; i < nodeCount; i++) { + if (fn === jqLiteData) { + // data() takes the whole object in jQuery + fn(this[i], arg1); + } else { + for (key in arg1) { + fn(this[i], key, arg1[key]); + } + } + } + // return self for chaining + return this; + } else { + // we are a read, so read the first child. + // TODO: do we still need this? + var value = fn.$dv; + // Only if we have $dv do we iterate over all, otherwise it is just the first element. + var jj = (value === undefined) ? Math.min(nodeCount, 1) : nodeCount; + for (var j = 0; j < jj; j++) { + var nodeValue = fn(this[j], arg1, arg2); + value = value ? value + nodeValue : nodeValue; + } + return value; + } + } else { + // we are a write, so apply to all children + for (i = 0; i < nodeCount; i++) { + fn(this[i], arg1, arg2); + } + // return self for chaining + return this; + } + }; +}); + +function createEventHandler(element, events) { + var eventHandler = function (event, type) { + if (!event.preventDefault) { + event.preventDefault = function() { + event.returnValue = false; //ie + }; + } + + if (!event.stopPropagation) { + event.stopPropagation = function() { + event.cancelBubble = true; //ie + }; + } + + if (!event.target) { + event.target = event.srcElement || document; + } + + if (isUndefined(event.defaultPrevented)) { + var prevent = event.preventDefault; + event.preventDefault = function() { + event.defaultPrevented = true; + prevent.call(event); + }; + event.defaultPrevented = false; + } + + event.isDefaultPrevented = function() { + return event.defaultPrevented || event.returnValue === false; + }; + + // Copy event handlers in case event handlers array is modified during execution. + var eventHandlersCopy = shallowCopy(events[type || event.type] || []); + + forEach(eventHandlersCopy, function(fn) { + fn.call(element, event); + }); + + // Remove monkey-patched methods (IE), + // as they would cause memory leaks in IE8. + if (msie <= 8) { + // IE7/8 does not allow to delete property on native object + event.preventDefault = null; + event.stopPropagation = null; + event.isDefaultPrevented = null; + } else { + // It shouldn't affect normal browsers (native methods are defined on prototype). + delete event.preventDefault; + delete event.stopPropagation; + delete event.isDefaultPrevented; + } + }; + eventHandler.elem = element; + return eventHandler; +} + +////////////////////////////////////////// +// Functions iterating traversal. +// These functions chain results into a single +// selector. +////////////////////////////////////////// +forEach({ + removeData: jqLiteRemoveData, + + dealoc: jqLiteDealoc, + + on: function onFn(element, type, fn, unsupported){ + if (isDefined(unsupported)) throw jqLiteMinErr('onargs', 'jqLite#on() does not support the `selector` or `eventData` parameters'); + + var events = jqLiteExpandoStore(element, 'events'), + handle = jqLiteExpandoStore(element, 'handle'); + + if (!events) jqLiteExpandoStore(element, 'events', events = {}); + if (!handle) jqLiteExpandoStore(element, 'handle', handle = createEventHandler(element, events)); + + forEach(type.split(' '), function(type){ + var eventFns = events[type]; + + if (!eventFns) { + if (type == 'mouseenter' || type == 'mouseleave') { + var contains = document.body.contains || document.body.compareDocumentPosition ? + function( a, b ) { + // jshint bitwise: false + var adown = a.nodeType === 9 ? a.documentElement : a, + bup = b && b.parentNode; + return a === bup || !!( bup && bup.nodeType === 1 && ( + adown.contains ? + adown.contains( bup ) : + a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 + )); + } : + function( a, b ) { + if ( b ) { + while ( (b = b.parentNode) ) { + if ( b === a ) { + return true; + } + } + } + return false; + }; + + events[type] = []; + + // Refer to jQuery's implementation of mouseenter & mouseleave + // Read about mouseenter and mouseleave: + // http://www.quirksmode.org/js/events_mouse.html#link8 + var eventmap = { mouseleave : "mouseout", mouseenter : "mouseover"}; + + onFn(element, eventmap[type], function(event) { + var target = this, related = event.relatedTarget; + // For mousenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || (related !== target && !contains(target, related)) ){ + handle(event, type); + } + }); + + } else { + addEventListenerFn(element, type, handle); + events[type] = []; + } + eventFns = events[type]; + } + eventFns.push(fn); + }); + }, + + off: jqLiteOff, + + one: function(element, type, fn) { + element = jqLite(element); + + //add the listener twice so that when it is called + //you can remove the original function and still be + //able to call element.off(ev, fn) normally + element.on(type, function onFn() { + element.off(type, fn); + element.off(type, onFn); + }); + element.on(type, fn); + }, + + replaceWith: function(element, replaceNode) { + var index, parent = element.parentNode; + jqLiteDealoc(element); + forEach(new JQLite(replaceNode), function(node){ + if (index) { + parent.insertBefore(node, index.nextSibling); + } else { + parent.replaceChild(node, element); + } + index = node; + }); + }, + + children: function(element) { + var children = []; + forEach(element.childNodes, function(element){ + if (element.nodeType === 1) + children.push(element); + }); + return children; + }, + + contents: function(element) { + return element.contentDocument || element.childNodes || []; + }, + + append: function(element, node) { + forEach(new JQLite(node), function(child){ + if (element.nodeType === 1 || element.nodeType === 11) { + element.appendChild(child); + } + }); + }, + + prepend: function(element, node) { + if (element.nodeType === 1) { + var index = element.firstChild; + forEach(new JQLite(node), function(child){ + element.insertBefore(child, index); + }); + } + }, + + wrap: function(element, wrapNode) { + wrapNode = jqLite(wrapNode)[0]; + var parent = element.parentNode; + if (parent) { + parent.replaceChild(wrapNode, element); + } + wrapNode.appendChild(element); + }, + + remove: function(element) { + jqLiteDealoc(element); + var parent = element.parentNode; + if (parent) parent.removeChild(element); + }, + + after: function(element, newElement) { + var index = element, parent = element.parentNode; + forEach(new JQLite(newElement), function(node){ + parent.insertBefore(node, index.nextSibling); + index = node; + }); + }, + + addClass: jqLiteAddClass, + removeClass: jqLiteRemoveClass, + + toggleClass: function(element, selector, condition) { + if (selector) { + forEach(selector.split(' '), function(className){ + var classCondition = condition; + if (isUndefined(classCondition)) { + classCondition = !jqLiteHasClass(element, className); + } + (classCondition ? jqLiteAddClass : jqLiteRemoveClass)(element, className); + }); + } + }, + + parent: function(element) { + var parent = element.parentNode; + return parent && parent.nodeType !== 11 ? parent : null; + }, + + next: function(element) { + if (element.nextElementSibling) { + return element.nextElementSibling; + } + + // IE8 doesn't have nextElementSibling + var elm = element.nextSibling; + while (elm != null && elm.nodeType !== 1) { + elm = elm.nextSibling; + } + return elm; + }, + + find: function(element, selector) { + if (element.getElementsByTagName) { + return element.getElementsByTagName(selector); + } else { + return []; + } + }, + + clone: jqLiteClone, + + triggerHandler: function(element, eventName, eventData) { + var eventFns = (jqLiteExpandoStore(element, 'events') || {})[eventName]; + + eventData = eventData || []; + + var event = [{ + preventDefault: noop, + stopPropagation: noop + }]; + + forEach(eventFns, function(fn) { + fn.apply(element, event.concat(eventData)); + }); + } +}, function(fn, name){ + /** + * chaining functions + */ + JQLite.prototype[name] = function(arg1, arg2, arg3) { + var value; + for(var i=0; i < this.length; i++) { + if (isUndefined(value)) { + value = fn(this[i], arg1, arg2, arg3); + if (isDefined(value)) { + // any function which returns a value needs to be wrapped + value = jqLite(value); + } + } else { + jqLiteAddNodes(value, fn(this[i], arg1, arg2, arg3)); + } + } + return isDefined(value) ? value : this; + }; + + // bind legacy bind/unbind to on/off + JQLite.prototype.bind = JQLite.prototype.on; + JQLite.prototype.unbind = JQLite.prototype.off; +}); + +/** + * Computes a hash of an 'obj'. + * Hash of a: + * string is string + * number is number as string + * object is either result of calling $$hashKey function on the object or uniquely generated id, + * that is also assigned to the $$hashKey property of the object. + * + * @param obj + * @returns {string} hash string such that the same input will have the same hash string. + * The resulting string key is in 'type:hashKey' format. + */ +function hashKey(obj, nextUidFn) { + var objType = typeof obj, + key; + + if (objType == 'function' || (objType == 'object' && obj !== null)) { + if (typeof (key = obj.$$hashKey) == 'function') { + // must invoke on object to keep the right this + key = obj.$$hashKey(); + } else if (key === undefined) { + key = obj.$$hashKey = (nextUidFn || nextUid)(); + } + } else { + key = obj; + } + + return objType + ':' + key; +} + +/** + * HashMap which can use objects as keys + */ +function HashMap(array, isolatedUid) { + if (isolatedUid) { + var uid = 0; + this.nextUid = function() { + return ++uid; + }; + } + forEach(array, this.put, this); +} +HashMap.prototype = { + /** + * Store key value pair + * @param key key to store can be any type + * @param value value to store can be any type + */ + put: function(key, value) { + this[hashKey(key, this.nextUid)] = value; + }, + + /** + * @param key + * @returns {Object} the value for the key + */ + get: function(key) { + return this[hashKey(key, this.nextUid)]; + }, + + /** + * Remove the key/value pair + * @param key + */ + remove: function(key) { + var value = this[key = hashKey(key, this.nextUid)]; + delete this[key]; + return value; + } +}; + +/** + * @ngdoc function + * @module ng + * @name angular.injector + * @kind function + * + * @description + * Creates an injector function that can be used for retrieving services as well as for + * dependency injection (see {@link guide/di dependency injection}). + * + + * @param {Array.} modules A list of module functions or their aliases. See + * {@link angular.module}. The `ng` module must be explicitly added. + * @returns {function()} Injector function. See {@link auto.$injector $injector}. + * + * @example + * Typical usage + * ```js + * // create an injector + * var $injector = angular.injector(['ng']); + * + * // use the injector to kick off your application + * // use the type inference to auto inject arguments, or use implicit injection + * $injector.invoke(function($rootScope, $compile, $document){ + * $compile($document)($rootScope); + * $rootScope.$digest(); + * }); + * ``` + * + * Sometimes you want to get access to the injector of a currently running Angular app + * from outside Angular. Perhaps, you want to inject and compile some markup after the + * application has been bootstrapped. You can do this using the extra `injector()` added + * to JQuery/jqLite elements. See {@link angular.element}. + * + * *This is fairly rare but could be the case if a third party library is injecting the + * markup.* + * + * In the following example a new block of HTML containing a `ng-controller` + * directive is added to the end of the document body by JQuery. We then compile and link + * it into the current AngularJS scope. + * + * ```js + * var $div = $('
{{content.label}}
'); + * $(document.body).append($div); + * + * angular.element(document).injector().invoke(function($compile) { + * var scope = angular.element($div).scope(); + * $compile($div)(scope); + * }); + * ``` + */ + + +/** + * @ngdoc module + * @name auto + * @description + * + * Implicit module which gets automatically added to each {@link auto.$injector $injector}. + */ + +var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; +var FN_ARG_SPLIT = /,/; +var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; +var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; +var $injectorMinErr = minErr('$injector'); +function annotate(fn) { + var $inject, + fnText, + argDecl, + last; + + if (typeof fn === 'function') { + if (!($inject = fn.$inject)) { + $inject = []; + if (fn.length) { + fnText = fn.toString().replace(STRIP_COMMENTS, ''); + argDecl = fnText.match(FN_ARGS); + forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){ + arg.replace(FN_ARG, function(all, underscore, name){ + $inject.push(name); + }); + }); + } + fn.$inject = $inject; + } + } else if (isArray(fn)) { + last = fn.length - 1; + assertArgFn(fn[last], 'fn'); + $inject = fn.slice(0, last); + } else { + assertArgFn(fn, 'fn', true); + } + return $inject; +} + +/////////////////////////////////////// + +/** + * @ngdoc service + * @name $injector + * @kind function + * + * @description + * + * `$injector` is used to retrieve object instances as defined by + * {@link auto.$provide provider}, instantiate types, invoke methods, + * and load modules. + * + * The following always holds true: + * + * ```js + * var $injector = angular.injector(); + * expect($injector.get('$injector')).toBe($injector); + * expect($injector.invoke(function($injector){ + * return $injector; + * }).toBe($injector); + * ``` + * + * # Injection Function Annotation + * + * JavaScript does not have annotations, and annotations are needed for dependency injection. The + * following are all valid ways of annotating function with injection arguments and are equivalent. + * + * ```js + * // inferred (only works if code not minified/obfuscated) + * $injector.invoke(function(serviceA){}); + * + * // annotated + * function explicit(serviceA) {}; + * explicit.$inject = ['serviceA']; + * $injector.invoke(explicit); + * + * // inline + * $injector.invoke(['serviceA', function(serviceA){}]); + * ``` + * + * ## Inference + * + * In JavaScript calling `toString()` on a function returns the function definition. The definition + * can then be parsed and the function arguments can be extracted. *NOTE:* This does not work with + * minification, and obfuscation tools since these tools change the argument names. + * + * ## `$inject` Annotation + * By adding an `$inject` property onto a function the injection parameters can be specified. + * + * ## Inline + * As an array of injection names, where the last item in the array is the function to call. + */ + +/** + * @ngdoc method + * @name $injector#get + * + * @description + * Return an instance of the service. + * + * @param {string} name The name of the instance to retrieve. + * @return {*} The instance. + */ + +/** + * @ngdoc method + * @name $injector#invoke + * + * @description + * Invoke the method and supply the method arguments from the `$injector`. + * + * @param {!Function} fn The function to invoke. Function parameters are injected according to the + * {@link guide/di $inject Annotation} rules. + * @param {Object=} self The `this` for the invoked method. + * @param {Object=} locals Optional object. If preset then any argument names are read from this + * object first, before the `$injector` is consulted. + * @returns {*} the value returned by the invoked `fn` function. + */ + +/** + * @ngdoc method + * @name $injector#has + * + * @description + * Allows the user to query if the particular service exists. + * + * @param {string} Name of the service to query. + * @returns {boolean} returns true if injector has given service. + */ + +/** + * @ngdoc method + * @name $injector#instantiate + * @description + * Create a new instance of JS type. The method takes a constructor function, invokes the new + * operator, and supplies all of the arguments to the constructor function as specified by the + * constructor annotation. + * + * @param {Function} Type Annotated constructor function. + * @param {Object=} locals Optional object. If preset then any argument names are read from this + * object first, before the `$injector` is consulted. + * @returns {Object} new instance of `Type`. + */ + +/** + * @ngdoc method + * @name $injector#annotate + * + * @description + * Returns an array of service names which the function is requesting for injection. This API is + * used by the injector to determine which services need to be injected into the function when the + * function is invoked. There are three ways in which the function can be annotated with the needed + * dependencies. + * + * # Argument names + * + * The simplest form is to extract the dependencies from the arguments of the function. This is done + * by converting the function into a string using `toString()` method and extracting the argument + * names. + * ```js + * // Given + * function MyController($scope, $route) { + * // ... + * } + * + * // Then + * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); + * ``` + * + * This method does not work with code minification / obfuscation. For this reason the following + * annotation strategies are supported. + * + * # The `$inject` property + * + * If a function has an `$inject` property and its value is an array of strings, then the strings + * represent names of services to be injected into the function. + * ```js + * // Given + * var MyController = function(obfuscatedScope, obfuscatedRoute) { + * // ... + * } + * // Define function dependencies + * MyController['$inject'] = ['$scope', '$route']; + * + * // Then + * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); + * ``` + * + * # The array notation + * + * It is often desirable to inline Injected functions and that's when setting the `$inject` property + * is very inconvenient. In these situations using the array notation to specify the dependencies in + * a way that survives minification is a better choice: + * + * ```js + * // We wish to write this (not minification / obfuscation safe) + * injector.invoke(function($compile, $rootScope) { + * // ... + * }); + * + * // We are forced to write break inlining + * var tmpFn = function(obfuscatedCompile, obfuscatedRootScope) { + * // ... + * }; + * tmpFn.$inject = ['$compile', '$rootScope']; + * injector.invoke(tmpFn); + * + * // To better support inline function the inline annotation is supported + * injector.invoke(['$compile', '$rootScope', function(obfCompile, obfRootScope) { + * // ... + * }]); + * + * // Therefore + * expect(injector.annotate( + * ['$compile', '$rootScope', function(obfus_$compile, obfus_$rootScope) {}]) + * ).toEqual(['$compile', '$rootScope']); + * ``` + * + * @param {Function|Array.} fn Function for which dependent service names need to + * be retrieved as described above. + * + * @returns {Array.} The names of the services which the function requires. + */ + + + + +/** + * @ngdoc service + * @name $provide + * + * @description + * + * The {@link auto.$provide $provide} service has a number of methods for registering components + * with the {@link auto.$injector $injector}. Many of these functions are also exposed on + * {@link angular.Module}. + * + * An Angular **service** is a singleton object created by a **service factory**. These **service + * factories** are functions which, in turn, are created by a **service provider**. + * The **service providers** are constructor functions. When instantiated they must contain a + * property called `$get`, which holds the **service factory** function. + * + * When you request a service, the {@link auto.$injector $injector} is responsible for finding the + * correct **service provider**, instantiating it and then calling its `$get` **service factory** + * function to get the instance of the **service**. + * + * Often services have no configuration options and there is no need to add methods to the service + * provider. The provider will be no more than a constructor function with a `$get` property. For + * these cases the {@link auto.$provide $provide} service has additional helper methods to register + * services without specifying a provider. + * + * * {@link auto.$provide#provider provider(provider)} - registers a **service provider** with the + * {@link auto.$injector $injector} + * * {@link auto.$provide#constant constant(obj)} - registers a value/object that can be accessed by + * providers and services. + * * {@link auto.$provide#value value(obj)} - registers a value/object that can only be accessed by + * services, not providers. + * * {@link auto.$provide#factory factory(fn)} - registers a service **factory function**, `fn`, + * that will be wrapped in a **service provider** object, whose `$get` property will contain the + * given factory function. + * * {@link auto.$provide#service service(class)} - registers a **constructor function**, `class` + * that will be wrapped in a **service provider** object, whose `$get` property will instantiate + * a new object using the given constructor function. + * + * See the individual methods for more information and examples. + */ + +/** + * @ngdoc method + * @name $provide#provider + * @description + * + * Register a **provider function** with the {@link auto.$injector $injector}. Provider functions + * are constructor functions, whose instances are responsible for "providing" a factory for a + * service. + * + * Service provider names start with the name of the service they provide followed by `Provider`. + * For example, the {@link ng.$log $log} service has a provider called + * {@link ng.$logProvider $logProvider}. + * + * Service provider objects can have additional methods which allow configuration of the provider + * and its service. Importantly, you can configure what kind of service is created by the `$get` + * method, or how that service will act. For example, the {@link ng.$logProvider $logProvider} has a + * method {@link ng.$logProvider#debugEnabled debugEnabled} + * which lets you specify whether the {@link ng.$log $log} service will log debug messages to the + * console or not. + * + * @param {string} name The name of the instance. NOTE: the provider will be available under `name + + 'Provider'` key. + * @param {(Object|function())} provider If the provider is: + * + * - `Object`: then it should have a `$get` method. The `$get` method will be invoked using + * {@link auto.$injector#invoke $injector.invoke()} when an instance needs to be created. + * - `Constructor`: a new instance of the provider will be created using + * {@link auto.$injector#instantiate $injector.instantiate()}, then treated as `object`. + * + * @returns {Object} registered provider instance + + * @example + * + * The following example shows how to create a simple event tracking service and register it using + * {@link auto.$provide#provider $provide.provider()}. + * + * ```js + * // Define the eventTracker provider + * function EventTrackerProvider() { + * var trackingUrl = '/track'; + * + * // A provider method for configuring where the tracked events should been saved + * this.setTrackingUrl = function(url) { + * trackingUrl = url; + * }; + * + * // The service factory function + * this.$get = ['$http', function($http) { + * var trackedEvents = {}; + * return { + * // Call this to track an event + * event: function(event) { + * var count = trackedEvents[event] || 0; + * count += 1; + * trackedEvents[event] = count; + * return count; + * }, + * // Call this to save the tracked events to the trackingUrl + * save: function() { + * $http.post(trackingUrl, trackedEvents); + * } + * }; + * }]; + * } + * + * describe('eventTracker', function() { + * var postSpy; + * + * beforeEach(module(function($provide) { + * // Register the eventTracker provider + * $provide.provider('eventTracker', EventTrackerProvider); + * })); + * + * beforeEach(module(function(eventTrackerProvider) { + * // Configure eventTracker provider + * eventTrackerProvider.setTrackingUrl('/custom-track'); + * })); + * + * it('tracks events', inject(function(eventTracker) { + * expect(eventTracker.event('login')).toEqual(1); + * expect(eventTracker.event('login')).toEqual(2); + * })); + * + * it('saves to the tracking url', inject(function(eventTracker, $http) { + * postSpy = spyOn($http, 'post'); + * eventTracker.event('login'); + * eventTracker.save(); + * expect(postSpy).toHaveBeenCalled(); + * expect(postSpy.mostRecentCall.args[0]).not.toEqual('/track'); + * expect(postSpy.mostRecentCall.args[0]).toEqual('/custom-track'); + * expect(postSpy.mostRecentCall.args[1]).toEqual({ 'login': 1 }); + * })); + * }); + * ``` + */ + +/** + * @ngdoc method + * @name $provide#factory + * @description + * + * Register a **service factory**, which will be called to return the service instance. + * This is short for registering a service where its provider consists of only a `$get` property, + * which is the given service factory function. + * You should use {@link auto.$provide#factory $provide.factory(getFn)} if you do not need to + * configure your service in a provider. + * + * @param {string} name The name of the instance. + * @param {function()} $getFn The $getFn for the instance creation. Internally this is a short hand + * for `$provide.provider(name, {$get: $getFn})`. + * @returns {Object} registered provider instance + * + * @example + * Here is an example of registering a service + * ```js + * $provide.factory('ping', ['$http', function($http) { + * return function ping() { + * return $http.send('/ping'); + * }; + * }]); + * ``` + * You would then inject and use this service like this: + * ```js + * someModule.controller('Ctrl', ['ping', function(ping) { + * ping(); + * }]); + * ``` + */ + + +/** + * @ngdoc method + * @name $provide#service + * @description + * + * Register a **service constructor**, which will be invoked with `new` to create the service + * instance. + * This is short for registering a service where its provider's `$get` property is the service + * constructor function that will be used to instantiate the service instance. + * + * You should use {@link auto.$provide#service $provide.service(class)} if you define your service + * as a type/class. + * + * @param {string} name The name of the instance. + * @param {Function} constructor A class (constructor function) that will be instantiated. + * @returns {Object} registered provider instance + * + * @example + * Here is an example of registering a service using + * {@link auto.$provide#service $provide.service(class)}. + * ```js + * var Ping = function($http) { + * this.$http = $http; + * }; + * + * Ping.$inject = ['$http']; + * + * Ping.prototype.send = function() { + * return this.$http.get('/ping'); + * }; + * $provide.service('ping', Ping); + * ``` + * You would then inject and use this service like this: + * ```js + * someModule.controller('Ctrl', ['ping', function(ping) { + * ping.send(); + * }]); + * ``` + */ + + +/** + * @ngdoc method + * @name $provide#value + * @description + * + * Register a **value service** with the {@link auto.$injector $injector}, such as a string, a + * number, an array, an object or a function. This is short for registering a service where its + * provider's `$get` property is a factory function that takes no arguments and returns the **value + * service**. + * + * Value services are similar to constant services, except that they cannot be injected into a + * module configuration function (see {@link angular.Module#config}) but they can be overridden by + * an Angular + * {@link auto.$provide#decorator decorator}. + * + * @param {string} name The name of the instance. + * @param {*} value The value. + * @returns {Object} registered provider instance + * + * @example + * Here are some examples of creating value services. + * ```js + * $provide.value('ADMIN_USER', 'admin'); + * + * $provide.value('RoleLookup', { admin: 0, writer: 1, reader: 2 }); + * + * $provide.value('halfOf', function(value) { + * return value / 2; + * }); + * ``` + */ + + +/** + * @ngdoc method + * @name $provide#constant + * @description + * + * Register a **constant service**, such as a string, a number, an array, an object or a function, + * with the {@link auto.$injector $injector}. Unlike {@link auto.$provide#value value} it can be + * injected into a module configuration function (see {@link angular.Module#config}) and it cannot + * be overridden by an Angular {@link auto.$provide#decorator decorator}. + * + * @param {string} name The name of the constant. + * @param {*} value The constant value. + * @returns {Object} registered instance + * + * @example + * Here a some examples of creating constants: + * ```js + * $provide.constant('SHARD_HEIGHT', 306); + * + * $provide.constant('MY_COLOURS', ['red', 'blue', 'grey']); + * + * $provide.constant('double', function(value) { + * return value * 2; + * }); + * ``` + */ + + +/** + * @ngdoc method + * @name $provide#decorator + * @description + * + * Register a **service decorator** with the {@link auto.$injector $injector}. A service decorator + * intercepts the creation of a service, allowing it to override or modify the behaviour of the + * service. The object returned by the decorator may be the original service, or a new service + * object which replaces or wraps and delegates to the original service. + * + * @param {string} name The name of the service to decorate. + * @param {function()} decorator This function will be invoked when the service needs to be + * instantiated and should return the decorated service instance. The function is called using + * the {@link auto.$injector#invoke injector.invoke} method and is therefore fully injectable. + * Local injection arguments: + * + * * `$delegate` - The original service instance, which can be monkey patched, configured, + * decorated or delegated to. + * + * @example + * Here we decorate the {@link ng.$log $log} service to convert warnings to errors by intercepting + * calls to {@link ng.$log#error $log.warn()}. + * ```js + * $provide.decorator('$log', ['$delegate', function($delegate) { + * $delegate.warn = $delegate.error; + * return $delegate; + * }]); + * ``` + */ + + +function createInjector(modulesToLoad) { + var INSTANTIATING = {}, + providerSuffix = 'Provider', + path = [], + loadedModules = new HashMap([], true), + providerCache = { + $provide: { + provider: supportObject(provider), + factory: supportObject(factory), + service: supportObject(service), + value: supportObject(value), + constant: supportObject(constant), + decorator: decorator + } + }, + providerInjector = (providerCache.$injector = + createInternalInjector(providerCache, function() { + throw $injectorMinErr('unpr', "Unknown provider: {0}", path.join(' <- ')); + })), + instanceCache = {}, + instanceInjector = (instanceCache.$injector = + createInternalInjector(instanceCache, function(servicename) { + var provider = providerInjector.get(servicename + providerSuffix); + return instanceInjector.invoke(provider.$get, provider); + })); + + + forEach(loadModules(modulesToLoad), function(fn) { instanceInjector.invoke(fn || noop); }); + + return instanceInjector; + + //////////////////////////////////// + // $provider + //////////////////////////////////// + + function supportObject(delegate) { + return function(key, value) { + if (isObject(key)) { + forEach(key, reverseParams(delegate)); + } else { + return delegate(key, value); + } + }; + } + + function provider(name, provider_) { + assertNotHasOwnProperty(name, 'service'); + if (isFunction(provider_) || isArray(provider_)) { + provider_ = providerInjector.instantiate(provider_); + } + if (!provider_.$get) { + throw $injectorMinErr('pget', "Provider '{0}' must define $get factory method.", name); + } + return providerCache[name + providerSuffix] = provider_; + } + + function factory(name, factoryFn) { return provider(name, { $get: factoryFn }); } + + function service(name, constructor) { + return factory(name, ['$injector', function($injector) { + return $injector.instantiate(constructor); + }]); + } + + function value(name, val) { return factory(name, valueFn(val)); } + + function constant(name, value) { + assertNotHasOwnProperty(name, 'constant'); + providerCache[name] = value; + instanceCache[name] = value; + } + + function decorator(serviceName, decorFn) { + var origProvider = providerInjector.get(serviceName + providerSuffix), + orig$get = origProvider.$get; + + origProvider.$get = function() { + var origInstance = instanceInjector.invoke(orig$get, origProvider); + return instanceInjector.invoke(decorFn, null, {$delegate: origInstance}); + }; + } + + //////////////////////////////////// + // Module Loading + //////////////////////////////////// + function loadModules(modulesToLoad){ + var runBlocks = [], moduleFn, invokeQueue, i, ii; + forEach(modulesToLoad, function(module) { + if (loadedModules.get(module)) return; + loadedModules.put(module, true); + + try { + if (isString(module)) { + moduleFn = angularModule(module); + runBlocks = runBlocks.concat(loadModules(moduleFn.requires)).concat(moduleFn._runBlocks); + + for(invokeQueue = moduleFn._invokeQueue, i = 0, ii = invokeQueue.length; i < ii; i++) { + var invokeArgs = invokeQueue[i], + provider = providerInjector.get(invokeArgs[0]); + + provider[invokeArgs[1]].apply(provider, invokeArgs[2]); + } + } else if (isFunction(module)) { + runBlocks.push(providerInjector.invoke(module)); + } else if (isArray(module)) { + runBlocks.push(providerInjector.invoke(module)); + } else { + assertArgFn(module, 'module'); + } + } catch (e) { + if (isArray(module)) { + module = module[module.length - 1]; + } + if (e.message && e.stack && e.stack.indexOf(e.message) == -1) { + // Safari & FF's stack traces don't contain error.message content + // unlike those of Chrome and IE + // So if stack doesn't contain message, we create a new string that contains both. + // Since error.stack is read-only in Safari, I'm overriding e and not e.stack here. + /* jshint -W022 */ + e = e.message + '\n' + e.stack; + } + throw $injectorMinErr('modulerr', "Failed to instantiate module {0} due to:\n{1}", + module, e.stack || e.message || e); + } + }); + return runBlocks; + } + + //////////////////////////////////// + // internal Injector + //////////////////////////////////// + + function createInternalInjector(cache, factory) { + + function getService(serviceName) { + if (cache.hasOwnProperty(serviceName)) { + if (cache[serviceName] === INSTANTIATING) { + throw $injectorMinErr('cdep', 'Circular dependency found: {0}', + serviceName + ' <- ' + path.join(' <- ')); + } + return cache[serviceName]; + } else { + try { + path.unshift(serviceName); + cache[serviceName] = INSTANTIATING; + return cache[serviceName] = factory(serviceName); + } catch (err) { + if (cache[serviceName] === INSTANTIATING) { + delete cache[serviceName]; + } + throw err; + } finally { + path.shift(); + } + } + } + + function invoke(fn, self, locals){ + var args = [], + $inject = annotate(fn), + length, i, + key; + + for(i = 0, length = $inject.length; i < length; i++) { + key = $inject[i]; + if (typeof key !== 'string') { + throw $injectorMinErr('itkn', + 'Incorrect injection token! Expected service name as string, got {0}', key); + } + args.push( + locals && locals.hasOwnProperty(key) + ? locals[key] + : getService(key) + ); + } + if (isArray(fn)) { + fn = fn[length]; + } + + // http://jsperf.com/angularjs-invoke-apply-vs-switch + // #5388 + return fn.apply(self, args); + } + + function instantiate(Type, locals) { + var Constructor = function() {}, + instance, returnedValue; + + // Check if Type is annotated and use just the given function at n-1 as parameter + // e.g. someModule.factory('greeter', ['$window', function(renamed$window) {}]); + Constructor.prototype = (isArray(Type) ? Type[Type.length - 1] : Type).prototype; + instance = new Constructor(); + returnedValue = invoke(Type, instance, locals); + + return isObject(returnedValue) || isFunction(returnedValue) ? returnedValue : instance; + } + + return { + invoke: invoke, + instantiate: instantiate, + get: getService, + annotate: annotate, + has: function(name) { + return providerCache.hasOwnProperty(name + providerSuffix) || cache.hasOwnProperty(name); + } + }; + } +} + +/** + * @ngdoc service + * @name $anchorScroll + * @kind function + * @requires $window + * @requires $location + * @requires $rootScope + * + * @description + * When called, it checks current value of `$location.hash()` and scrolls to the related element, + * according to rules specified in + * [Html5 spec](http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document). + * + * It also watches the `$location.hash()` and scrolls whenever it changes to match any anchor. + * This can be disabled by calling `$anchorScrollProvider.disableAutoScrolling()`. + * + * @example + + +
+ Go to bottom + You're at the bottom! +
+
+ + function ScrollCtrl($scope, $location, $anchorScroll) { + $scope.gotoBottom = function (){ + // set the location.hash to the id of + // the element you wish to scroll to. + $location.hash('bottom'); + + // call $anchorScroll() + $anchorScroll(); + }; + } + + + #scrollArea { + height: 350px; + overflow: auto; + } + + #bottom { + display: block; + margin-top: 2000px; + } + +
+ */ +function $AnchorScrollProvider() { + + var autoScrollingEnabled = true; + + this.disableAutoScrolling = function() { + autoScrollingEnabled = false; + }; + + this.$get = ['$window', '$location', '$rootScope', function($window, $location, $rootScope) { + var document = $window.document; + + // helper function to get first anchor from a NodeList + // can't use filter.filter, as it accepts only instances of Array + // and IE can't convert NodeList to an array using [].slice + // TODO(vojta): use filter if we change it to accept lists as well + function getFirstAnchor(list) { + var result = null; + forEach(list, function(element) { + if (!result && lowercase(element.nodeName) === 'a') result = element; + }); + return result; + } + + function scroll() { + var hash = $location.hash(), elm; + + // empty hash, scroll to the top of the page + if (!hash) $window.scrollTo(0, 0); + + // element with given id + else if ((elm = document.getElementById(hash))) elm.scrollIntoView(); + + // first anchor with given name :-D + else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) elm.scrollIntoView(); + + // no element and hash == 'top', scroll to the top of the page + else if (hash === 'top') $window.scrollTo(0, 0); + } + + // does not scroll when user clicks on anchor link that is currently on + // (no url change, no $location.hash() change), browser native does scroll + if (autoScrollingEnabled) { + $rootScope.$watch(function autoScrollWatch() {return $location.hash();}, + function autoScrollWatchAction() { + $rootScope.$evalAsync(scroll); + }); + } + + return scroll; + }]; +} + +var $animateMinErr = minErr('$animate'); + +/** + * @ngdoc provider + * @name $animateProvider + * + * @description + * Default implementation of $animate that doesn't perform any animations, instead just + * synchronously performs DOM + * updates and calls done() callbacks. + * + * In order to enable animations the ngAnimate module has to be loaded. + * + * To see the functional implementation check out src/ngAnimate/animate.js + */ +var $AnimateProvider = ['$provide', function($provide) { + + + this.$$selectors = {}; + + + /** + * @ngdoc method + * @name $animateProvider#register + * + * @description + * Registers a new injectable animation factory function. The factory function produces the + * animation object which contains callback functions for each event that is expected to be + * animated. + * + * * `eventFn`: `function(Element, doneFunction)` The element to animate, the `doneFunction` + * must be called once the element animation is complete. If a function is returned then the + * animation service will use this function to cancel the animation whenever a cancel event is + * triggered. + * + * + * ```js + * return { + * eventFn : function(element, done) { + * //code to run the animation + * //once complete, then run done() + * return function cancellationFunction() { + * //code to cancel the animation + * } + * } + * } + * ``` + * + * @param {string} name The name of the animation. + * @param {Function} factory The factory function that will be executed to return the animation + * object. + */ + this.register = function(name, factory) { + var key = name + '-animation'; + if (name && name.charAt(0) != '.') throw $animateMinErr('notcsel', + "Expecting class selector starting with '.' got '{0}'.", name); + this.$$selectors[name.substr(1)] = key; + $provide.factory(key, factory); + }; + + /** + * @ngdoc method + * @name $animateProvider#classNameFilter + * + * @description + * Sets and/or returns the CSS class regular expression that is checked when performing + * an animation. Upon bootstrap the classNameFilter value is not set at all and will + * therefore enable $animate to attempt to perform an animation on any element. + * When setting the classNameFilter value, animations will only be performed on elements + * that successfully match the filter expression. This in turn can boost performance + * for low-powered devices as well as applications containing a lot of structural operations. + * @param {RegExp=} expression The className expression which will be checked against all animations + * @return {RegExp} The current CSS className expression value. If null then there is no expression value + */ + this.classNameFilter = function(expression) { + if(arguments.length === 1) { + this.$$classNameFilter = (expression instanceof RegExp) ? expression : null; + } + return this.$$classNameFilter; + }; + + this.$get = ['$timeout', '$$asyncCallback', function($timeout, $$asyncCallback) { + + function async(fn) { + fn && $$asyncCallback(fn); + } + + /** + * + * @ngdoc service + * @name $animate + * @description The $animate service provides rudimentary DOM manipulation functions to + * insert, remove and move elements within the DOM, as well as adding and removing classes. + * This service is the core service used by the ngAnimate $animator service which provides + * high-level animation hooks for CSS and JavaScript. + * + * $animate is available in the AngularJS core, however, the ngAnimate module must be included + * to enable full out animation support. Otherwise, $animate will only perform simple DOM + * manipulation operations. + * + * To learn more about enabling animation support, click here to visit the {@link ngAnimate + * ngAnimate module page} as well as the {@link ngAnimate.$animate ngAnimate $animate service + * page}. + */ + return { + + /** + * + * @ngdoc method + * @name $animate#enter + * @kind function + * @description Inserts the element into the DOM either after the `after` element or within + * the `parent` element. Once complete, the done() callback will be fired (if provided). + * @param {DOMElement} element the element which will be inserted into the DOM + * @param {DOMElement} parent the parent element which will append the element as + * a child (if the after element is not present) + * @param {DOMElement} after the sibling element which will append the element + * after itself + * @param {Function=} done callback function that will be called after the element has been + * inserted into the DOM + */ + enter : function(element, parent, after, done) { + if (after) { + after.after(element); + } else { + if (!parent || !parent[0]) { + parent = after.parent(); + } + parent.append(element); + } + async(done); + }, + + /** + * + * @ngdoc method + * @name $animate#leave + * @kind function + * @description Removes the element from the DOM. Once complete, the done() callback will be + * fired (if provided). + * @param {DOMElement} element the element which will be removed from the DOM + * @param {Function=} done callback function that will be called after the element has been + * removed from the DOM + */ + leave : function(element, done) { + element.remove(); + async(done); + }, + + /** + * + * @ngdoc method + * @name $animate#move + * @kind function + * @description Moves the position of the provided element within the DOM to be placed + * either after the `after` element or inside of the `parent` element. Once complete, the + * done() callback will be fired (if provided). + * + * @param {DOMElement} element the element which will be moved around within the + * DOM + * @param {DOMElement} parent the parent element where the element will be + * inserted into (if the after element is not present) + * @param {DOMElement} after the sibling element where the element will be + * positioned next to + * @param {Function=} done the callback function (if provided) that will be fired after the + * element has been moved to its new position + */ + move : function(element, parent, after, done) { + // Do not remove element before insert. Removing will cause data associated with the + // element to be dropped. Insert will implicitly do the remove. + this.enter(element, parent, after, done); + }, + + /** + * + * @ngdoc method + * @name $animate#addClass + * @kind function + * @description Adds the provided className CSS class value to the provided element. Once + * complete, the done() callback will be fired (if provided). + * @param {DOMElement} element the element which will have the className value + * added to it + * @param {string} className the CSS class which will be added to the element + * @param {Function=} done the callback function (if provided) that will be fired after the + * className value has been added to the element + */ + addClass : function(element, className, done) { + className = isString(className) ? + className : + isArray(className) ? className.join(' ') : ''; + forEach(element, function (element) { + jqLiteAddClass(element, className); + }); + async(done); + }, + + /** + * + * @ngdoc method + * @name $animate#removeClass + * @kind function + * @description Removes the provided className CSS class value from the provided element. + * Once complete, the done() callback will be fired (if provided). + * @param {DOMElement} element the element which will have the className value + * removed from it + * @param {string} className the CSS class which will be removed from the element + * @param {Function=} done the callback function (if provided) that will be fired after the + * className value has been removed from the element + */ + removeClass : function(element, className, done) { + className = isString(className) ? + className : + isArray(className) ? className.join(' ') : ''; + forEach(element, function (element) { + jqLiteRemoveClass(element, className); + }); + async(done); + }, + + /** + * + * @ngdoc method + * @name $animate#setClass + * @kind function + * @description Adds and/or removes the given CSS classes to and from the element. + * Once complete, the done() callback will be fired (if provided). + * @param {DOMElement} element the element which will have its CSS classes changed + * removed from it + * @param {string} add the CSS classes which will be added to the element + * @param {string} remove the CSS class which will be removed from the element + * @param {Function=} done the callback function (if provided) that will be fired after the + * CSS classes have been set on the element + */ + setClass : function(element, add, remove, done) { + forEach(element, function (element) { + jqLiteAddClass(element, add); + jqLiteRemoveClass(element, remove); + }); + async(done); + }, + + enabled : noop + }; + }]; +}]; + +function $$AsyncCallbackProvider(){ + this.$get = ['$$rAF', '$timeout', function($$rAF, $timeout) { + return $$rAF.supported + ? function(fn) { return $$rAF(fn); } + : function(fn) { + return $timeout(fn, 0, false); + }; + }]; +} + +/** + * ! This is a private undocumented service ! + * + * @name $browser + * @requires $log + * @description + * This object has two goals: + * + * - hide all the global state in the browser caused by the window object + * - abstract away all the browser specific features and inconsistencies + * + * For tests we provide {@link ngMock.$browser mock implementation} of the `$browser` + * service, which can be used for convenient testing of the application without the interaction with + * the real browser apis. + */ +/** + * @param {object} window The global window object. + * @param {object} document jQuery wrapped document. + * @param {function()} XHR XMLHttpRequest constructor. + * @param {object} $log console.log or an object with the same interface. + * @param {object} $sniffer $sniffer service + */ +function Browser(window, document, $log, $sniffer) { + var self = this, + rawDocument = document[0], + location = window.location, + history = window.history, + setTimeout = window.setTimeout, + clearTimeout = window.clearTimeout, + pendingDeferIds = {}; + + self.isMock = false; + + var outstandingRequestCount = 0; + var outstandingRequestCallbacks = []; + + // TODO(vojta): remove this temporary api + self.$$completeOutstandingRequest = completeOutstandingRequest; + self.$$incOutstandingRequestCount = function() { outstandingRequestCount++; }; + + /** + * Executes the `fn` function(supports currying) and decrements the `outstandingRequestCallbacks` + * counter. If the counter reaches 0, all the `outstandingRequestCallbacks` are executed. + */ + function completeOutstandingRequest(fn) { + try { + fn.apply(null, sliceArgs(arguments, 1)); + } finally { + outstandingRequestCount--; + if (outstandingRequestCount === 0) { + while(outstandingRequestCallbacks.length) { + try { + outstandingRequestCallbacks.pop()(); + } catch (e) { + $log.error(e); + } + } + } + } + } + + /** + * @private + * Note: this method is used only by scenario runner + * TODO(vojta): prefix this method with $$ ? + * @param {function()} callback Function that will be called when no outstanding request + */ + self.notifyWhenNoOutstandingRequests = function(callback) { + // force browser to execute all pollFns - this is needed so that cookies and other pollers fire + // at some deterministic time in respect to the test runner's actions. Leaving things up to the + // regular poller would result in flaky tests. + forEach(pollFns, function(pollFn){ pollFn(); }); + + if (outstandingRequestCount === 0) { + callback(); + } else { + outstandingRequestCallbacks.push(callback); + } + }; + + ////////////////////////////////////////////////////////////// + // Poll Watcher API + ////////////////////////////////////////////////////////////// + var pollFns = [], + pollTimeout; + + /** + * @name $browser#addPollFn + * + * @param {function()} fn Poll function to add + * + * @description + * Adds a function to the list of functions that poller periodically executes, + * and starts polling if not started yet. + * + * @returns {function()} the added function + */ + self.addPollFn = function(fn) { + if (isUndefined(pollTimeout)) startPoller(100, setTimeout); + pollFns.push(fn); + return fn; + }; + + /** + * @param {number} interval How often should browser call poll functions (ms) + * @param {function()} setTimeout Reference to a real or fake `setTimeout` function. + * + * @description + * Configures the poller to run in the specified intervals, using the specified + * setTimeout fn and kicks it off. + */ + function startPoller(interval, setTimeout) { + (function check() { + forEach(pollFns, function(pollFn){ pollFn(); }); + pollTimeout = setTimeout(check, interval); + })(); + } + + ////////////////////////////////////////////////////////////// + // URL API + ////////////////////////////////////////////////////////////// + + var lastBrowserUrl = location.href, + baseElement = document.find('base'), + newLocation = null; + + /** + * @name $browser#url + * + * @description + * GETTER: + * Without any argument, this method just returns current value of location.href. + * + * SETTER: + * With at least one argument, this method sets url to new value. + * If html5 history api supported, pushState/replaceState is used, otherwise + * location.href/location.replace is used. + * Returns its own instance to allow chaining + * + * NOTE: this api is intended for use only by the $location service. Please use the + * {@link ng.$location $location service} to change url. + * + * @param {string} url New url (when used as setter) + * @param {boolean=} replace Should new url replace current history record ? + */ + self.url = function(url, replace) { + // Android Browser BFCache causes location, history reference to become stale. + if (location !== window.location) location = window.location; + if (history !== window.history) history = window.history; + + // setter + if (url) { + if (lastBrowserUrl == url) return; + lastBrowserUrl = url; + if ($sniffer.history) { + if (replace) history.replaceState(null, '', url); + else { + history.pushState(null, '', url); + // Crazy Opera Bug: http://my.opera.com/community/forums/topic.dml?id=1185462 + baseElement.attr('href', baseElement.attr('href')); + } + } else { + newLocation = url; + if (replace) { + location.replace(url); + } else { + location.href = url; + } + } + return self; + // getter + } else { + // - newLocation is a workaround for an IE7-9 issue with location.replace and location.href + // methods not updating location.href synchronously. + // - the replacement is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=407172 + return newLocation || location.href.replace(/%27/g,"'"); + } + }; + + var urlChangeListeners = [], + urlChangeInit = false; + + function fireUrlChange() { + newLocation = null; + if (lastBrowserUrl == self.url()) return; + + lastBrowserUrl = self.url(); + forEach(urlChangeListeners, function(listener) { + listener(self.url()); + }); + } + + /** + * @name $browser#onUrlChange + * + * @description + * Register callback function that will be called, when url changes. + * + * It's only called when the url is changed from outside of angular: + * - user types different url into address bar + * - user clicks on history (forward/back) button + * - user clicks on a link + * + * It's not called when url is changed by $browser.url() method + * + * The listener gets called with new url as parameter. + * + * NOTE: this api is intended for use only by the $location service. Please use the + * {@link ng.$location $location service} to monitor url changes in angular apps. + * + * @param {function(string)} listener Listener function to be called when url changes. + * @return {function(string)} Returns the registered listener fn - handy if the fn is anonymous. + */ + self.onUrlChange = function(callback) { + // TODO(vojta): refactor to use node's syntax for events + if (!urlChangeInit) { + // We listen on both (hashchange/popstate) when available, as some browsers (e.g. Opera) + // don't fire popstate when user change the address bar and don't fire hashchange when url + // changed by push/replaceState + + // html5 history api - popstate event + if ($sniffer.history) jqLite(window).on('popstate', fireUrlChange); + // hashchange event + if ($sniffer.hashchange) jqLite(window).on('hashchange', fireUrlChange); + // polling + else self.addPollFn(fireUrlChange); + + urlChangeInit = true; + } + + urlChangeListeners.push(callback); + return callback; + }; + + ////////////////////////////////////////////////////////////// + // Misc API + ////////////////////////////////////////////////////////////// + + /** + * @name $browser#baseHref + * + * @description + * Returns current + * (always relative - without domain) + * + * @returns {string} The current base href + */ + self.baseHref = function() { + var href = baseElement.attr('href'); + return href ? href.replace(/^(https?\:)?\/\/[^\/]*/, '') : ''; + }; + + ////////////////////////////////////////////////////////////// + // Cookies API + ////////////////////////////////////////////////////////////// + var lastCookies = {}; + var lastCookieString = ''; + var cookiePath = self.baseHref(); + + /** + * @name $browser#cookies + * + * @param {string=} name Cookie name + * @param {string=} value Cookie value + * + * @description + * The cookies method provides a 'private' low level access to browser cookies. + * It is not meant to be used directly, use the $cookie service instead. + * + * The return values vary depending on the arguments that the method was called with as follows: + * + * - cookies() -> hash of all cookies, this is NOT a copy of the internal state, so do not modify + * it + * - cookies(name, value) -> set name to value, if value is undefined delete the cookie + * - cookies(name) -> the same as (name, undefined) == DELETES (no one calls it right now that + * way) + * + * @returns {Object} Hash of all cookies (if called without any parameter) + */ + self.cookies = function(name, value) { + /* global escape: false, unescape: false */ + var cookieLength, cookieArray, cookie, i, index; + + if (name) { + if (value === undefined) { + rawDocument.cookie = escape(name) + "=;path=" + cookiePath + + ";expires=Thu, 01 Jan 1970 00:00:00 GMT"; + } else { + if (isString(value)) { + cookieLength = (rawDocument.cookie = escape(name) + '=' + escape(value) + + ';path=' + cookiePath).length + 1; + + // per http://www.ietf.org/rfc/rfc2109.txt browser must allow at minimum: + // - 300 cookies + // - 20 cookies per unique domain + // - 4096 bytes per cookie + if (cookieLength > 4096) { + $log.warn("Cookie '"+ name + + "' possibly not set or overflowed because it was too large ("+ + cookieLength + " > 4096 bytes)!"); + } + } + } + } else { + if (rawDocument.cookie !== lastCookieString) { + lastCookieString = rawDocument.cookie; + cookieArray = lastCookieString.split("; "); + lastCookies = {}; + + for (i = 0; i < cookieArray.length; i++) { + cookie = cookieArray[i]; + index = cookie.indexOf('='); + if (index > 0) { //ignore nameless cookies + name = unescape(cookie.substring(0, index)); + // the first value that is seen for a cookie is the most + // specific one. values for the same cookie name that + // follow are for less specific paths. + if (lastCookies[name] === undefined) { + lastCookies[name] = unescape(cookie.substring(index + 1)); + } + } + } + } + return lastCookies; + } + }; + + + /** + * @name $browser#defer + * @param {function()} fn A function, who's execution should be deferred. + * @param {number=} [delay=0] of milliseconds to defer the function execution. + * @returns {*} DeferId that can be used to cancel the task via `$browser.defer.cancel()`. + * + * @description + * Executes a fn asynchronously via `setTimeout(fn, delay)`. + * + * Unlike when calling `setTimeout` directly, in test this function is mocked and instead of using + * `setTimeout` in tests, the fns are queued in an array, which can be programmatically flushed + * via `$browser.defer.flush()`. + * + */ + self.defer = function(fn, delay) { + var timeoutId; + outstandingRequestCount++; + timeoutId = setTimeout(function() { + delete pendingDeferIds[timeoutId]; + completeOutstandingRequest(fn); + }, delay || 0); + pendingDeferIds[timeoutId] = true; + return timeoutId; + }; + + + /** + * @name $browser#defer.cancel + * + * @description + * Cancels a deferred task identified with `deferId`. + * + * @param {*} deferId Token returned by the `$browser.defer` function. + * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfully + * canceled. + */ + self.defer.cancel = function(deferId) { + if (pendingDeferIds[deferId]) { + delete pendingDeferIds[deferId]; + clearTimeout(deferId); + completeOutstandingRequest(noop); + return true; + } + return false; + }; + +} + +function $BrowserProvider(){ + this.$get = ['$window', '$log', '$sniffer', '$document', + function( $window, $log, $sniffer, $document){ + return new Browser($window, $document, $log, $sniffer); + }]; +} + +/** + * @ngdoc service + * @name $cacheFactory + * + * @description + * Factory that constructs {@link $cacheFactory.Cache Cache} objects and gives access to + * them. + * + * ```js + * + * var cache = $cacheFactory('cacheId'); + * expect($cacheFactory.get('cacheId')).toBe(cache); + * expect($cacheFactory.get('noSuchCacheId')).not.toBeDefined(); + * + * cache.put("key", "value"); + * cache.put("another key", "another value"); + * + * // We've specified no options on creation + * expect(cache.info()).toEqual({id: 'cacheId', size: 2}); + * + * ``` + * + * + * @param {string} cacheId Name or id of the newly created cache. + * @param {object=} options Options object that specifies the cache behavior. Properties: + * + * - `{number=}` `capacity` — turns the cache into LRU cache. + * + * @returns {object} Newly created cache object with the following set of methods: + * + * - `{object}` `info()` — Returns id, size, and options of cache. + * - `{{*}}` `put({string} key, {*} value)` — Puts a new key-value pair into the cache and returns + * it. + * - `{{*}}` `get({string} key)` — Returns cached value for `key` or undefined for cache miss. + * - `{void}` `remove({string} key)` — Removes a key-value pair from the cache. + * - `{void}` `removeAll()` — Removes all cached values. + * - `{void}` `destroy()` — Removes references to this cache from $cacheFactory. + * + * @example + + +
+ + + + +

Cached Values

+
+ + : + +
+ +

Cache Info

+
+ + : + +
+
+
+ + angular.module('cacheExampleApp', []). + controller('CacheController', ['$scope', '$cacheFactory', function($scope, $cacheFactory) { + $scope.keys = []; + $scope.cache = $cacheFactory('cacheId'); + $scope.put = function(key, value) { + $scope.cache.put(key, value); + $scope.keys.push(key); + }; + }]); + + + p { + margin: 10px 0 3px; + } + +
+ */ +function $CacheFactoryProvider() { + + this.$get = function() { + var caches = {}; + + function cacheFactory(cacheId, options) { + if (cacheId in caches) { + throw minErr('$cacheFactory')('iid', "CacheId '{0}' is already taken!", cacheId); + } + + var size = 0, + stats = extend({}, options, {id: cacheId}), + data = {}, + capacity = (options && options.capacity) || Number.MAX_VALUE, + lruHash = {}, + freshEnd = null, + staleEnd = null; + + /** + * @ngdoc type + * @name $cacheFactory.Cache + * + * @description + * A cache object used to store and retrieve data, primarily used by + * {@link $http $http} and the {@link ng.directive:script script} directive to cache + * templates and other data. + * + * ```js + * angular.module('superCache') + * .factory('superCache', ['$cacheFactory', function($cacheFactory) { + * return $cacheFactory('super-cache'); + * }]); + * ``` + * + * Example test: + * + * ```js + * it('should behave like a cache', inject(function(superCache) { + * superCache.put('key', 'value'); + * superCache.put('another key', 'another value'); + * + * expect(superCache.info()).toEqual({ + * id: 'super-cache', + * size: 2 + * }); + * + * superCache.remove('another key'); + * expect(superCache.get('another key')).toBeUndefined(); + * + * superCache.removeAll(); + * expect(superCache.info()).toEqual({ + * id: 'super-cache', + * size: 0 + * }); + * })); + * ``` + */ + return caches[cacheId] = { + + /** + * @ngdoc method + * @name $cacheFactory.Cache#put + * @kind function + * + * @description + * Inserts a named entry into the {@link $cacheFactory.Cache Cache} object to be + * retrieved later, and incrementing the size of the cache if the key was not already + * present in the cache. If behaving like an LRU cache, it will also remove stale + * entries from the set. + * + * It will not insert undefined values into the cache. + * + * @param {string} key the key under which the cached data is stored. + * @param {*} value the value to store alongside the key. If it is undefined, the key + * will not be stored. + * @returns {*} the value stored. + */ + put: function(key, value) { + if (capacity < Number.MAX_VALUE) { + var lruEntry = lruHash[key] || (lruHash[key] = {key: key}); + + refresh(lruEntry); + } + + if (isUndefined(value)) return; + if (!(key in data)) size++; + data[key] = value; + + if (size > capacity) { + this.remove(staleEnd.key); + } + + return value; + }, + + /** + * @ngdoc method + * @name $cacheFactory.Cache#get + * @kind function + * + * @description + * Retrieves named data stored in the {@link $cacheFactory.Cache Cache} object. + * + * @param {string} key the key of the data to be retrieved + * @returns {*} the value stored. + */ + get: function(key) { + if (capacity < Number.MAX_VALUE) { + var lruEntry = lruHash[key]; + + if (!lruEntry) return; + + refresh(lruEntry); + } + + return data[key]; + }, + + + /** + * @ngdoc method + * @name $cacheFactory.Cache#remove + * @kind function + * + * @description + * Removes an entry from the {@link $cacheFactory.Cache Cache} object. + * + * @param {string} key the key of the entry to be removed + */ + remove: function(key) { + if (capacity < Number.MAX_VALUE) { + var lruEntry = lruHash[key]; + + if (!lruEntry) return; + + if (lruEntry == freshEnd) freshEnd = lruEntry.p; + if (lruEntry == staleEnd) staleEnd = lruEntry.n; + link(lruEntry.n,lruEntry.p); + + delete lruHash[key]; + } + + delete data[key]; + size--; + }, + + + /** + * @ngdoc method + * @name $cacheFactory.Cache#removeAll + * @kind function + * + * @description + * Clears the cache object of any entries. + */ + removeAll: function() { + data = {}; + size = 0; + lruHash = {}; + freshEnd = staleEnd = null; + }, + + + /** + * @ngdoc method + * @name $cacheFactory.Cache#destroy + * @kind function + * + * @description + * Destroys the {@link $cacheFactory.Cache Cache} object entirely, + * removing it from the {@link $cacheFactory $cacheFactory} set. + */ + destroy: function() { + data = null; + stats = null; + lruHash = null; + delete caches[cacheId]; + }, + + + /** + * @ngdoc method + * @name $cacheFactory.Cache#info + * @kind function + * + * @description + * Retrieve information regarding a particular {@link $cacheFactory.Cache Cache}. + * + * @returns {object} an object with the following properties: + *
    + *
  • **id**: the id of the cache instance
  • + *
  • **size**: the number of entries kept in the cache instance
  • + *
  • **...**: any additional properties from the options object when creating the + * cache.
  • + *
+ */ + info: function() { + return extend({}, stats, {size: size}); + } + }; + + + /** + * makes the `entry` the freshEnd of the LRU linked list + */ + function refresh(entry) { + if (entry != freshEnd) { + if (!staleEnd) { + staleEnd = entry; + } else if (staleEnd == entry) { + staleEnd = entry.n; + } + + link(entry.n, entry.p); + link(entry, freshEnd); + freshEnd = entry; + freshEnd.n = null; + } + } + + + /** + * bidirectionally links two entries of the LRU linked list + */ + function link(nextEntry, prevEntry) { + if (nextEntry != prevEntry) { + if (nextEntry) nextEntry.p = prevEntry; //p stands for previous, 'prev' didn't minify + if (prevEntry) prevEntry.n = nextEntry; //n stands for next, 'next' didn't minify + } + } + } + + + /** + * @ngdoc method + * @name $cacheFactory#info + * + * @description + * Get information about all the caches that have been created + * + * @returns {Object} - key-value map of `cacheId` to the result of calling `cache#info` + */ + cacheFactory.info = function() { + var info = {}; + forEach(caches, function(cache, cacheId) { + info[cacheId] = cache.info(); + }); + return info; + }; + + + /** + * @ngdoc method + * @name $cacheFactory#get + * + * @description + * Get access to a cache object by the `cacheId` used when it was created. + * + * @param {string} cacheId Name or id of a cache to access. + * @returns {object} Cache object identified by the cacheId or undefined if no such cache. + */ + cacheFactory.get = function(cacheId) { + return caches[cacheId]; + }; + + + return cacheFactory; + }; +} + +/** + * @ngdoc service + * @name $templateCache + * + * @description + * The first time a template is used, it is loaded in the template cache for quick retrieval. You + * can load templates directly into the cache in a `script` tag, or by consuming the + * `$templateCache` service directly. + * + * Adding via the `script` tag: + * + * ```html + * + * ``` + * + * **Note:** the `script` tag containing the template does not need to be included in the `head` of + * the document, but it must be below the `ng-app` definition. + * + * Adding via the $templateCache service: + * + * ```js + * var myApp = angular.module('myApp', []); + * myApp.run(function($templateCache) { + * $templateCache.put('templateId.html', 'This is the content of the template'); + * }); + * ``` + * + * To retrieve the template later, simply use it in your HTML: + * ```html + *
+ * ``` + * + * or get it via Javascript: + * ```js + * $templateCache.get('templateId.html') + * ``` + * + * See {@link ng.$cacheFactory $cacheFactory}. + * + */ +function $TemplateCacheProvider() { + this.$get = ['$cacheFactory', function($cacheFactory) { + return $cacheFactory('templates'); + }]; +} + +/* ! VARIABLE/FUNCTION NAMING CONVENTIONS THAT APPLY TO THIS FILE! + * + * DOM-related variables: + * + * - "node" - DOM Node + * - "element" - DOM Element or Node + * - "$node" or "$element" - jqLite-wrapped node or element + * + * + * Compiler related stuff: + * + * - "linkFn" - linking fn of a single directive + * - "nodeLinkFn" - function that aggregates all linking fns for a particular node + * - "childLinkFn" - function that aggregates all linking fns for child nodes of a particular node + * - "compositeLinkFn" - function that aggregates all linking fns for a compilation root (nodeList) + */ + + +/** + * @ngdoc service + * @name $compile + * @kind function + * + * @description + * Compiles an HTML string or DOM into a template and produces a template function, which + * can then be used to link {@link ng.$rootScope.Scope `scope`} and the template together. + * + * The compilation is a process of walking the DOM tree and matching DOM elements to + * {@link ng.$compileProvider#directive directives}. + * + *
+ * **Note:** This document is an in-depth reference of all directive options. + * For a gentle introduction to directives with examples of common use cases, + * see the {@link guide/directive directive guide}. + *
+ * + * ## Comprehensive Directive API + * + * There are many different options for a directive. + * + * The difference resides in the return value of the factory function. + * You can either return a "Directive Definition Object" (see below) that defines the directive properties, + * or just the `postLink` function (all other properties will have the default values). + * + *
+ * **Best Practice:** It's recommended to use the "directive definition object" form. + *
+ * + * Here's an example directive declared with a Directive Definition Object: + * + * ```js + * var myModule = angular.module(...); + * + * myModule.directive('directiveName', function factory(injectables) { + * var directiveDefinitionObject = { + * priority: 0, + * template: '
', // or // function(tElement, tAttrs) { ... }, + * // or + * // templateUrl: 'directive.html', // or // function(tElement, tAttrs) { ... }, + * transclude: false, + * restrict: 'A', + * scope: false, + * controller: function($scope, $element, $attrs, $transclude, otherInjectables) { ... }, + * controllerAs: 'stringAlias', + * require: 'siblingDirectiveName', // or // ['^parentDirectiveName', '?optionalDirectiveName', '?^optionalParent'], + * compile: function compile(tElement, tAttrs, transclude) { + * return { + * pre: function preLink(scope, iElement, iAttrs, controller) { ... }, + * post: function postLink(scope, iElement, iAttrs, controller) { ... } + * } + * // or + * // return function postLink( ... ) { ... } + * }, + * // or + * // link: { + * // pre: function preLink(scope, iElement, iAttrs, controller) { ... }, + * // post: function postLink(scope, iElement, iAttrs, controller) { ... } + * // } + * // or + * // link: function postLink( ... ) { ... } + * }; + * return directiveDefinitionObject; + * }); + * ``` + * + *
+ * **Note:** Any unspecified options will use the default value. You can see the default values below. + *
+ * + * Therefore the above can be simplified as: + * + * ```js + * var myModule = angular.module(...); + * + * myModule.directive('directiveName', function factory(injectables) { + * var directiveDefinitionObject = { + * link: function postLink(scope, iElement, iAttrs) { ... } + * }; + * return directiveDefinitionObject; + * // or + * // return function postLink(scope, iElement, iAttrs) { ... } + * }); + * ``` + * + * + * + * ### Directive Definition Object + * + * The directive definition object provides instructions to the {@link ng.$compile + * compiler}. The attributes are: + * + * #### `priority` + * When there are multiple directives defined on a single DOM element, sometimes it + * is necessary to specify the order in which the directives are applied. The `priority` is used + * to sort the directives before their `compile` functions get called. Priority is defined as a + * number. Directives with greater numerical `priority` are compiled first. Pre-link functions + * are also run in priority order, but post-link functions are run in reverse order. The order + * of directives with the same priority is undefined. The default priority is `0`. + * + * #### `terminal` + * If set to true then the current `priority` will be the last set of directives + * which will execute (any directives at the current priority will still execute + * as the order of execution on same `priority` is undefined). + * + * #### `scope` + * **If set to `true`,** then a new scope will be created for this directive. If multiple directives on the + * same element request a new scope, only one new scope is created. The new scope rule does not + * apply for the root of the template since the root of the template always gets a new scope. + * + * **If set to `{}` (object hash),** then a new "isolate" scope is created. The 'isolate' scope differs from + * normal scope in that it does not prototypically inherit from the parent scope. This is useful + * when creating reusable components, which should not accidentally read or modify data in the + * parent scope. + * + * The 'isolate' scope takes an object hash which defines a set of local scope properties + * derived from the parent scope. These local properties are useful for aliasing values for + * templates. Locals definition is a hash of local scope property to its source: + * + * * `@` or `@attr` - bind a local scope property to the value of DOM attribute. The result is + * always a string since DOM attributes are strings. If no `attr` name is specified then the + * attribute name is assumed to be the same as the local name. + * Given `` and widget definition + * of `scope: { localName:'@myAttr' }`, then widget scope property `localName` will reflect + * the interpolated value of `hello {{name}}`. As the `name` attribute changes so will the + * `localName` property on the widget scope. The `name` is read from the parent scope (not + * component scope). + * + * * `=` or `=attr` - set up bi-directional binding between a local scope property and the + * parent scope property of name defined via the value of the `attr` attribute. If no `attr` + * name is specified then the attribute name is assumed to be the same as the local name. + * Given `` and widget definition of + * `scope: { localModel:'=myAttr' }`, then widget scope property `localModel` will reflect the + * value of `parentModel` on the parent scope. Any changes to `parentModel` will be reflected + * in `localModel` and any changes in `localModel` will reflect in `parentModel`. If the parent + * scope property doesn't exist, it will throw a NON_ASSIGNABLE_MODEL_EXPRESSION exception. You + * can avoid this behavior using `=?` or `=?attr` in order to flag the property as optional. + * + * * `&` or `&attr` - provides a way to execute an expression in the context of the parent scope. + * If no `attr` name is specified then the attribute name is assumed to be the same as the + * local name. Given `` and widget definition of + * `scope: { localFn:'&myAttr' }`, then isolate scope property `localFn` will point to + * a function wrapper for the `count = count + value` expression. Often it's desirable to + * pass data from the isolated scope via an expression to the parent scope, this can be + * done by passing a map of local variable names and values into the expression wrapper fn. + * For example, if the expression is `increment(amount)` then we can specify the amount value + * by calling the `localFn` as `localFn({amount: 22})`. + * + * + * + * #### `controller` + * Controller constructor function. The controller is instantiated before the + * pre-linking phase and it is shared with other directives (see + * `require` attribute). This allows the directives to communicate with each other and augment + * each other's behavior. The controller is injectable (and supports bracket notation) with the following locals: + * + * * `$scope` - Current scope associated with the element + * * `$element` - Current element + * * `$attrs` - Current attributes object for the element + * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope. + * The scope can be overridden by an optional first argument. + * `function([scope], cloneLinkingFn)`. + * + * + * #### `require` + * Require another directive and inject its controller as the fourth argument to the linking function. The + * `require` takes a string name (or array of strings) of the directive(s) to pass in. If an array is used, the + * injected argument will be an array in corresponding order. If no such directive can be + * found, or if the directive does not have a controller, then an error is raised. The name can be prefixed with: + * + * * (no prefix) - Locate the required controller on the current element. Throw an error if not found. + * * `?` - Attempt to locate the required controller or pass `null` to the `link` fn if not found. + * * `^` - Locate the required controller by searching the element's parents. Throw an error if not found. + * * `?^` - Attempt to locate the required controller by searching the element's parents or pass `null` to the + * `link` fn if not found. + * + * + * #### `controllerAs` + * Controller alias at the directive scope. An alias for the controller so it + * can be referenced at the directive template. The directive needs to define a scope for this + * configuration to be used. Useful in the case when directive is used as component. + * + * + * #### `restrict` + * String of subset of `EACM` which restricts the directive to a specific directive + * declaration style. If omitted, the default (attributes only) is used. + * + * * `E` - Element name: `` + * * `A` - Attribute (default): `
` + * * `C` - Class: `
` + * * `M` - Comment: `` + * + * + * #### `template` + * replace the current element with the contents of the HTML. The replacement process + * migrates all of the attributes / classes from the old element to the new one. See the + * {@link guide/directive#creating-custom-directives_creating-directives_template-expanding-directive + * Directives Guide} for an example. + * + * You can specify `template` as a string representing the template or as a function which takes + * two arguments `tElement` and `tAttrs` (described in the `compile` function api below) and + * returns a string value representing the template. + * + * + * #### `templateUrl` + * Same as `template` but the template is loaded from the specified URL. Because + * the template loading is asynchronous the compilation/linking is suspended until the template + * is loaded. + * + * You can specify `templateUrl` as a string representing the URL or as a function which takes two + * arguments `tElement` and `tAttrs` (described in the `compile` function api below) and returns + * a string value representing the url. In either case, the template URL is passed through {@link + * api/ng.$sce#getTrustedResourceUrl $sce.getTrustedResourceUrl}. + * + * + * #### `replace` ([*DEPRECATED*!], will be removed in next major release) + * specify where the template should be inserted. Defaults to `false`. + * + * * `true` - the template will replace the current element. + * * `false` - the template will replace the contents of the current element. + * + * + * #### `transclude` + * compile the content of the element and make it available to the directive. + * Typically used with {@link ng.directive:ngTransclude + * ngTransclude}. The advantage of transclusion is that the linking function receives a + * transclusion function which is pre-bound to the correct scope. In a typical setup the widget + * creates an `isolate` scope, but the transclusion is not a child, but a sibling of the `isolate` + * scope. This makes it possible for the widget to have private state, and the transclusion to + * be bound to the parent (pre-`isolate`) scope. + * + * * `true` - transclude the content of the directive. + * * `'element'` - transclude the whole element including any directives defined at lower priority. + * + * + * #### `compile` + * + * ```js + * function compile(tElement, tAttrs, transclude) { ... } + * ``` + * + * The compile function deals with transforming the template DOM. Since most directives do not do + * template transformation, it is not used often. The compile function takes the following arguments: + * + * * `tElement` - template element - The element where the directive has been declared. It is + * safe to do template transformation on the element and child elements only. + * + * * `tAttrs` - template attributes - Normalized list of attributes declared on this element shared + * between all directive compile functions. + * + * * `transclude` - [*DEPRECATED*!] A transclude linking function: `function(scope, cloneLinkingFn)` + * + *
+ * **Note:** The template instance and the link instance may be different objects if the template has + * been cloned. For this reason it is **not** safe to do anything other than DOM transformations that + * apply to all cloned DOM nodes within the compile function. Specifically, DOM listener registration + * should be done in a linking function rather than in a compile function. + *
+ + *
+ * **Note:** The compile function cannot handle directives that recursively use themselves in their + * own templates or compile functions. Compiling these directives results in an infinite loop and a + * stack overflow errors. + * + * This can be avoided by manually using $compile in the postLink function to imperatively compile + * a directive's template instead of relying on automatic template compilation via `template` or + * `templateUrl` declaration or manual compilation inside the compile function. + *
+ * + *
+ * **Note:** The `transclude` function that is passed to the compile function is deprecated, as it + * e.g. does not know about the right outer scope. Please use the transclude function that is passed + * to the link function instead. + *
+ + * A compile function can have a return value which can be either a function or an object. + * + * * returning a (post-link) function - is equivalent to registering the linking function via the + * `link` property of the config object when the compile function is empty. + * + * * returning an object with function(s) registered via `pre` and `post` properties - allows you to + * control when a linking function should be called during the linking phase. See info about + * pre-linking and post-linking functions below. + * + * + * #### `link` + * This property is used only if the `compile` property is not defined. + * + * ```js + * function link(scope, iElement, iAttrs, controller, transcludeFn) { ... } + * ``` + * + * The link function is responsible for registering DOM listeners as well as updating the DOM. It is + * executed after the template has been cloned. This is where most of the directive logic will be + * put. + * + * * `scope` - {@link ng.$rootScope.Scope Scope} - The scope to be used by the + * directive for registering {@link ng.$rootScope.Scope#$watch watches}. + * + * * `iElement` - instance element - The element where the directive is to be used. It is safe to + * manipulate the children of the element only in `postLink` function since the children have + * already been linked. + * + * * `iAttrs` - instance attributes - Normalized list of attributes declared on this element shared + * between all directive linking functions. + * + * * `controller` - a controller instance - A controller instance if at least one directive on the + * element defines a controller. The controller is shared among all the directives, which allows + * the directives to use the controllers as a communication channel. + * + * * `transcludeFn` - A transclude linking function pre-bound to the correct transclusion scope. + * The scope can be overridden by an optional first argument. This is the same as the `$transclude` + * parameter of directive controllers. + * `function([scope], cloneLinkingFn)`. + * + * + * #### Pre-linking function + * + * Executed before the child elements are linked. Not safe to do DOM transformation since the + * compiler linking function will fail to locate the correct elements for linking. + * + * #### Post-linking function + * + * Executed after the child elements are linked. It is safe to do DOM transformation in the post-linking function. + * + * + * ### Attributes + * + * The {@link ng.$compile.directive.Attributes Attributes} object - passed as a parameter in the + * `link()` or `compile()` functions. It has a variety of uses. + * + * accessing *Normalized attribute names:* + * Directives like 'ngBind' can be expressed in many ways: 'ng:bind', `data-ng-bind`, or 'x-ng-bind'. + * the attributes object allows for normalized access to + * the attributes. + * + * * *Directive inter-communication:* All directives share the same instance of the attributes + * object which allows the directives to use the attributes object as inter directive + * communication. + * + * * *Supports interpolation:* Interpolation attributes are assigned to the attribute object + * allowing other directives to read the interpolated value. + * + * * *Observing interpolated attributes:* Use `$observe` to observe the value changes of attributes + * that contain interpolation (e.g. `src="{{bar}}"`). Not only is this very efficient but it's also + * the only way to easily get the actual value because during the linking phase the interpolation + * hasn't been evaluated yet and so the value is at this time set to `undefined`. + * + * ```js + * function linkingFn(scope, elm, attrs, ctrl) { + * // get the attribute value + * console.log(attrs.ngModel); + * + * // change the attribute + * attrs.$set('ngModel', 'new value'); + * + * // observe changes to interpolated attribute + * attrs.$observe('ngModel', function(value) { + * console.log('ngModel has changed value to ' + value); + * }); + * } + * ``` + * + * Below is an example using `$compileProvider`. + * + *
+ * **Note**: Typically directives are registered with `module.directive`. The example below is + * to illustrate how `$compile` works. + *
+ * + + + +
+
+
+
+
+
+ + it('should auto compile', function() { + var textarea = $('textarea'); + var output = $('div[compile]'); + // The initial state reads 'Hello Angular'. + expect(output.getText()).toBe('Hello Angular'); + textarea.clear(); + textarea.sendKeys('{{name}}!'); + expect(output.getText()).toBe('Angular!'); + }); + +
+ + * + * + * @param {string|DOMElement} element Element or HTML string to compile into a template function. + * @param {function(angular.Scope, cloneAttachFn=)} transclude function available to directives. + * @param {number} maxPriority only apply directives lower than given priority (Only effects the + * root element(s), not their children) + * @returns {function(scope, cloneAttachFn=)} a link function which is used to bind template + * (a DOM element/tree) to a scope. Where: + * + * * `scope` - A {@link ng.$rootScope.Scope Scope} to bind to. + * * `cloneAttachFn` - If `cloneAttachFn` is provided, then the link function will clone the + * `template` and call the `cloneAttachFn` function allowing the caller to attach the + * cloned elements to the DOM document at the appropriate place. The `cloneAttachFn` is + * called as:
`cloneAttachFn(clonedElement, scope)` where: + * + * * `clonedElement` - is a clone of the original `element` passed into the compiler. + * * `scope` - is the current scope with which the linking function is working with. + * + * Calling the linking function returns the element of the template. It is either the original + * element passed in, or the clone of the element if the `cloneAttachFn` is provided. + * + * After linking the view is not updated until after a call to $digest which typically is done by + * Angular automatically. + * + * If you need access to the bound view, there are two ways to do it: + * + * - If you are not asking the linking function to clone the template, create the DOM element(s) + * before you send them to the compiler and keep this reference around. + * ```js + * var element = $compile('

{{total}}

')(scope); + * ``` + * + * - if on the other hand, you need the element to be cloned, the view reference from the original + * example would not point to the clone, but rather to the original template that was cloned. In + * this case, you can access the clone via the cloneAttachFn: + * ```js + * var templateElement = angular.element('

{{total}}

'), + * scope = ....; + * + * var clonedElement = $compile(templateElement)(scope, function(clonedElement, scope) { + * //attach the clone to DOM document at the right place + * }); + * + * //now we have reference to the cloned DOM via `clonedElement` + * ``` + * + * + * For information on how the compiler works, see the + * {@link guide/compiler Angular HTML Compiler} section of the Developer Guide. + */ + +var $compileMinErr = minErr('$compile'); + +/** + * @ngdoc provider + * @name $compileProvider + * @kind function + * + * @description + */ +$CompileProvider.$inject = ['$provide', '$$sanitizeUriProvider']; +function $CompileProvider($provide, $$sanitizeUriProvider) { + var hasDirectives = {}, + Suffix = 'Directive', + COMMENT_DIRECTIVE_REGEXP = /^\s*directive\:\s*([\d\w_\-]+)\s+(.*)$/, + CLASS_DIRECTIVE_REGEXP = /(([\d\w_\-]+)(?:\:([^;]+))?;?)/; + + // Ref: http://developers.whatwg.org/webappapis.html#event-handler-idl-attributes + // The assumption is that future DOM event attribute names will begin with + // 'on' and be composed of only English letters. + var EVENT_HANDLER_ATTR_REGEXP = /^(on[a-z]+|formaction)$/; + + /** + * @ngdoc method + * @name $compileProvider#directive + * @kind function + * + * @description + * Register a new directive with the compiler. + * + * @param {string|Object} name Name of the directive in camel-case (i.e. ngBind which + * will match as ng-bind), or an object map of directives where the keys are the + * names and the values are the factories. + * @param {Function|Array} directiveFactory An injectable directive factory function. See + * {@link guide/directive} for more info. + * @returns {ng.$compileProvider} Self for chaining. + */ + this.directive = function registerDirective(name, directiveFactory) { + assertNotHasOwnProperty(name, 'directive'); + if (isString(name)) { + assertArg(directiveFactory, 'directiveFactory'); + if (!hasDirectives.hasOwnProperty(name)) { + hasDirectives[name] = []; + $provide.factory(name + Suffix, ['$injector', '$exceptionHandler', + function($injector, $exceptionHandler) { + var directives = []; + forEach(hasDirectives[name], function(directiveFactory, index) { + try { + var directive = $injector.invoke(directiveFactory); + if (isFunction(directive)) { + directive = { compile: valueFn(directive) }; + } else if (!directive.compile && directive.link) { + directive.compile = valueFn(directive.link); + } + directive.priority = directive.priority || 0; + directive.index = index; + directive.name = directive.name || name; + directive.require = directive.require || (directive.controller && directive.name); + directive.restrict = directive.restrict || 'A'; + directives.push(directive); + } catch (e) { + $exceptionHandler(e); + } + }); + return directives; + }]); + } + hasDirectives[name].push(directiveFactory); + } else { + forEach(name, reverseParams(registerDirective)); + } + return this; + }; + + + /** + * @ngdoc method + * @name $compileProvider#aHrefSanitizationWhitelist + * @kind function + * + * @description + * Retrieves or overrides the default regular expression that is used for whitelisting of safe + * urls during a[href] sanitization. + * + * The sanitization is a security measure aimed at prevent XSS attacks via html links. + * + * Any url about to be assigned to a[href] via data-binding is first normalized and turned into + * an absolute url. Afterwards, the url is matched against the `aHrefSanitizationWhitelist` + * regular expression. If a match is found, the original url is written into the dom. Otherwise, + * the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM. + * + * @param {RegExp=} regexp New regexp to whitelist urls with. + * @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for + * chaining otherwise. + */ + this.aHrefSanitizationWhitelist = function(regexp) { + if (isDefined(regexp)) { + $$sanitizeUriProvider.aHrefSanitizationWhitelist(regexp); + return this; + } else { + return $$sanitizeUriProvider.aHrefSanitizationWhitelist(); + } + }; + + + /** + * @ngdoc method + * @name $compileProvider#imgSrcSanitizationWhitelist + * @kind function + * + * @description + * Retrieves or overrides the default regular expression that is used for whitelisting of safe + * urls during img[src] sanitization. + * + * The sanitization is a security measure aimed at prevent XSS attacks via html links. + * + * Any url about to be assigned to img[src] via data-binding is first normalized and turned into + * an absolute url. Afterwards, the url is matched against the `imgSrcSanitizationWhitelist` + * regular expression. If a match is found, the original url is written into the dom. Otherwise, + * the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM. + * + * @param {RegExp=} regexp New regexp to whitelist urls with. + * @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for + * chaining otherwise. + */ + this.imgSrcSanitizationWhitelist = function(regexp) { + if (isDefined(regexp)) { + $$sanitizeUriProvider.imgSrcSanitizationWhitelist(regexp); + return this; + } else { + return $$sanitizeUriProvider.imgSrcSanitizationWhitelist(); + } + }; + + this.$get = [ + '$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', '$parse', + '$controller', '$rootScope', '$document', '$sce', '$animate', '$$sanitizeUri', + function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, + $controller, $rootScope, $document, $sce, $animate, $$sanitizeUri) { + + var Attributes = function(element, attr) { + this.$$element = element; + this.$attr = attr || {}; + }; + + Attributes.prototype = { + $normalize: directiveNormalize, + + + /** + * @ngdoc method + * @name $compile.directive.Attributes#$addClass + * @kind function + * + * @description + * Adds the CSS class value specified by the classVal parameter to the element. If animations + * are enabled then an animation will be triggered for the class addition. + * + * @param {string} classVal The className value that will be added to the element + */ + $addClass : function(classVal) { + if(classVal && classVal.length > 0) { + $animate.addClass(this.$$element, classVal); + } + }, + + /** + * @ngdoc method + * @name $compile.directive.Attributes#$removeClass + * @kind function + * + * @description + * Removes the CSS class value specified by the classVal parameter from the element. If + * animations are enabled then an animation will be triggered for the class removal. + * + * @param {string} classVal The className value that will be removed from the element + */ + $removeClass : function(classVal) { + if(classVal && classVal.length > 0) { + $animate.removeClass(this.$$element, classVal); + } + }, + + /** + * @ngdoc method + * @name $compile.directive.Attributes#$updateClass + * @kind function + * + * @description + * Adds and removes the appropriate CSS class values to the element based on the difference + * between the new and old CSS class values (specified as newClasses and oldClasses). + * + * @param {string} newClasses The current CSS className value + * @param {string} oldClasses The former CSS className value + */ + $updateClass : function(newClasses, oldClasses) { + var toAdd = tokenDifference(newClasses, oldClasses); + var toRemove = tokenDifference(oldClasses, newClasses); + + if(toAdd.length === 0) { + $animate.removeClass(this.$$element, toRemove); + } else if(toRemove.length === 0) { + $animate.addClass(this.$$element, toAdd); + } else { + $animate.setClass(this.$$element, toAdd, toRemove); + } + }, + + /** + * Set a normalized attribute on the element in a way such that all directives + * can share the attribute. This function properly handles boolean attributes. + * @param {string} key Normalized key. (ie ngAttribute) + * @param {string|boolean} value The value to set. If `null` attribute will be deleted. + * @param {boolean=} writeAttr If false, does not write the value to DOM element attribute. + * Defaults to true. + * @param {string=} attrName Optional none normalized name. Defaults to key. + */ + $set: function(key, value, writeAttr, attrName) { + // TODO: decide whether or not to throw an error if "class" + //is set through this function since it may cause $updateClass to + //become unstable. + + var booleanKey = getBooleanAttrName(this.$$element[0], key), + normalizedVal, + nodeName; + + if (booleanKey) { + this.$$element.prop(key, value); + attrName = booleanKey; + } + + this[key] = value; + + // translate normalized key to actual key + if (attrName) { + this.$attr[key] = attrName; + } else { + attrName = this.$attr[key]; + if (!attrName) { + this.$attr[key] = attrName = snake_case(key, '-'); + } + } + + nodeName = nodeName_(this.$$element); + + // sanitize a[href] and img[src] values + if ((nodeName === 'A' && key === 'href') || + (nodeName === 'IMG' && key === 'src')) { + this[key] = value = $$sanitizeUri(value, key === 'src'); + } + + if (writeAttr !== false) { + if (value === null || value === undefined) { + this.$$element.removeAttr(attrName); + } else { + this.$$element.attr(attrName, value); + } + } + + // fire observers + var $$observers = this.$$observers; + $$observers && forEach($$observers[key], function(fn) { + try { + fn(value); + } catch (e) { + $exceptionHandler(e); + } + }); + }, + + + /** + * @ngdoc method + * @name $compile.directive.Attributes#$observe + * @kind function + * + * @description + * Observes an interpolated attribute. + * + * The observer function will be invoked once during the next `$digest` following + * compilation. The observer is then invoked whenever the interpolated value + * changes. + * + * @param {string} key Normalized key. (ie ngAttribute) . + * @param {function(interpolatedValue)} fn Function that will be called whenever + the interpolated value of the attribute changes. + * See the {@link guide/directive#Attributes Directives} guide for more info. + * @returns {function()} the `fn` parameter. + */ + $observe: function(key, fn) { + var attrs = this, + $$observers = (attrs.$$observers || (attrs.$$observers = {})), + listeners = ($$observers[key] || ($$observers[key] = [])); + + listeners.push(fn); + $rootScope.$evalAsync(function() { + if (!listeners.$$inter) { + // no one registered attribute interpolation function, so lets call it manually + fn(attrs[key]); + } + }); + return fn; + } + }; + + var startSymbol = $interpolate.startSymbol(), + endSymbol = $interpolate.endSymbol(), + denormalizeTemplate = (startSymbol == '{{' || endSymbol == '}}') + ? identity + : function denormalizeTemplate(template) { + return template.replace(/\{\{/g, startSymbol).replace(/}}/g, endSymbol); + }, + NG_ATTR_BINDING = /^ngAttr[A-Z]/; + + + return compile; + + //================================ + + function compile($compileNodes, transcludeFn, maxPriority, ignoreDirective, + previousCompileContext) { + if (!($compileNodes instanceof jqLite)) { + // jquery always rewraps, whereas we need to preserve the original selector so that we can + // modify it. + $compileNodes = jqLite($compileNodes); + } + // We can not compile top level text elements since text nodes can be merged and we will + // not be able to attach scope data to them, so we will wrap them in + forEach($compileNodes, function(node, index){ + if (node.nodeType == 3 /* text node */ && node.nodeValue.match(/\S+/) /* non-empty */ ) { + $compileNodes[index] = node = jqLite(node).wrap('').parent()[0]; + } + }); + var compositeLinkFn = + compileNodes($compileNodes, transcludeFn, $compileNodes, + maxPriority, ignoreDirective, previousCompileContext); + safeAddClass($compileNodes, 'ng-scope'); + return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn){ + assertArg(scope, 'scope'); + // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart + // and sometimes changes the structure of the DOM. + var $linkNode = cloneConnectFn + ? JQLitePrototype.clone.call($compileNodes) // IMPORTANT!!! + : $compileNodes; + + forEach(transcludeControllers, function(instance, name) { + $linkNode.data('$' + name + 'Controller', instance); + }); + + // Attach scope only to non-text nodes. + for(var i = 0, ii = $linkNode.length; i + addDirective(directives, + directiveNormalize(nodeName_(node).toLowerCase()), 'E', maxPriority, ignoreDirective); + + // iterate over the attributes + for (var attr, name, nName, ngAttrName, value, isNgAttr, nAttrs = node.attributes, + j = 0, jj = nAttrs && nAttrs.length; j < jj; j++) { + var attrStartName = false; + var attrEndName = false; + + attr = nAttrs[j]; + if (!msie || msie >= 8 || attr.specified) { + name = attr.name; + value = trim(attr.value); + + // support ngAttr attribute binding + ngAttrName = directiveNormalize(name); + if (isNgAttr = NG_ATTR_BINDING.test(ngAttrName)) { + name = snake_case(ngAttrName.substr(6), '-'); + } + + var directiveNName = ngAttrName.replace(/(Start|End)$/, ''); + if (ngAttrName === directiveNName + 'Start') { + attrStartName = name; + attrEndName = name.substr(0, name.length - 5) + 'end'; + name = name.substr(0, name.length - 6); + } + + nName = directiveNormalize(name.toLowerCase()); + attrsMap[nName] = name; + if (isNgAttr || !attrs.hasOwnProperty(nName)) { + attrs[nName] = value; + if (getBooleanAttrName(node, nName)) { + attrs[nName] = true; // presence means true + } + } + addAttrInterpolateDirective(node, directives, value, nName); + addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName, + attrEndName); + } + } + + // use class as directive + className = node.className; + if (isString(className) && className !== '') { + while (match = CLASS_DIRECTIVE_REGEXP.exec(className)) { + nName = directiveNormalize(match[2]); + if (addDirective(directives, nName, 'C', maxPriority, ignoreDirective)) { + attrs[nName] = trim(match[3]); + } + className = className.substr(match.index + match[0].length); + } + } + break; + case 3: /* Text Node */ + addTextInterpolateDirective(directives, node.nodeValue); + break; + case 8: /* Comment */ + try { + match = COMMENT_DIRECTIVE_REGEXP.exec(node.nodeValue); + if (match) { + nName = directiveNormalize(match[1]); + if (addDirective(directives, nName, 'M', maxPriority, ignoreDirective)) { + attrs[nName] = trim(match[2]); + } + } + } catch (e) { + // turns out that under some circumstances IE9 throws errors when one attempts to read + // comment's node value. + // Just ignore it and continue. (Can't seem to reproduce in test case.) + } + break; + } + + directives.sort(byPriority); + return directives; + } + + /** + * Given a node with an directive-start it collects all of the siblings until it finds + * directive-end. + * @param node + * @param attrStart + * @param attrEnd + * @returns {*} + */ + function groupScan(node, attrStart, attrEnd) { + var nodes = []; + var depth = 0; + if (attrStart && node.hasAttribute && node.hasAttribute(attrStart)) { + var startNode = node; + do { + if (!node) { + throw $compileMinErr('uterdir', + "Unterminated attribute, found '{0}' but no matching '{1}' found.", + attrStart, attrEnd); + } + if (node.nodeType == 1 /** Element **/) { + if (node.hasAttribute(attrStart)) depth++; + if (node.hasAttribute(attrEnd)) depth--; + } + nodes.push(node); + node = node.nextSibling; + } while (depth > 0); + } else { + nodes.push(node); + } + + return jqLite(nodes); + } + + /** + * Wrapper for linking function which converts normal linking function into a grouped + * linking function. + * @param linkFn + * @param attrStart + * @param attrEnd + * @returns {Function} + */ + function groupElementsLinkFnWrapper(linkFn, attrStart, attrEnd) { + return function(scope, element, attrs, controllers, transcludeFn) { + element = groupScan(element[0], attrStart, attrEnd); + return linkFn(scope, element, attrs, controllers, transcludeFn); + }; + } + + /** + * Once the directives have been collected, their compile functions are executed. This method + * is responsible for inlining directive templates as well as terminating the application + * of the directives if the terminal directive has been reached. + * + * @param {Array} directives Array of collected directives to execute their compile function. + * this needs to be pre-sorted by priority order. + * @param {Node} compileNode The raw DOM node to apply the compile functions to + * @param {Object} templateAttrs The shared attribute function + * @param {function(angular.Scope, cloneAttachFn=)} transcludeFn A linking function, where the + * scope argument is auto-generated to the new + * child of the transcluded parent scope. + * @param {JQLite} jqCollection If we are working on the root of the compile tree then this + * argument has the root jqLite array so that we can replace nodes + * on it. + * @param {Object=} originalReplaceDirective An optional directive that will be ignored when + * compiling the transclusion. + * @param {Array.} preLinkFns + * @param {Array.} postLinkFns + * @param {Object} previousCompileContext Context used for previous compilation of the current + * node + * @returns {Function} linkFn + */ + function applyDirectivesToNode(directives, compileNode, templateAttrs, transcludeFn, + jqCollection, originalReplaceDirective, preLinkFns, postLinkFns, + previousCompileContext) { + previousCompileContext = previousCompileContext || {}; + + var terminalPriority = -Number.MAX_VALUE, + newScopeDirective, + controllerDirectives = previousCompileContext.controllerDirectives, + newIsolateScopeDirective = previousCompileContext.newIsolateScopeDirective, + templateDirective = previousCompileContext.templateDirective, + nonTlbTranscludeDirective = previousCompileContext.nonTlbTranscludeDirective, + hasTranscludeDirective = false, + hasTemplate = false, + hasElementTranscludeDirective = previousCompileContext.hasElementTranscludeDirective, + $compileNode = templateAttrs.$$element = jqLite(compileNode), + directive, + directiveName, + $template, + replaceDirective = originalReplaceDirective, + childTranscludeFn = transcludeFn, + linkFn, + directiveValue; + + // executes all directives on the current element + for(var i = 0, ii = directives.length; i < ii; i++) { + directive = directives[i]; + var attrStart = directive.$$start; + var attrEnd = directive.$$end; + + // collect multiblock sections + if (attrStart) { + $compileNode = groupScan(compileNode, attrStart, attrEnd); + } + $template = undefined; + + if (terminalPriority > directive.priority) { + break; // prevent further processing of directives + } + + if (directiveValue = directive.scope) { + newScopeDirective = newScopeDirective || directive; + + // skip the check for directives with async templates, we'll check the derived sync + // directive when the template arrives + if (!directive.templateUrl) { + assertNoDuplicate('new/isolated scope', newIsolateScopeDirective, directive, + $compileNode); + if (isObject(directiveValue)) { + newIsolateScopeDirective = directive; + } + } + } + + directiveName = directive.name; + + if (!directive.templateUrl && directive.controller) { + directiveValue = directive.controller; + controllerDirectives = controllerDirectives || {}; + assertNoDuplicate("'" + directiveName + "' controller", + controllerDirectives[directiveName], directive, $compileNode); + controllerDirectives[directiveName] = directive; + } + + if (directiveValue = directive.transclude) { + hasTranscludeDirective = true; + + // Special case ngIf and ngRepeat so that we don't complain about duplicate transclusion. + // This option should only be used by directives that know how to safely handle element transclusion, + // where the transcluded nodes are added or replaced after linking. + if (!directive.$$tlb) { + assertNoDuplicate('transclusion', nonTlbTranscludeDirective, directive, $compileNode); + nonTlbTranscludeDirective = directive; + } + + if (directiveValue == 'element') { + hasElementTranscludeDirective = true; + terminalPriority = directive.priority; + $template = groupScan(compileNode, attrStart, attrEnd); + $compileNode = templateAttrs.$$element = + jqLite(document.createComment(' ' + directiveName + ': ' + + templateAttrs[directiveName] + ' ')); + compileNode = $compileNode[0]; + replaceWith(jqCollection, jqLite(sliceArgs($template)), compileNode); + + childTranscludeFn = compile($template, transcludeFn, terminalPriority, + replaceDirective && replaceDirective.name, { + // Don't pass in: + // - controllerDirectives - otherwise we'll create duplicates controllers + // - newIsolateScopeDirective or templateDirective - combining templates with + // element transclusion doesn't make sense. + // + // We need only nonTlbTranscludeDirective so that we prevent putting transclusion + // on the same element more than once. + nonTlbTranscludeDirective: nonTlbTranscludeDirective + }); + } else { + $template = jqLite(jqLiteClone(compileNode)).contents(); + $compileNode.empty(); // clear contents + childTranscludeFn = compile($template, transcludeFn); + } + } + + if (directive.template) { + hasTemplate = true; + assertNoDuplicate('template', templateDirective, directive, $compileNode); + templateDirective = directive; + + directiveValue = (isFunction(directive.template)) + ? directive.template($compileNode, templateAttrs) + : directive.template; + + directiveValue = denormalizeTemplate(directiveValue); + + if (directive.replace) { + replaceDirective = directive; + if (jqLiteIsTextNode(directiveValue)) { + $template = []; + } else { + $template = jqLite(trim(directiveValue)); + } + compileNode = $template[0]; + + if ($template.length != 1 || compileNode.nodeType !== 1) { + throw $compileMinErr('tplrt', + "Template for directive '{0}' must have exactly one root element. {1}", + directiveName, ''); + } + + replaceWith(jqCollection, $compileNode, compileNode); + + var newTemplateAttrs = {$attr: {}}; + + // combine directives from the original node and from the template: + // - take the array of directives for this element + // - split it into two parts, those that already applied (processed) and those that weren't (unprocessed) + // - collect directives from the template and sort them by priority + // - combine directives as: processed + template + unprocessed + var templateDirectives = collectDirectives(compileNode, [], newTemplateAttrs); + var unprocessedDirectives = directives.splice(i + 1, directives.length - (i + 1)); + + if (newIsolateScopeDirective) { + markDirectivesAsIsolate(templateDirectives); + } + directives = directives.concat(templateDirectives).concat(unprocessedDirectives); + mergeTemplateAttributes(templateAttrs, newTemplateAttrs); + + ii = directives.length; + } else { + $compileNode.html(directiveValue); + } + } + + if (directive.templateUrl) { + hasTemplate = true; + assertNoDuplicate('template', templateDirective, directive, $compileNode); + templateDirective = directive; + + if (directive.replace) { + replaceDirective = directive; + } + + nodeLinkFn = compileTemplateUrl(directives.splice(i, directives.length - i), $compileNode, + templateAttrs, jqCollection, hasTranscludeDirective && childTranscludeFn, preLinkFns, postLinkFns, { + controllerDirectives: controllerDirectives, + newIsolateScopeDirective: newIsolateScopeDirective, + templateDirective: templateDirective, + nonTlbTranscludeDirective: nonTlbTranscludeDirective + }); + ii = directives.length; + } else if (directive.compile) { + try { + linkFn = directive.compile($compileNode, templateAttrs, childTranscludeFn); + if (isFunction(linkFn)) { + addLinkFns(null, linkFn, attrStart, attrEnd); + } else if (linkFn) { + addLinkFns(linkFn.pre, linkFn.post, attrStart, attrEnd); + } + } catch (e) { + $exceptionHandler(e, startingTag($compileNode)); + } + } + + if (directive.terminal) { + nodeLinkFn.terminal = true; + terminalPriority = Math.max(terminalPriority, directive.priority); + } + + } + + nodeLinkFn.scope = newScopeDirective && newScopeDirective.scope === true; + nodeLinkFn.transcludeOnThisElement = hasTranscludeDirective; + nodeLinkFn.templateOnThisElement = hasTemplate; + nodeLinkFn.transclude = childTranscludeFn; + + previousCompileContext.hasElementTranscludeDirective = hasElementTranscludeDirective; + + // might be normal or delayed nodeLinkFn depending on if templateUrl is present + return nodeLinkFn; + + //////////////////// + + function addLinkFns(pre, post, attrStart, attrEnd) { + if (pre) { + if (attrStart) pre = groupElementsLinkFnWrapper(pre, attrStart, attrEnd); + pre.require = directive.require; + pre.directiveName = directiveName; + if (newIsolateScopeDirective === directive || directive.$$isolateScope) { + pre = cloneAndAnnotateFn(pre, {isolateScope: true}); + } + preLinkFns.push(pre); + } + if (post) { + if (attrStart) post = groupElementsLinkFnWrapper(post, attrStart, attrEnd); + post.require = directive.require; + post.directiveName = directiveName; + if (newIsolateScopeDirective === directive || directive.$$isolateScope) { + post = cloneAndAnnotateFn(post, {isolateScope: true}); + } + postLinkFns.push(post); + } + } + + + function getControllers(directiveName, require, $element, elementControllers) { + var value, retrievalMethod = 'data', optional = false; + if (isString(require)) { + while((value = require.charAt(0)) == '^' || value == '?') { + require = require.substr(1); + if (value == '^') { + retrievalMethod = 'inheritedData'; + } + optional = optional || value == '?'; + } + value = null; + + if (elementControllers && retrievalMethod === 'data') { + value = elementControllers[require]; + } + value = value || $element[retrievalMethod]('$' + require + 'Controller'); + + if (!value && !optional) { + throw $compileMinErr('ctreq', + "Controller '{0}', required by directive '{1}', can't be found!", + require, directiveName); + } + return value; + } else if (isArray(require)) { + value = []; + forEach(require, function(require) { + value.push(getControllers(directiveName, require, $element, elementControllers)); + }); + } + return value; + } + + + function nodeLinkFn(childLinkFn, scope, linkNode, $rootElement, boundTranscludeFn) { + var attrs, $element, i, ii, linkFn, controller, isolateScope, elementControllers = {}, transcludeFn; + + if (compileNode === linkNode) { + attrs = templateAttrs; + } else { + attrs = shallowCopy(templateAttrs, new Attributes(jqLite(linkNode), templateAttrs.$attr)); + } + $element = attrs.$$element; + + if (newIsolateScopeDirective) { + var LOCAL_REGEXP = /^\s*([@=&])(\??)\s*(\w*)\s*$/; + var $linkNode = jqLite(linkNode); + + isolateScope = scope.$new(true); + + if (templateDirective && (templateDirective === newIsolateScopeDirective || + templateDirective === newIsolateScopeDirective.$$originalDirective)) { + $linkNode.data('$isolateScope', isolateScope) ; + } else { + $linkNode.data('$isolateScopeNoTemplate', isolateScope); + } + + + + safeAddClass($linkNode, 'ng-isolate-scope'); + + forEach(newIsolateScopeDirective.scope, function(definition, scopeName) { + var match = definition.match(LOCAL_REGEXP) || [], + attrName = match[3] || scopeName, + optional = (match[2] == '?'), + mode = match[1], // @, =, or & + lastValue, + parentGet, parentSet, compare; + + isolateScope.$$isolateBindings[scopeName] = mode + attrName; + + switch (mode) { + + case '@': + attrs.$observe(attrName, function(value) { + isolateScope[scopeName] = value; + }); + attrs.$$observers[attrName].$$scope = scope; + if( attrs[attrName] ) { + // If the attribute has been provided then we trigger an interpolation to ensure + // the value is there for use in the link fn + isolateScope[scopeName] = $interpolate(attrs[attrName])(scope); + } + break; + + case '=': + if (optional && !attrs[attrName]) { + return; + } + parentGet = $parse(attrs[attrName]); + if (parentGet.literal) { + compare = equals; + } else { + compare = function(a,b) { return a === b; }; + } + parentSet = parentGet.assign || function() { + // reset the change, or we will throw this exception on every $digest + lastValue = isolateScope[scopeName] = parentGet(scope); + throw $compileMinErr('nonassign', + "Expression '{0}' used with directive '{1}' is non-assignable!", + attrs[attrName], newIsolateScopeDirective.name); + }; + lastValue = isolateScope[scopeName] = parentGet(scope); + isolateScope.$watch(function parentValueWatch() { + var parentValue = parentGet(scope); + if (!compare(parentValue, isolateScope[scopeName])) { + // we are out of sync and need to copy + if (!compare(parentValue, lastValue)) { + // parent changed and it has precedence + isolateScope[scopeName] = parentValue; + } else { + // if the parent can be assigned then do so + parentSet(scope, parentValue = isolateScope[scopeName]); + } + } + return lastValue = parentValue; + }, null, parentGet.literal); + break; + + case '&': + parentGet = $parse(attrs[attrName]); + isolateScope[scopeName] = function(locals) { + return parentGet(scope, locals); + }; + break; + + default: + throw $compileMinErr('iscp', + "Invalid isolate scope definition for directive '{0}'." + + " Definition: {... {1}: '{2}' ...}", + newIsolateScopeDirective.name, scopeName, definition); + } + }); + } + transcludeFn = boundTranscludeFn && controllersBoundTransclude; + if (controllerDirectives) { + forEach(controllerDirectives, function(directive) { + var locals = { + $scope: directive === newIsolateScopeDirective || directive.$$isolateScope ? isolateScope : scope, + $element: $element, + $attrs: attrs, + $transclude: transcludeFn + }, controllerInstance; + + controller = directive.controller; + if (controller == '@') { + controller = attrs[directive.name]; + } + + controllerInstance = $controller(controller, locals); + // For directives with element transclusion the element is a comment, + // but jQuery .data doesn't support attaching data to comment nodes as it's hard to + // clean up (http://bugs.jquery.com/ticket/8335). + // Instead, we save the controllers for the element in a local hash and attach to .data + // later, once we have the actual element. + elementControllers[directive.name] = controllerInstance; + if (!hasElementTranscludeDirective) { + $element.data('$' + directive.name + 'Controller', controllerInstance); + } + + if (directive.controllerAs) { + locals.$scope[directive.controllerAs] = controllerInstance; + } + }); + } + + // PRELINKING + for(i = 0, ii = preLinkFns.length; i < ii; i++) { + try { + linkFn = preLinkFns[i]; + linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), transcludeFn); + } catch (e) { + $exceptionHandler(e, startingTag($element)); + } + } + + // RECURSION + // We only pass the isolate scope, if the isolate directive has a template, + // otherwise the child elements do not belong to the isolate directive. + var scopeToChild = scope; + if (newIsolateScopeDirective && (newIsolateScopeDirective.template || newIsolateScopeDirective.templateUrl === null)) { + scopeToChild = isolateScope; + } + childLinkFn && childLinkFn(scopeToChild, linkNode.childNodes, undefined, boundTranscludeFn); + + // POSTLINKING + for(i = postLinkFns.length - 1; i >= 0; i--) { + try { + linkFn = postLinkFns[i]; + linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), transcludeFn); + } catch (e) { + $exceptionHandler(e, startingTag($element)); + } + } + + // This is the function that is injected as `$transclude`. + function controllersBoundTransclude(scope, cloneAttachFn) { + var transcludeControllers; + + // no scope passed + if (arguments.length < 2) { + cloneAttachFn = scope; + scope = undefined; + } + + if (hasElementTranscludeDirective) { + transcludeControllers = elementControllers; + } + + return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers); + } + } + } + + function markDirectivesAsIsolate(directives) { + // mark all directives as needing isolate scope. + for (var j = 0, jj = directives.length; j < jj; j++) { + directives[j] = inherit(directives[j], {$$isolateScope: true}); + } + } + + /** + * looks up the directive and decorates it with exception handling and proper parameters. We + * call this the boundDirective. + * + * @param {string} name name of the directive to look up. + * @param {string} location The directive must be found in specific format. + * String containing any of theses characters: + * + * * `E`: element name + * * `A': attribute + * * `C`: class + * * `M`: comment + * @returns {boolean} true if directive was added. + */ + function addDirective(tDirectives, name, location, maxPriority, ignoreDirective, startAttrName, + endAttrName) { + if (name === ignoreDirective) return null; + var match = null; + if (hasDirectives.hasOwnProperty(name)) { + for(var directive, directives = $injector.get(name + Suffix), + i = 0, ii = directives.length; i directive.priority) && + directive.restrict.indexOf(location) != -1) { + if (startAttrName) { + directive = inherit(directive, {$$start: startAttrName, $$end: endAttrName}); + } + tDirectives.push(directive); + match = directive; + } + } catch(e) { $exceptionHandler(e); } + } + } + return match; + } + + + /** + * When the element is replaced with HTML template then the new attributes + * on the template need to be merged with the existing attributes in the DOM. + * The desired effect is to have both of the attributes present. + * + * @param {object} dst destination attributes (original DOM) + * @param {object} src source attributes (from the directive template) + */ + function mergeTemplateAttributes(dst, src) { + var srcAttr = src.$attr, + dstAttr = dst.$attr, + $element = dst.$$element; + + // reapply the old attributes to the new element + forEach(dst, function(value, key) { + if (key.charAt(0) != '$') { + if (src[key] && src[key] !== value) { + value += (key === 'style' ? ';' : ' ') + src[key]; + } + dst.$set(key, value, true, srcAttr[key]); + } + }); + + // copy the new attributes on the old attrs object + forEach(src, function(value, key) { + if (key == 'class') { + safeAddClass($element, value); + dst['class'] = (dst['class'] ? dst['class'] + ' ' : '') + value; + } else if (key == 'style') { + $element.attr('style', $element.attr('style') + ';' + value); + dst['style'] = (dst['style'] ? dst['style'] + ';' : '') + value; + // `dst` will never contain hasOwnProperty as DOM parser won't let it. + // You will get an "InvalidCharacterError: DOM Exception 5" error if you + // have an attribute like "has-own-property" or "data-has-own-property", etc. + } else if (key.charAt(0) != '$' && !dst.hasOwnProperty(key)) { + dst[key] = value; + dstAttr[key] = srcAttr[key]; + } + }); + } + + + function compileTemplateUrl(directives, $compileNode, tAttrs, + $rootElement, childTranscludeFn, preLinkFns, postLinkFns, previousCompileContext) { + var linkQueue = [], + afterTemplateNodeLinkFn, + afterTemplateChildLinkFn, + beforeTemplateCompileNode = $compileNode[0], + origAsyncDirective = directives.shift(), + // The fact that we have to copy and patch the directive seems wrong! + derivedSyncDirective = extend({}, origAsyncDirective, { + templateUrl: null, transclude: null, replace: null, $$originalDirective: origAsyncDirective + }), + templateUrl = (isFunction(origAsyncDirective.templateUrl)) + ? origAsyncDirective.templateUrl($compileNode, tAttrs) + : origAsyncDirective.templateUrl; + + $compileNode.empty(); + + $http.get($sce.getTrustedResourceUrl(templateUrl), {cache: $templateCache}). + success(function(content) { + var compileNode, tempTemplateAttrs, $template, childBoundTranscludeFn; + + content = denormalizeTemplate(content); + + if (origAsyncDirective.replace) { + if (jqLiteIsTextNode(content)) { + $template = []; + } else { + $template = jqLite(trim(content)); + } + compileNode = $template[0]; + + if ($template.length != 1 || compileNode.nodeType !== 1) { + throw $compileMinErr('tplrt', + "Template for directive '{0}' must have exactly one root element. {1}", + origAsyncDirective.name, templateUrl); + } + + tempTemplateAttrs = {$attr: {}}; + replaceWith($rootElement, $compileNode, compileNode); + var templateDirectives = collectDirectives(compileNode, [], tempTemplateAttrs); + + if (isObject(origAsyncDirective.scope)) { + markDirectivesAsIsolate(templateDirectives); + } + directives = templateDirectives.concat(directives); + mergeTemplateAttributes(tAttrs, tempTemplateAttrs); + } else { + compileNode = beforeTemplateCompileNode; + $compileNode.html(content); + } + + directives.unshift(derivedSyncDirective); + + afterTemplateNodeLinkFn = applyDirectivesToNode(directives, compileNode, tAttrs, + childTranscludeFn, $compileNode, origAsyncDirective, preLinkFns, postLinkFns, + previousCompileContext); + forEach($rootElement, function(node, i) { + if (node == compileNode) { + $rootElement[i] = $compileNode[0]; + } + }); + afterTemplateChildLinkFn = compileNodes($compileNode[0].childNodes, childTranscludeFn); + + while(linkQueue.length) { + var scope = linkQueue.shift(), + beforeTemplateLinkNode = linkQueue.shift(), + linkRootElement = linkQueue.shift(), + boundTranscludeFn = linkQueue.shift(), + linkNode = $compileNode[0]; + + if (beforeTemplateLinkNode !== beforeTemplateCompileNode) { + var oldClasses = beforeTemplateLinkNode.className; + + if (!(previousCompileContext.hasElementTranscludeDirective && + origAsyncDirective.replace)) { + // it was cloned therefore we have to clone as well. + linkNode = jqLiteClone(compileNode); + } + + replaceWith(linkRootElement, jqLite(beforeTemplateLinkNode), linkNode); + + // Copy in CSS classes from original node + safeAddClass(jqLite(linkNode), oldClasses); + } + if (afterTemplateNodeLinkFn.transcludeOnThisElement) { + childBoundTranscludeFn = createBoundTranscludeFn(scope, afterTemplateNodeLinkFn.transclude, boundTranscludeFn); + } else { + childBoundTranscludeFn = boundTranscludeFn; + } + afterTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, linkNode, $rootElement, + childBoundTranscludeFn); + } + linkQueue = null; + }). + error(function(response, code, headers, config) { + throw $compileMinErr('tpload', 'Failed to load template: {0}', config.url); + }); + + return function delayedNodeLinkFn(ignoreChildLinkFn, scope, node, rootElement, boundTranscludeFn) { + var childBoundTranscludeFn = boundTranscludeFn; + if (linkQueue) { + linkQueue.push(scope); + linkQueue.push(node); + linkQueue.push(rootElement); + linkQueue.push(childBoundTranscludeFn); + } else { + if (afterTemplateNodeLinkFn.transcludeOnThisElement) { + childBoundTranscludeFn = createBoundTranscludeFn(scope, afterTemplateNodeLinkFn.transclude, boundTranscludeFn); + } + afterTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, node, rootElement, childBoundTranscludeFn); + } + }; + } + + + /** + * Sorting function for bound directives. + */ + function byPriority(a, b) { + var diff = b.priority - a.priority; + if (diff !== 0) return diff; + if (a.name !== b.name) return (a.name < b.name) ? -1 : 1; + return a.index - b.index; + } + + + function assertNoDuplicate(what, previousDirective, directive, element) { + if (previousDirective) { + throw $compileMinErr('multidir', 'Multiple directives [{0}, {1}] asking for {2} on: {3}', + previousDirective.name, directive.name, what, startingTag(element)); + } + } + + + function addTextInterpolateDirective(directives, text) { + var interpolateFn = $interpolate(text, true); + if (interpolateFn) { + directives.push({ + priority: 0, + compile: function textInterpolateCompileFn(templateNode) { + // when transcluding a template that has bindings in the root + // then we don't have a parent and should do this in the linkFn + var parent = templateNode.parent(), hasCompileParent = parent.length; + if (hasCompileParent) safeAddClass(templateNode.parent(), 'ng-binding'); + + return function textInterpolateLinkFn(scope, node) { + var parent = node.parent(), + bindings = parent.data('$binding') || []; + bindings.push(interpolateFn); + parent.data('$binding', bindings); + if (!hasCompileParent) safeAddClass(parent, 'ng-binding'); + scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { + node[0].nodeValue = value; + }); + }; + } + }); + } + } + + + function getTrustedContext(node, attrNormalizedName) { + if (attrNormalizedName == "srcdoc") { + return $sce.HTML; + } + var tag = nodeName_(node); + // maction[xlink:href] can source SVG. It's not limited to . + if (attrNormalizedName == "xlinkHref" || + (tag == "FORM" && attrNormalizedName == "action") || + (tag != "IMG" && (attrNormalizedName == "src" || + attrNormalizedName == "ngSrc"))) { + return $sce.RESOURCE_URL; + } + } + + + function addAttrInterpolateDirective(node, directives, value, name) { + var interpolateFn = $interpolate(value, true); + + // no interpolation found -> ignore + if (!interpolateFn) return; + + + if (name === "multiple" && nodeName_(node) === "SELECT") { + throw $compileMinErr("selmulti", + "Binding to the 'multiple' attribute is not supported. Element: {0}", + startingTag(node)); + } + + directives.push({ + priority: 100, + compile: function() { + return { + pre: function attrInterpolatePreLinkFn(scope, element, attr) { + var $$observers = (attr.$$observers || (attr.$$observers = {})); + + if (EVENT_HANDLER_ATTR_REGEXP.test(name)) { + throw $compileMinErr('nodomevents', + "Interpolations for HTML DOM event attributes are disallowed. Please use the " + + "ng- versions (such as ng-click instead of onclick) instead."); + } + + // we need to interpolate again, in case the attribute value has been updated + // (e.g. by another directive's compile function) + interpolateFn = $interpolate(attr[name], true, getTrustedContext(node, name)); + + // if attribute was updated so that there is no interpolation going on we don't want to + // register any observers + if (!interpolateFn) return; + + // TODO(i): this should likely be attr.$set(name, iterpolateFn(scope) so that we reset the + // actual attr value + attr[name] = interpolateFn(scope); + ($$observers[name] || ($$observers[name] = [])).$$inter = true; + (attr.$$observers && attr.$$observers[name].$$scope || scope). + $watch(interpolateFn, function interpolateFnWatchAction(newValue, oldValue) { + //special case for class attribute addition + removal + //so that class changes can tap into the animation + //hooks provided by the $animate service. Be sure to + //skip animations when the first digest occurs (when + //both the new and the old values are the same) since + //the CSS classes are the non-interpolated values + if(name === 'class' && newValue != oldValue) { + attr.$updateClass(newValue, oldValue); + } else { + attr.$set(name, newValue); + } + }); + } + }; + } + }); + } + + + /** + * This is a special jqLite.replaceWith, which can replace items which + * have no parents, provided that the containing jqLite collection is provided. + * + * @param {JqLite=} $rootElement The root of the compile tree. Used so that we can replace nodes + * in the root of the tree. + * @param {JqLite} elementsToRemove The jqLite element which we are going to replace. We keep + * the shell, but replace its DOM node reference. + * @param {Node} newNode The new DOM node. + */ + function replaceWith($rootElement, elementsToRemove, newNode) { + var firstElementToRemove = elementsToRemove[0], + removeCount = elementsToRemove.length, + parent = firstElementToRemove.parentNode, + i, ii; + + if ($rootElement) { + for(i = 0, ii = $rootElement.length; i < ii; i++) { + if ($rootElement[i] == firstElementToRemove) { + $rootElement[i++] = newNode; + for (var j = i, j2 = j + removeCount - 1, + jj = $rootElement.length; + j < jj; j++, j2++) { + if (j2 < jj) { + $rootElement[j] = $rootElement[j2]; + } else { + delete $rootElement[j]; + } + } + $rootElement.length -= removeCount - 1; + break; + } + } + } + + if (parent) { + parent.replaceChild(newNode, firstElementToRemove); + } + var fragment = document.createDocumentFragment(); + fragment.appendChild(firstElementToRemove); + newNode[jqLite.expando] = firstElementToRemove[jqLite.expando]; + for (var k = 1, kk = elementsToRemove.length; k < kk; k++) { + var element = elementsToRemove[k]; + jqLite(element).remove(); // must do this way to clean up expando + fragment.appendChild(element); + delete elementsToRemove[k]; + } + + elementsToRemove[0] = newNode; + elementsToRemove.length = 1; + } + + + function cloneAndAnnotateFn(fn, annotation) { + return extend(function() { return fn.apply(null, arguments); }, fn, annotation); + } + }]; +} + +var PREFIX_REGEXP = /^(x[\:\-_]|data[\:\-_])/i; +/** + * Converts all accepted directives format into proper directive name. + * All of these will become 'myDirective': + * my:Directive + * my-directive + * x-my-directive + * data-my:directive + * + * Also there is special case for Moz prefix starting with upper case letter. + * @param name Name to normalize + */ +function directiveNormalize(name) { + return camelCase(name.replace(PREFIX_REGEXP, '')); +} + +/** + * @ngdoc type + * @name $compile.directive.Attributes + * + * @description + * A shared object between directive compile / linking functions which contains normalized DOM + * element attributes. The values reflect current binding state `{{ }}`. The normalization is + * needed since all of these are treated as equivalent in Angular: + * + * ``` + * + * ``` + */ + +/** + * @ngdoc property + * @name $compile.directive.Attributes#$attr + * @returns {object} A map of DOM element attribute names to the normalized name. This is + * needed to do reverse lookup from normalized name back to actual name. + */ + + +/** + * @ngdoc method + * @name $compile.directive.Attributes#$set + * @kind function + * + * @description + * Set DOM element attribute value. + * + * + * @param {string} name Normalized element attribute name of the property to modify. The name is + * reverse-translated using the {@link ng.$compile.directive.Attributes#$attr $attr} + * property to the original name. + * @param {string} value Value to set the attribute to. The value can be an interpolated string. + */ + + + +/** + * Closure compiler type information + */ + +function nodesetLinkingFn( + /* angular.Scope */ scope, + /* NodeList */ nodeList, + /* Element */ rootElement, + /* function(Function) */ boundTranscludeFn +){} + +function directiveLinkingFn( + /* nodesetLinkingFn */ nodesetLinkingFn, + /* angular.Scope */ scope, + /* Node */ node, + /* Element */ rootElement, + /* function(Function) */ boundTranscludeFn +){} + +function tokenDifference(str1, str2) { + var values = '', + tokens1 = str1.split(/\s+/), + tokens2 = str2.split(/\s+/); + + outer: + for(var i = 0; i < tokens1.length; i++) { + var token = tokens1[i]; + for(var j = 0; j < tokens2.length; j++) { + if(token == tokens2[j]) continue outer; + } + values += (values.length > 0 ? ' ' : '') + token; + } + return values; +} + +/** + * @ngdoc provider + * @name $controllerProvider + * @description + * The {@link ng.$controller $controller service} is used by Angular to create new + * controllers. + * + * This provider allows controller registration via the + * {@link ng.$controllerProvider#register register} method. + */ +function $ControllerProvider() { + var controllers = {}, + CNTRL_REG = /^(\S+)(\s+as\s+(\w+))?$/; + + + /** + * @ngdoc method + * @name $controllerProvider#register + * @param {string|Object} name Controller name, or an object map of controllers where the keys are + * the names and the values are the constructors. + * @param {Function|Array} constructor Controller constructor fn (optionally decorated with DI + * annotations in the array notation). + */ + this.register = function(name, constructor) { + assertNotHasOwnProperty(name, 'controller'); + if (isObject(name)) { + extend(controllers, name); + } else { + controllers[name] = constructor; + } + }; + + + this.$get = ['$injector', '$window', function($injector, $window) { + + /** + * @ngdoc service + * @name $controller + * @requires $injector + * + * @param {Function|string} constructor If called with a function then it's considered to be the + * controller constructor function. Otherwise it's considered to be a string which is used + * to retrieve the controller constructor using the following steps: + * + * * check if a controller with given name is registered via `$controllerProvider` + * * check if evaluating the string on the current scope returns a constructor + * * check `window[constructor]` on the global `window` object + * + * @param {Object} locals Injection locals for Controller. + * @return {Object} Instance of given controller. + * + * @description + * `$controller` service is responsible for instantiating controllers. + * + * It's just a simple call to {@link auto.$injector $injector}, but extracted into + * a service, so that one can override this service with [BC version](https://gist.github.com/1649788). + */ + return function(expression, locals) { + var instance, match, constructor, identifier; + + if(isString(expression)) { + match = expression.match(CNTRL_REG), + constructor = match[1], + identifier = match[3]; + expression = controllers.hasOwnProperty(constructor) + ? controllers[constructor] + : getter(locals.$scope, constructor, true) || getter($window, constructor, true); + + assertArgFn(expression, constructor, true); + } + + instance = $injector.instantiate(expression, locals); + + if (identifier) { + if (!(locals && typeof locals.$scope === 'object')) { + throw minErr('$controller')('noscp', + "Cannot export controller '{0}' as '{1}'! No $scope object provided via `locals`.", + constructor || expression.name, identifier); + } + + locals.$scope[identifier] = instance; + } + + return instance; + }; + }]; +} + +/** + * @ngdoc service + * @name $document + * @requires $window + * + * @description + * A {@link angular.element jQuery or jqLite} wrapper for the browser's `window.document` object. + * + * @example + + +
+

$document title:

+

window.document title:

+
+
+ + angular.module('documentExample', []) + .controller('ExampleController', ['$scope', '$document', function($scope, $document) { + $scope.title = $document[0].title; + $scope.windowTitle = angular.element(window.document)[0].title; + }]); + +
+ */ +function $DocumentProvider(){ + this.$get = ['$window', function(window){ + return jqLite(window.document); + }]; +} + +/** + * @ngdoc service + * @name $exceptionHandler + * @requires ng.$log + * + * @description + * Any uncaught exception in angular expressions is delegated to this service. + * The default implementation simply delegates to `$log.error` which logs it into + * the browser console. + * + * In unit tests, if `angular-mocks.js` is loaded, this service is overridden by + * {@link ngMock.$exceptionHandler mock $exceptionHandler} which aids in testing. + * + * ## Example: + * + * ```js + * angular.module('exceptionOverride', []).factory('$exceptionHandler', function () { + * return function (exception, cause) { + * exception.message += ' (caused by "' + cause + '")'; + * throw exception; + * }; + * }); + * ``` + * + * This example will override the normal action of `$exceptionHandler`, to make angular + * exceptions fail hard when they happen, instead of just logging to the console. + * + * @param {Error} exception Exception associated with the error. + * @param {string=} cause optional information about the context in which + * the error was thrown. + * + */ +function $ExceptionHandlerProvider() { + this.$get = ['$log', function($log) { + return function(exception, cause) { + $log.error.apply($log, arguments); + }; + }]; +} + +/** + * Parse headers into key value object + * + * @param {string} headers Raw headers as a string + * @returns {Object} Parsed headers as key value object + */ +function parseHeaders(headers) { + var parsed = {}, key, val, i; + + if (!headers) return parsed; + + forEach(headers.split('\n'), function(line) { + i = line.indexOf(':'); + key = lowercase(trim(line.substr(0, i))); + val = trim(line.substr(i + 1)); + + if (key) { + if (parsed[key]) { + parsed[key] += ', ' + val; + } else { + parsed[key] = val; + } + } + }); + + return parsed; +} + + +/** + * Returns a function that provides access to parsed headers. + * + * Headers are lazy parsed when first requested. + * @see parseHeaders + * + * @param {(string|Object)} headers Headers to provide access to. + * @returns {function(string=)} Returns a getter function which if called with: + * + * - if called with single an argument returns a single header value or null + * - if called with no arguments returns an object containing all headers. + */ +function headersGetter(headers) { + var headersObj = isObject(headers) ? headers : undefined; + + return function(name) { + if (!headersObj) headersObj = parseHeaders(headers); + + if (name) { + return headersObj[lowercase(name)] || null; + } + + return headersObj; + }; +} + + +/** + * Chain all given functions + * + * This function is used for both request and response transforming + * + * @param {*} data Data to transform. + * @param {function(string=)} headers Http headers getter fn. + * @param {(Function|Array.)} fns Function or an array of functions. + * @returns {*} Transformed data. + */ +function transformData(data, headers, fns) { + if (isFunction(fns)) + return fns(data, headers); + + forEach(fns, function(fn) { + data = fn(data, headers); + }); + + return data; +} + + +function isSuccess(status) { + return 200 <= status && status < 300; +} + + +/** + * @ngdoc provider + * @name $httpProvider + * @description + * Use `$httpProvider` to change the default behavior of the {@link ng.$http $http} service. + * */ +function $HttpProvider() { + var JSON_START = /^\s*(\[|\{[^\{])/, + JSON_END = /[\}\]]\s*$/, + PROTECTION_PREFIX = /^\)\]\}',?\n/, + CONTENT_TYPE_APPLICATION_JSON = {'Content-Type': 'application/json;charset=utf-8'}; + + /** + * @ngdoc property + * @name $httpProvider#defaults + * @description + * + * Object containing default values for all {@link ng.$http $http} requests. + * + * - **`defaults.xsrfCookieName`** - {string} - Name of cookie containing the XSRF token. + * Defaults value is `'XSRF-TOKEN'`. + * + * - **`defaults.xsrfHeaderName`** - {string} - Name of HTTP header to populate with the + * XSRF token. Defaults value is `'X-XSRF-TOKEN'`. + * + * - **`defaults.headers`** - {Object} - Default headers for all $http requests. + * Refer to {@link ng.$http#setting-http-headers $http} for documentation on + * setting default headers. + * - **`defaults.headers.common`** + * - **`defaults.headers.post`** + * - **`defaults.headers.put`** + * - **`defaults.headers.patch`** + **/ + var defaults = this.defaults = { + // transform incoming response data + transformResponse: [function(data) { + if (isString(data)) { + // strip json vulnerability protection prefix + data = data.replace(PROTECTION_PREFIX, ''); + if (JSON_START.test(data) && JSON_END.test(data)) + data = fromJson(data); + } + return data; + }], + + // transform outgoing request data + transformRequest: [function(d) { + return isObject(d) && !isFile(d) && !isBlob(d) ? toJson(d) : d; + }], + + // default headers + headers: { + common: { + 'Accept': 'application/json, text/plain, */*' + }, + post: shallowCopy(CONTENT_TYPE_APPLICATION_JSON), + put: shallowCopy(CONTENT_TYPE_APPLICATION_JSON), + patch: shallowCopy(CONTENT_TYPE_APPLICATION_JSON) + }, + + xsrfCookieName: 'XSRF-TOKEN', + xsrfHeaderName: 'X-XSRF-TOKEN' + }; + + /** + * Are ordered by request, i.e. they are applied in the same order as the + * array, on request, but reverse order, on response. + */ + var interceptorFactories = this.interceptors = []; + + /** + * For historical reasons, response interceptors are ordered by the order in which + * they are applied to the response. (This is the opposite of interceptorFactories) + */ + var responseInterceptorFactories = this.responseInterceptors = []; + + this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', + function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { + + var defaultCache = $cacheFactory('$http'); + + /** + * Interceptors stored in reverse order. Inner interceptors before outer interceptors. + * The reversal is needed so that we can build up the interception chain around the + * server request. + */ + var reversedInterceptors = []; + + forEach(interceptorFactories, function(interceptorFactory) { + reversedInterceptors.unshift(isString(interceptorFactory) + ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory)); + }); + + forEach(responseInterceptorFactories, function(interceptorFactory, index) { + var responseFn = isString(interceptorFactory) + ? $injector.get(interceptorFactory) + : $injector.invoke(interceptorFactory); + + /** + * Response interceptors go before "around" interceptors (no real reason, just + * had to pick one.) But they are already reversed, so we can't use unshift, hence + * the splice. + */ + reversedInterceptors.splice(index, 0, { + response: function(response) { + return responseFn($q.when(response)); + }, + responseError: function(response) { + return responseFn($q.reject(response)); + } + }); + }); + + + /** + * @ngdoc service + * @kind function + * @name $http + * @requires ng.$httpBackend + * @requires $cacheFactory + * @requires $rootScope + * @requires $q + * @requires $injector + * + * @description + * The `$http` service is a core Angular service that facilitates communication with the remote + * HTTP servers via the browser's [XMLHttpRequest](https://developer.mozilla.org/en/xmlhttprequest) + * object or via [JSONP](http://en.wikipedia.org/wiki/JSONP). + * + * For unit testing applications that use `$http` service, see + * {@link ngMock.$httpBackend $httpBackend mock}. + * + * For a higher level of abstraction, please check out the {@link ngResource.$resource + * $resource} service. + * + * The $http API is based on the {@link ng.$q deferred/promise APIs} exposed by + * the $q service. While for simple usage patterns this doesn't matter much, for advanced usage + * it is important to familiarize yourself with these APIs and the guarantees they provide. + * + * + * # General usage + * The `$http` service is a function which takes a single argument — a configuration object — + * that is used to generate an HTTP request and returns a {@link ng.$q promise} + * with two $http specific methods: `success` and `error`. + * + * ```js + * $http({method: 'GET', url: '/someUrl'}). + * success(function(data, status, headers, config) { + * // this callback will be called asynchronously + * // when the response is available + * }). + * error(function(data, status, headers, config) { + * // called asynchronously if an error occurs + * // or server returns response with an error status. + * }); + * ``` + * + * Since the returned value of calling the $http function is a `promise`, you can also use + * the `then` method to register callbacks, and these callbacks will receive a single argument – + * an object representing the response. See the API signature and type info below for more + * details. + * + * A response status code between 200 and 299 is considered a success status and + * will result in the success callback being called. Note that if the response is a redirect, + * XMLHttpRequest will transparently follow it, meaning that the error callback will not be + * called for such responses. + * + * # Writing Unit Tests that use $http + * When unit testing (using {@link ngMock ngMock}), it is necessary to call + * {@link ngMock.$httpBackend#flush $httpBackend.flush()} to flush each pending + * request using trained responses. + * + * ``` + * $httpBackend.expectGET(...); + * $http.get(...); + * $httpBackend.flush(); + * ``` + * + * # Shortcut methods + * + * Shortcut methods are also available. All shortcut methods require passing in the URL, and + * request data must be passed in for POST/PUT requests. + * + * ```js + * $http.get('/someUrl').success(successCallback); + * $http.post('/someUrl', data).success(successCallback); + * ``` + * + * Complete list of shortcut methods: + * + * - {@link ng.$http#get $http.get} + * - {@link ng.$http#head $http.head} + * - {@link ng.$http#post $http.post} + * - {@link ng.$http#put $http.put} + * - {@link ng.$http#delete $http.delete} + * - {@link ng.$http#jsonp $http.jsonp} + * + * + * # Setting HTTP Headers + * + * The $http service will automatically add certain HTTP headers to all requests. These defaults + * can be fully configured by accessing the `$httpProvider.defaults.headers` configuration + * object, which currently contains this default configuration: + * + * - `$httpProvider.defaults.headers.common` (headers that are common for all requests): + * - `Accept: application/json, text/plain, * / *` + * - `$httpProvider.defaults.headers.post`: (header defaults for POST requests) + * - `Content-Type: application/json` + * - `$httpProvider.defaults.headers.put` (header defaults for PUT requests) + * - `Content-Type: application/json` + * + * To add or overwrite these defaults, simply add or remove a property from these configuration + * objects. To add headers for an HTTP method other than POST or PUT, simply add a new object + * with the lowercased HTTP method name as the key, e.g. + * `$httpProvider.defaults.headers.get = { 'My-Header' : 'value' }. + * + * The defaults can also be set at runtime via the `$http.defaults` object in the same + * fashion. For example: + * + * ``` + * module.run(function($http) { + * $http.defaults.headers.common.Authorization = 'Basic YmVlcDpib29w' + * }); + * ``` + * + * In addition, you can supply a `headers` property in the config object passed when + * calling `$http(config)`, which overrides the defaults without changing them globally. + * + * + * # Transforming Requests and Responses + * + * Both requests and responses can be transformed using transform functions. By default, Angular + * applies these transformations: + * + * Request transformations: + * + * - If the `data` property of the request configuration object contains an object, serialize it + * into JSON format. + * + * Response transformations: + * + * - If XSRF prefix is detected, strip it (see Security Considerations section below). + * - If JSON response is detected, deserialize it using a JSON parser. + * + * To globally augment or override the default transforms, modify the + * `$httpProvider.defaults.transformRequest` and `$httpProvider.defaults.transformResponse` + * properties. These properties are by default an array of transform functions, which allows you + * to `push` or `unshift` a new transformation function into the transformation chain. You can + * also decide to completely override any default transformations by assigning your + * transformation functions to these properties directly without the array wrapper. These defaults + * are again available on the $http factory at run-time, which may be useful if you have run-time + * services you wish to be involved in your transformations. + * + * Similarly, to locally override the request/response transforms, augment the + * `transformRequest` and/or `transformResponse` properties of the configuration object passed + * into `$http`. + * + * + * # Caching + * + * To enable caching, set the request configuration `cache` property to `true` (to use default + * cache) or to a custom cache object (built with {@link ng.$cacheFactory `$cacheFactory`}). + * When the cache is enabled, `$http` stores the response from the server in the specified + * cache. The next time the same request is made, the response is served from the cache without + * sending a request to the server. + * + * Note that even if the response is served from cache, delivery of the data is asynchronous in + * the same way that real requests are. + * + * If there are multiple GET requests for the same URL that should be cached using the same + * cache, but the cache is not populated yet, only one request to the server will be made and + * the remaining requests will be fulfilled using the response from the first request. + * + * You can change the default cache to a new object (built with + * {@link ng.$cacheFactory `$cacheFactory`}) by updating the + * {@link ng.$http#properties_defaults `$http.defaults.cache`} property. All requests who set + * their `cache` property to `true` will now use this cache object. + * + * If you set the default cache to `false` then only requests that specify their own custom + * cache object will be cached. + * + * # Interceptors + * + * Before you start creating interceptors, be sure to understand the + * {@link ng.$q $q and deferred/promise APIs}. + * + * For purposes of global error handling, authentication, or any kind of synchronous or + * asynchronous pre-processing of request or postprocessing of responses, it is desirable to be + * able to intercept requests before they are handed to the server and + * responses before they are handed over to the application code that + * initiated these requests. The interceptors leverage the {@link ng.$q + * promise APIs} to fulfill this need for both synchronous and asynchronous pre-processing. + * + * The interceptors are service factories that are registered with the `$httpProvider` by + * adding them to the `$httpProvider.interceptors` array. The factory is called and + * injected with dependencies (if specified) and returns the interceptor. + * + * There are two kinds of interceptors (and two kinds of rejection interceptors): + * + * * `request`: interceptors get called with a http `config` object. The function is free to + * modify the `config` object or create a new one. The function needs to return the `config` + * object directly, or a promise containing the `config` or a new `config` object. + * * `requestError`: interceptor gets called when a previous interceptor threw an error or + * resolved with a rejection. + * * `response`: interceptors get called with http `response` object. The function is free to + * modify the `response` object or create a new one. The function needs to return the `response` + * object directly, or as a promise containing the `response` or a new `response` object. + * * `responseError`: interceptor gets called when a previous interceptor threw an error or + * resolved with a rejection. + * + * + * ```js + * // register the interceptor as a service + * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { + * return { + * // optional method + * 'request': function(config) { + * // do something on success + * return config; + * }, + * + * // optional method + * 'requestError': function(rejection) { + * // do something on error + * if (canRecover(rejection)) { + * return responseOrNewPromise + * } + * return $q.reject(rejection); + * }, + * + * + * + * // optional method + * 'response': function(response) { + * // do something on success + * return response; + * }, + * + * // optional method + * 'responseError': function(rejection) { + * // do something on error + * if (canRecover(rejection)) { + * return responseOrNewPromise + * } + * return $q.reject(rejection); + * } + * }; + * }); + * + * $httpProvider.interceptors.push('myHttpInterceptor'); + * + * + * // alternatively, register the interceptor via an anonymous factory + * $httpProvider.interceptors.push(function($q, dependency1, dependency2) { + * return { + * 'request': function(config) { + * // same as above + * }, + * + * 'response': function(response) { + * // same as above + * } + * }; + * }); + * ``` + * + * # Response interceptors (DEPRECATED) + * + * Before you start creating interceptors, be sure to understand the + * {@link ng.$q $q and deferred/promise APIs}. + * + * For purposes of global error handling, authentication or any kind of synchronous or + * asynchronous preprocessing of received responses, it is desirable to be able to intercept + * responses for http requests before they are handed over to the application code that + * initiated these requests. The response interceptors leverage the {@link ng.$q + * promise apis} to fulfil this need for both synchronous and asynchronous preprocessing. + * + * The interceptors are service factories that are registered with the $httpProvider by + * adding them to the `$httpProvider.responseInterceptors` array. The factory is called and + * injected with dependencies (if specified) and returns the interceptor — a function that + * takes a {@link ng.$q promise} and returns the original or a new promise. + * + * ```js + * // register the interceptor as a service + * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { + * return function(promise) { + * return promise.then(function(response) { + * // do something on success + * return response; + * }, function(response) { + * // do something on error + * if (canRecover(response)) { + * return responseOrNewPromise + * } + * return $q.reject(response); + * }); + * } + * }); + * + * $httpProvider.responseInterceptors.push('myHttpInterceptor'); + * + * + * // register the interceptor via an anonymous factory + * $httpProvider.responseInterceptors.push(function($q, dependency1, dependency2) { + * return function(promise) { + * // same as above + * } + * }); + * ``` + * + * + * # Security Considerations + * + * When designing web applications, consider security threats from: + * + * - [JSON vulnerability](http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx) + * - [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) + * + * Both server and the client must cooperate in order to eliminate these threats. Angular comes + * pre-configured with strategies that address these issues, but for this to work backend server + * cooperation is required. + * + * ## JSON Vulnerability Protection + * + * A [JSON vulnerability](http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx) + * allows third party website to turn your JSON resource URL into + * [JSONP](http://en.wikipedia.org/wiki/JSONP) request under some conditions. To + * counter this your server can prefix all JSON requests with following string `")]}',\n"`. + * Angular will automatically strip the prefix before processing it as JSON. + * + * For example if your server needs to return: + * ```js + * ['one','two'] + * ``` + * + * which is vulnerable to attack, your server can return: + * ```js + * )]}', + * ['one','two'] + * ``` + * + * Angular will strip the prefix, before processing the JSON. + * + * + * ## Cross Site Request Forgery (XSRF) Protection + * + * [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) is a technique by which + * an unauthorized site can gain your user's private data. Angular provides a mechanism + * to counter XSRF. When performing XHR requests, the $http service reads a token from a cookie + * (by default, `XSRF-TOKEN`) and sets it as an HTTP header (`X-XSRF-TOKEN`). Since only + * JavaScript that runs on your domain could read the cookie, your server can be assured that + * the XHR came from JavaScript running on your domain. The header will not be set for + * cross-domain requests. + * + * To take advantage of this, your server needs to set a token in a JavaScript readable session + * cookie called `XSRF-TOKEN` on the first HTTP GET request. On subsequent XHR requests the + * server can verify that the cookie matches `X-XSRF-TOKEN` HTTP header, and therefore be sure + * that only JavaScript running on your domain could have sent the request. The token must be + * unique for each user and must be verifiable by the server (to prevent the JavaScript from + * making up its own tokens). We recommend that the token is a digest of your site's + * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) + * for added security. + * + * The name of the headers can be specified using the xsrfHeaderName and xsrfCookieName + * properties of either $httpProvider.defaults at config-time, $http.defaults at run-time, + * or the per-request config object. + * + * + * @param {object} config Object describing the request to be made and how it should be + * processed. The object has following properties: + * + * - **method** – `{string}` – HTTP method (e.g. 'GET', 'POST', etc) + * - **url** – `{string}` – Absolute or relative URL of the resource that is being requested. + * - **params** – `{Object.}` – Map of strings or objects which will be turned + * to `?key1=value1&key2=value2` after the url. If the value is not a string, it will be + * JSONified. + * - **data** – `{string|Object}` – Data to be sent as the request message data. + * - **headers** – `{Object}` – Map of strings or functions which return strings representing + * HTTP headers to send to the server. If the return value of a function is null, the + * header will not be sent. + * - **xsrfHeaderName** – `{string}` – Name of HTTP header to populate with the XSRF token. + * - **xsrfCookieName** – `{string}` – Name of cookie containing the XSRF token. + * - **transformRequest** – + * `{function(data, headersGetter)|Array.}` – + * transform function or an array of such functions. The transform function takes the http + * request body and headers and returns its transformed (typically serialized) version. + * - **transformResponse** – + * `{function(data, headersGetter)|Array.}` – + * transform function or an array of such functions. The transform function takes the http + * response body and headers and returns its transformed (typically deserialized) version. + * - **cache** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the + * GET request, otherwise if a cache instance built with + * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for + * caching. + * - **timeout** – `{number|Promise}` – timeout in milliseconds, or {@link ng.$q promise} + * that should abort the request when resolved. + * - **withCredentials** - `{boolean}` - whether to set the `withCredentials` flag on the + * XHR object. See [requests with credentials]https://developer.mozilla.org/en/http_access_control#section_5 + * for more information. + * - **responseType** - `{string}` - see + * [requestType](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType). + * + * @returns {HttpPromise} Returns a {@link ng.$q promise} object with the + * standard `then` method and two http specific methods: `success` and `error`. The `then` + * method takes two arguments a success and an error callback which will be called with a + * response object. The `success` and `error` methods take a single argument - a function that + * will be called when the request succeeds or fails respectively. The arguments passed into + * these functions are destructured representation of the response object passed into the + * `then` method. The response object has these properties: + * + * - **data** – `{string|Object}` – The response body transformed with the transform + * functions. + * - **status** – `{number}` – HTTP status code of the response. + * - **headers** – `{function([headerName])}` – Header getter function. + * - **config** – `{Object}` – The configuration object that was used to generate the request. + * - **statusText** – `{string}` – HTTP status text of the response. + * + * @property {Array.} pendingRequests Array of config objects for currently pending + * requests. This is primarily meant to be used for debugging purposes. + * + * + * @example + + +
+ + +
+ + + +
http status code: {{status}}
+
http response data: {{data}}
+
+
+ + angular.module('httpExample', []) + .controller('FetchController', ['$scope', '$http', '$templateCache', + function($scope, $http, $templateCache) { + $scope.method = 'GET'; + $scope.url = 'http-hello.html'; + + $scope.fetch = function() { + $scope.code = null; + $scope.response = null; + + $http({method: $scope.method, url: $scope.url, cache: $templateCache}). + success(function(data, status) { + $scope.status = status; + $scope.data = data; + }). + error(function(data, status) { + $scope.data = data || "Request failed"; + $scope.status = status; + }); + }; + + $scope.updateModel = function(method, url) { + $scope.method = method; + $scope.url = url; + }; + }]); + + + Hello, $http! + + + var status = element(by.binding('status')); + var data = element(by.binding('data')); + var fetchBtn = element(by.id('fetchbtn')); + var sampleGetBtn = element(by.id('samplegetbtn')); + var sampleJsonpBtn = element(by.id('samplejsonpbtn')); + var invalidJsonpBtn = element(by.id('invalidjsonpbtn')); + + it('should make an xhr GET request', function() { + sampleGetBtn.click(); + fetchBtn.click(); + expect(status.getText()).toMatch('200'); + expect(data.getText()).toMatch(/Hello, \$http!/); + }); + + it('should make a JSONP request to angularjs.org', function() { + sampleJsonpBtn.click(); + fetchBtn.click(); + expect(status.getText()).toMatch('200'); + expect(data.getText()).toMatch(/Super Hero!/); + }); + + it('should make JSONP request to invalid URL and invoke the error handler', + function() { + invalidJsonpBtn.click(); + fetchBtn.click(); + expect(status.getText()).toMatch('0'); + expect(data.getText()).toMatch('Request failed'); + }); + +
+ */ + function $http(requestConfig) { + var config = { + method: 'get', + transformRequest: defaults.transformRequest, + transformResponse: defaults.transformResponse + }; + var headers = mergeHeaders(requestConfig); + + extend(config, requestConfig); + config.headers = headers; + config.method = uppercase(config.method); + + var serverRequest = function(config) { + headers = config.headers; + var reqData = transformData(config.data, headersGetter(headers), config.transformRequest); + + // strip content-type if data is undefined + if (isUndefined(reqData)) { + forEach(headers, function(value, header) { + if (lowercase(header) === 'content-type') { + delete headers[header]; + } + }); + } + + if (isUndefined(config.withCredentials) && !isUndefined(defaults.withCredentials)) { + config.withCredentials = defaults.withCredentials; + } + + // send request + return sendReq(config, reqData, headers).then(transformResponse, transformResponse); + }; + + var chain = [serverRequest, undefined]; + var promise = $q.when(config); + + // apply interceptors + forEach(reversedInterceptors, function(interceptor) { + if (interceptor.request || interceptor.requestError) { + chain.unshift(interceptor.request, interceptor.requestError); + } + if (interceptor.response || interceptor.responseError) { + chain.push(interceptor.response, interceptor.responseError); + } + }); + + while(chain.length) { + var thenFn = chain.shift(); + var rejectFn = chain.shift(); + + promise = promise.then(thenFn, rejectFn); + } + + promise.success = function(fn) { + promise.then(function(response) { + fn(response.data, response.status, response.headers, config); + }); + return promise; + }; + + promise.error = function(fn) { + promise.then(null, function(response) { + fn(response.data, response.status, response.headers, config); + }); + return promise; + }; + + return promise; + + function transformResponse(response) { + // make a copy since the response must be cacheable + var resp = extend({}, response, { + data: transformData(response.data, response.headers, config.transformResponse) + }); + return (isSuccess(response.status)) + ? resp + : $q.reject(resp); + } + + function mergeHeaders(config) { + var defHeaders = defaults.headers, + reqHeaders = extend({}, config.headers), + defHeaderName, lowercaseDefHeaderName, reqHeaderName; + + defHeaders = extend({}, defHeaders.common, defHeaders[lowercase(config.method)]); + + // using for-in instead of forEach to avoid unecessary iteration after header has been found + defaultHeadersIteration: + for (defHeaderName in defHeaders) { + lowercaseDefHeaderName = lowercase(defHeaderName); + + for (reqHeaderName in reqHeaders) { + if (lowercase(reqHeaderName) === lowercaseDefHeaderName) { + continue defaultHeadersIteration; + } + } + + reqHeaders[defHeaderName] = defHeaders[defHeaderName]; + } + + // execute if header value is a function for merged headers + execHeaders(reqHeaders); + return reqHeaders; + + function execHeaders(headers) { + var headerContent; + + forEach(headers, function(headerFn, header) { + if (isFunction(headerFn)) { + headerContent = headerFn(); + if (headerContent != null) { + headers[header] = headerContent; + } else { + delete headers[header]; + } + } + }); + } + } + } + + $http.pendingRequests = []; + + /** + * @ngdoc method + * @name $http#get + * + * @description + * Shortcut method to perform `GET` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + + /** + * @ngdoc method + * @name $http#delete + * + * @description + * Shortcut method to perform `DELETE` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + + /** + * @ngdoc method + * @name $http#head + * + * @description + * Shortcut method to perform `HEAD` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + + /** + * @ngdoc method + * @name $http#jsonp + * + * @description + * Shortcut method to perform `JSONP` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request. + * Should contain `JSON_CALLBACK` string. + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + createShortMethods('get', 'delete', 'head', 'jsonp'); + + /** + * @ngdoc method + * @name $http#post + * + * @description + * Shortcut method to perform `POST` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {*} data Request content + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + + /** + * @ngdoc method + * @name $http#put + * + * @description + * Shortcut method to perform `PUT` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {*} data Request content + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + createShortMethodsWithData('post', 'put'); + + /** + * @ngdoc property + * @name $http#defaults + * + * @description + * Runtime equivalent of the `$httpProvider.defaults` property. Allows configuration of + * default headers, withCredentials as well as request and response transformations. + * + * See "Setting HTTP Headers" and "Transforming Requests and Responses" sections above. + */ + $http.defaults = defaults; + + + return $http; + + + function createShortMethods(names) { + forEach(arguments, function(name) { + $http[name] = function(url, config) { + return $http(extend(config || {}, { + method: name, + url: url + })); + }; + }); + } + + + function createShortMethodsWithData(name) { + forEach(arguments, function(name) { + $http[name] = function(url, data, config) { + return $http(extend(config || {}, { + method: name, + url: url, + data: data + })); + }; + }); + } + + + /** + * Makes the request. + * + * !!! ACCESSES CLOSURE VARS: + * $httpBackend, defaults, $log, $rootScope, defaultCache, $http.pendingRequests + */ + function sendReq(config, reqData, reqHeaders) { + var deferred = $q.defer(), + promise = deferred.promise, + cache, + cachedResp, + url = buildUrl(config.url, config.params); + + $http.pendingRequests.push(config); + promise.then(removePendingReq, removePendingReq); + + + if ((config.cache || defaults.cache) && config.cache !== false && config.method == 'GET') { + cache = isObject(config.cache) ? config.cache + : isObject(defaults.cache) ? defaults.cache + : defaultCache; + } + + if (cache) { + cachedResp = cache.get(url); + if (isDefined(cachedResp)) { + if (cachedResp.then) { + // cached request has already been sent, but there is no response yet + cachedResp.then(removePendingReq, removePendingReq); + return cachedResp; + } else { + // serving from cache + if (isArray(cachedResp)) { + resolvePromise(cachedResp[1], cachedResp[0], shallowCopy(cachedResp[2]), cachedResp[3]); + } else { + resolvePromise(cachedResp, 200, {}, 'OK'); + } + } + } else { + // put the promise for the non-transformed response into cache as a placeholder + cache.put(url, promise); + } + } + + + // if we won't have the response in cache, set the xsrf headers and + // send the request to the backend + if (isUndefined(cachedResp)) { + var xsrfValue = urlIsSameOrigin(config.url) + ? $browser.cookies()[config.xsrfCookieName || defaults.xsrfCookieName] + : undefined; + if (xsrfValue) { + reqHeaders[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue; + } + + $httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout, + config.withCredentials, config.responseType); + } + + return promise; + + + /** + * Callback registered to $httpBackend(): + * - caches the response if desired + * - resolves the raw $http promise + * - calls $apply + */ + function done(status, response, headersString, statusText) { + if (cache) { + if (isSuccess(status)) { + cache.put(url, [status, response, parseHeaders(headersString), statusText]); + } else { + // remove promise from the cache + cache.remove(url); + } + } + + resolvePromise(response, status, headersString, statusText); + if (!$rootScope.$$phase) $rootScope.$apply(); + } + + + /** + * Resolves the raw $http promise. + */ + function resolvePromise(response, status, headers, statusText) { + // normalize internal statuses to 0 + status = Math.max(status, 0); + + (isSuccess(status) ? deferred.resolve : deferred.reject)({ + data: response, + status: status, + headers: headersGetter(headers), + config: config, + statusText : statusText + }); + } + + + function removePendingReq() { + var idx = indexOf($http.pendingRequests, config); + if (idx !== -1) $http.pendingRequests.splice(idx, 1); + } + } + + + function buildUrl(url, params) { + if (!params) return url; + var parts = []; + forEachSorted(params, function(value, key) { + if (value === null || isUndefined(value)) return; + if (!isArray(value)) value = [value]; + + forEach(value, function(v) { + if (isObject(v)) { + v = toJson(v); + } + parts.push(encodeUriQuery(key) + '=' + + encodeUriQuery(v)); + }); + }); + if(parts.length > 0) { + url += ((url.indexOf('?') == -1) ? '?' : '&') + parts.join('&'); + } + return url; + } + + + }]; +} + +function createXhr(method) { + //if IE and the method is not RFC2616 compliant, or if XMLHttpRequest + //is not available, try getting an ActiveXObject. Otherwise, use XMLHttpRequest + //if it is available + if (msie <= 8 && (!method.match(/^(get|post|head|put|delete|options)$/i) || + !window.XMLHttpRequest)) { + return new window.ActiveXObject("Microsoft.XMLHTTP"); + } else if (window.XMLHttpRequest) { + return new window.XMLHttpRequest(); + } + + throw minErr('$httpBackend')('noxhr', "This browser does not support XMLHttpRequest."); +} + +/** + * @ngdoc service + * @name $httpBackend + * @requires $window + * @requires $document + * + * @description + * HTTP backend used by the {@link ng.$http service} that delegates to + * XMLHttpRequest object or JSONP and deals with browser incompatibilities. + * + * You should never need to use this service directly, instead use the higher-level abstractions: + * {@link ng.$http $http} or {@link ngResource.$resource $resource}. + * + * During testing this implementation is swapped with {@link ngMock.$httpBackend mock + * $httpBackend} which can be trained with responses. + */ +function $HttpBackendProvider() { + this.$get = ['$browser', '$window', '$document', function($browser, $window, $document) { + return createHttpBackend($browser, createXhr, $browser.defer, $window.angular.callbacks, $document[0]); + }]; +} + +function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDocument) { + var ABORTED = -1; + + // TODO(vojta): fix the signature + return function(method, url, post, callback, headers, timeout, withCredentials, responseType) { + var status; + $browser.$$incOutstandingRequestCount(); + url = url || $browser.url(); + + if (lowercase(method) == 'jsonp') { + var callbackId = '_' + (callbacks.counter++).toString(36); + callbacks[callbackId] = function(data) { + callbacks[callbackId].data = data; + callbacks[callbackId].called = true; + }; + + var jsonpDone = jsonpReq(url.replace('JSON_CALLBACK', 'angular.callbacks.' + callbackId), + callbackId, function(status, text) { + completeRequest(callback, status, callbacks[callbackId].data, "", text); + callbacks[callbackId] = noop; + }); + } else { + + var xhr = createXhr(method); + + xhr.open(method, url, true); + forEach(headers, function(value, key) { + if (isDefined(value)) { + xhr.setRequestHeader(key, value); + } + }); + + // In IE6 and 7, this might be called synchronously when xhr.send below is called and the + // response is in the cache. the promise api will ensure that to the app code the api is + // always async + xhr.onreadystatechange = function() { + // onreadystatechange might get called multiple times with readyState === 4 on mobile webkit caused by + // xhrs that are resolved while the app is in the background (see #5426). + // since calling completeRequest sets the `xhr` variable to null, we just check if it's not null before + // continuing + // + // we can't set xhr.onreadystatechange to undefined or delete it because that breaks IE8 (method=PATCH) and + // Safari respectively. + if (xhr && xhr.readyState == 4) { + var responseHeaders = null, + response = null, + statusText = ''; + + if(status !== ABORTED) { + responseHeaders = xhr.getAllResponseHeaders(); + + // responseText is the old-school way of retrieving response (supported by IE8 & 9) + // response/responseType properties were introduced in XHR Level2 spec (supported by IE10) + response = ('response' in xhr) ? xhr.response : xhr.responseText; + } + + // Accessing statusText on an aborted xhr object will + // throw an 'c00c023f error' in IE9 and lower, don't touch it. + if (!(status === ABORTED && msie < 10)) { + statusText = xhr.statusText; + } + + completeRequest(callback, + status || xhr.status, + response, + responseHeaders, + statusText); + } + }; + + if (withCredentials) { + xhr.withCredentials = true; + } + + if (responseType) { + try { + xhr.responseType = responseType; + } catch (e) { + // WebKit added support for the json responseType value on 09/03/2013 + // https://bugs.webkit.org/show_bug.cgi?id=73648. Versions of Safari prior to 7 are + // known to throw when setting the value "json" as the response type. Other older + // browsers implementing the responseType + // + // The json response type can be ignored if not supported, because JSON payloads are + // parsed on the client-side regardless. + if (responseType !== 'json') { + throw e; + } + } + } + + xhr.send(post || null); + } + + if (timeout > 0) { + var timeoutId = $browserDefer(timeoutRequest, timeout); + } else if (timeout && timeout.then) { + timeout.then(timeoutRequest); + } + + + function timeoutRequest() { + status = ABORTED; + jsonpDone && jsonpDone(); + xhr && xhr.abort(); + } + + function completeRequest(callback, status, response, headersString, statusText) { + // cancel timeout and subsequent timeout promise resolution + timeoutId && $browserDefer.cancel(timeoutId); + jsonpDone = xhr = null; + + // fix status code when it is 0 (0 status is undocumented). + // Occurs when accessing file resources or on Android 4.1 stock browser + // while retrieving files from application cache. + if (status === 0) { + status = response ? 200 : urlResolve(url).protocol == 'file' ? 404 : 0; + } + + // normalize IE bug (http://bugs.jquery.com/ticket/1450) + status = status === 1223 ? 204 : status; + statusText = statusText || ''; + + callback(status, response, headersString, statusText); + $browser.$$completeOutstandingRequest(noop); + } + }; + + function jsonpReq(url, callbackId, done) { + // we can't use jQuery/jqLite here because jQuery does crazy shit with script elements, e.g.: + // - fetches local scripts via XHR and evals them + // - adds and immediately removes script elements from the document + var script = rawDocument.createElement('script'), callback = null; + script.type = "text/javascript"; + script.src = url; + script.async = true; + + callback = function(event) { + removeEventListenerFn(script, "load", callback); + removeEventListenerFn(script, "error", callback); + rawDocument.body.removeChild(script); + script = null; + var status = -1; + var text = "unknown"; + + if (event) { + if (event.type === "load" && !callbacks[callbackId].called) { + event = { type: "error" }; + } + text = event.type; + status = event.type === "error" ? 404 : 200; + } + + if (done) { + done(status, text); + } + }; + + addEventListenerFn(script, "load", callback); + addEventListenerFn(script, "error", callback); + + if (msie <= 8) { + script.onreadystatechange = function() { + if (isString(script.readyState) && /loaded|complete/.test(script.readyState)) { + script.onreadystatechange = null; + callback({ + type: 'load' + }); + } + }; + } + + rawDocument.body.appendChild(script); + return callback; + } +} + +var $interpolateMinErr = minErr('$interpolate'); + +/** + * @ngdoc provider + * @name $interpolateProvider + * @kind function + * + * @description + * + * Used for configuring the interpolation markup. Defaults to `{{` and `}}`. + * + * @example + + + +
+ //demo.label// +
+
+ + it('should interpolate binding with custom symbols', function() { + expect(element(by.binding('demo.label')).getText()).toBe('This binding is brought you by // interpolation symbols.'); + }); + +
+ */ +function $InterpolateProvider() { + var startSymbol = '{{'; + var endSymbol = '}}'; + + /** + * @ngdoc method + * @name $interpolateProvider#startSymbol + * @description + * Symbol to denote start of expression in the interpolated string. Defaults to `{{`. + * + * @param {string=} value new value to set the starting symbol to. + * @returns {string|self} Returns the symbol when used as getter and self if used as setter. + */ + this.startSymbol = function(value){ + if (value) { + startSymbol = value; + return this; + } else { + return startSymbol; + } + }; + + /** + * @ngdoc method + * @name $interpolateProvider#endSymbol + * @description + * Symbol to denote the end of expression in the interpolated string. Defaults to `}}`. + * + * @param {string=} value new value to set the ending symbol to. + * @returns {string|self} Returns the symbol when used as getter and self if used as setter. + */ + this.endSymbol = function(value){ + if (value) { + endSymbol = value; + return this; + } else { + return endSymbol; + } + }; + + + this.$get = ['$parse', '$exceptionHandler', '$sce', function($parse, $exceptionHandler, $sce) { + var startSymbolLength = startSymbol.length, + endSymbolLength = endSymbol.length; + + /** + * @ngdoc service + * @name $interpolate + * @kind function + * + * @requires $parse + * @requires $sce + * + * @description + * + * Compiles a string with markup into an interpolation function. This service is used by the + * HTML {@link ng.$compile $compile} service for data binding. See + * {@link ng.$interpolateProvider $interpolateProvider} for configuring the + * interpolation markup. + * + * + * ```js + * var $interpolate = ...; // injected + * var exp = $interpolate('Hello {{name | uppercase}}!'); + * expect(exp({name:'Angular'}).toEqual('Hello ANGULAR!'); + * ``` + * + * + * @param {string} text The text with markup to interpolate. + * @param {boolean=} mustHaveExpression if set to true then the interpolation string must have + * embedded expression in order to return an interpolation function. Strings with no + * embedded expression will return null for the interpolation function. + * @param {string=} trustedContext when provided, the returned function passes the interpolated + * result through {@link ng.$sce#getTrusted $sce.getTrusted(interpolatedResult, + * trustedContext)} before returning it. Refer to the {@link ng.$sce $sce} service that + * provides Strict Contextual Escaping for details. + * @returns {function(context)} an interpolation function which is used to compute the + * interpolated string. The function has these parameters: + * + * * `context`: an object against which any expressions embedded in the strings are evaluated + * against. + * + */ + function $interpolate(text, mustHaveExpression, trustedContext) { + var startIndex, + endIndex, + index = 0, + parts = [], + length = text.length, + hasInterpolation = false, + fn, + exp, + concat = []; + + while(index < length) { + if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) && + ((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) != -1) ) { + (index != startIndex) && parts.push(text.substring(index, startIndex)); + parts.push(fn = $parse(exp = text.substring(startIndex + startSymbolLength, endIndex))); + fn.exp = exp; + index = endIndex + endSymbolLength; + hasInterpolation = true; + } else { + // we did not find anything, so we have to add the remainder to the parts array + (index != length) && parts.push(text.substring(index)); + index = length; + } + } + + if (!(length = parts.length)) { + // we added, nothing, must have been an empty string. + parts.push(''); + length = 1; + } + + // Concatenating expressions makes it hard to reason about whether some combination of + // concatenated values are unsafe to use and could easily lead to XSS. By requiring that a + // single expression be used for iframe[src], object[src], etc., we ensure that the value + // that's used is assigned or constructed by some JS code somewhere that is more testable or + // make it obvious that you bound the value to some user controlled value. This helps reduce + // the load when auditing for XSS issues. + if (trustedContext && parts.length > 1) { + throw $interpolateMinErr('noconcat', + "Error while interpolating: {0}\nStrict Contextual Escaping disallows " + + "interpolations that concatenate multiple expressions when a trusted value is " + + "required. See http://docs.angularjs.org/api/ng.$sce", text); + } + + if (!mustHaveExpression || hasInterpolation) { + concat.length = length; + fn = function(context) { + try { + for(var i = 0, ii = length, part; i + * **Note**: Intervals created by this service must be explicitly destroyed when you are finished + * with them. In particular they are not automatically destroyed when a controller's scope or a + * directive's element are destroyed. + * You should take this into consideration and make sure to always cancel the interval at the + * appropriate moment. See the example below for more details on how and when to do this. + * + * + * @param {function()} fn A function that should be called repeatedly. + * @param {number} delay Number of milliseconds between each function call. + * @param {number=} [count=0] Number of times to repeat. If not set, or 0, will repeat + * indefinitely. + * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise + * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. + * @returns {promise} A promise which will be notified on each iteration. + * + * @example + * + * + * + * + *
+ *
+ * Date format:
+ * Current time is: + *
+ * Blood 1 : {{blood_1}} + * Blood 2 : {{blood_2}} + * + * + * + *
+ *
+ * + *
+ *
+ */ + function interval(fn, delay, count, invokeApply) { + var setInterval = $window.setInterval, + clearInterval = $window.clearInterval, + deferred = $q.defer(), + promise = deferred.promise, + iteration = 0, + skipApply = (isDefined(invokeApply) && !invokeApply); + + count = isDefined(count) ? count : 0; + + promise.then(null, null, fn); + + promise.$$intervalId = setInterval(function tick() { + deferred.notify(iteration++); + + if (count > 0 && iteration >= count) { + deferred.resolve(iteration); + clearInterval(promise.$$intervalId); + delete intervals[promise.$$intervalId]; + } + + if (!skipApply) $rootScope.$apply(); + + }, delay); + + intervals[promise.$$intervalId] = deferred; + + return promise; + } + + + /** + * @ngdoc method + * @name $interval#cancel + * + * @description + * Cancels a task associated with the `promise`. + * + * @param {promise} promise returned by the `$interval` function. + * @returns {boolean} Returns `true` if the task was successfully canceled. + */ + interval.cancel = function(promise) { + if (promise && promise.$$intervalId in intervals) { + intervals[promise.$$intervalId].reject('canceled'); + $window.clearInterval(promise.$$intervalId); + delete intervals[promise.$$intervalId]; + return true; + } + return false; + }; + + return interval; + }]; +} + +/** + * @ngdoc service + * @name $locale + * + * @description + * $locale service provides localization rules for various Angular components. As of right now the + * only public api is: + * + * * `id` – `{string}` – locale id formatted as `languageId-countryId` (e.g. `en-us`) + */ +function $LocaleProvider(){ + this.$get = function() { + return { + id: 'en-us', + + NUMBER_FORMATS: { + DECIMAL_SEP: '.', + GROUP_SEP: ',', + PATTERNS: [ + { // Decimal Pattern + minInt: 1, + minFrac: 0, + maxFrac: 3, + posPre: '', + posSuf: '', + negPre: '-', + negSuf: '', + gSize: 3, + lgSize: 3 + },{ //Currency Pattern + minInt: 1, + minFrac: 2, + maxFrac: 2, + posPre: '\u00A4', + posSuf: '', + negPre: '(\u00A4', + negSuf: ')', + gSize: 3, + lgSize: 3 + } + ], + CURRENCY_SYM: '$' + }, + + DATETIME_FORMATS: { + MONTH: + 'January,February,March,April,May,June,July,August,September,October,November,December' + .split(','), + SHORTMONTH: 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'.split(','), + DAY: 'Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday'.split(','), + SHORTDAY: 'Sun,Mon,Tue,Wed,Thu,Fri,Sat'.split(','), + AMPMS: ['AM','PM'], + medium: 'MMM d, y h:mm:ss a', + short: 'M/d/yy h:mm a', + fullDate: 'EEEE, MMMM d, y', + longDate: 'MMMM d, y', + mediumDate: 'MMM d, y', + shortDate: 'M/d/yy', + mediumTime: 'h:mm:ss a', + shortTime: 'h:mm a' + }, + + pluralCat: function(num) { + if (num === 1) { + return 'one'; + } + return 'other'; + } + }; + }; +} + +var PATH_MATCH = /^([^\?#]*)(\?([^#]*))?(#(.*))?$/, + DEFAULT_PORTS = {'http': 80, 'https': 443, 'ftp': 21}; +var $locationMinErr = minErr('$location'); + + +/** + * Encode path using encodeUriSegment, ignoring forward slashes + * + * @param {string} path Path to encode + * @returns {string} + */ +function encodePath(path) { + var segments = path.split('/'), + i = segments.length; + + while (i--) { + segments[i] = encodeUriSegment(segments[i]); + } + + return segments.join('/'); +} + +function parseAbsoluteUrl(absoluteUrl, locationObj, appBase) { + var parsedUrl = urlResolve(absoluteUrl, appBase); + + locationObj.$$protocol = parsedUrl.protocol; + locationObj.$$host = parsedUrl.hostname; + locationObj.$$port = int(parsedUrl.port) || DEFAULT_PORTS[parsedUrl.protocol] || null; +} + + +function parseAppUrl(relativeUrl, locationObj, appBase) { + var prefixed = (relativeUrl.charAt(0) !== '/'); + if (prefixed) { + relativeUrl = '/' + relativeUrl; + } + var match = urlResolve(relativeUrl, appBase); + locationObj.$$path = decodeURIComponent(prefixed && match.pathname.charAt(0) === '/' ? + match.pathname.substring(1) : match.pathname); + locationObj.$$search = parseKeyValue(match.search); + locationObj.$$hash = decodeURIComponent(match.hash); + + // make sure path starts with '/'; + if (locationObj.$$path && locationObj.$$path.charAt(0) != '/') { + locationObj.$$path = '/' + locationObj.$$path; + } +} + + +/** + * + * @param {string} begin + * @param {string} whole + * @returns {string} returns text from whole after begin or undefined if it does not begin with + * expected string. + */ +function beginsWith(begin, whole) { + if (whole.indexOf(begin) === 0) { + return whole.substr(begin.length); + } +} + + +function stripHash(url) { + var index = url.indexOf('#'); + return index == -1 ? url : url.substr(0, index); +} + + +function stripFile(url) { + return url.substr(0, stripHash(url).lastIndexOf('/') + 1); +} + +/* return the server only (scheme://host:port) */ +function serverBase(url) { + return url.substring(0, url.indexOf('/', url.indexOf('//') + 2)); +} + + +/** + * LocationHtml5Url represents an url + * This object is exposed as $location service when HTML5 mode is enabled and supported + * + * @constructor + * @param {string} appBase application base URL + * @param {string} basePrefix url path prefix + */ +function LocationHtml5Url(appBase, basePrefix) { + this.$$html5 = true; + basePrefix = basePrefix || ''; + var appBaseNoFile = stripFile(appBase); + parseAbsoluteUrl(appBase, this, appBase); + + + /** + * Parse given html5 (regular) url string into properties + * @param {string} newAbsoluteUrl HTML5 url + * @private + */ + this.$$parse = function(url) { + var pathUrl = beginsWith(appBaseNoFile, url); + if (!isString(pathUrl)) { + throw $locationMinErr('ipthprfx', 'Invalid url "{0}", missing path prefix "{1}".', url, + appBaseNoFile); + } + + parseAppUrl(pathUrl, this, appBase); + + if (!this.$$path) { + this.$$path = '/'; + } + + this.$$compose(); + }; + + /** + * Compose url and update `absUrl` property + * @private + */ + this.$$compose = function() { + var search = toKeyValue(this.$$search), + hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; + + this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + this.$$absUrl = appBaseNoFile + this.$$url.substr(1); // first char is always '/' + }; + + this.$$rewrite = function(url) { + var appUrl, prevAppUrl; + + if ( (appUrl = beginsWith(appBase, url)) !== undefined ) { + prevAppUrl = appUrl; + if ( (appUrl = beginsWith(basePrefix, appUrl)) !== undefined ) { + return appBaseNoFile + (beginsWith('/', appUrl) || appUrl); + } else { + return appBase + prevAppUrl; + } + } else if ( (appUrl = beginsWith(appBaseNoFile, url)) !== undefined ) { + return appBaseNoFile + appUrl; + } else if (appBaseNoFile == url + '/') { + return appBaseNoFile; + } + }; +} + + +/** + * LocationHashbangUrl represents url + * This object is exposed as $location service when developer doesn't opt into html5 mode. + * It also serves as the base class for html5 mode fallback on legacy browsers. + * + * @constructor + * @param {string} appBase application base URL + * @param {string} hashPrefix hashbang prefix + */ +function LocationHashbangUrl(appBase, hashPrefix) { + var appBaseNoFile = stripFile(appBase); + + parseAbsoluteUrl(appBase, this, appBase); + + + /** + * Parse given hashbang url into properties + * @param {string} url Hashbang url + * @private + */ + this.$$parse = function(url) { + var withoutBaseUrl = beginsWith(appBase, url) || beginsWith(appBaseNoFile, url); + var withoutHashUrl = withoutBaseUrl.charAt(0) == '#' + ? beginsWith(hashPrefix, withoutBaseUrl) + : (this.$$html5) + ? withoutBaseUrl + : ''; + + if (!isString(withoutHashUrl)) { + throw $locationMinErr('ihshprfx', 'Invalid url "{0}", missing hash prefix "{1}".', url, + hashPrefix); + } + parseAppUrl(withoutHashUrl, this, appBase); + + this.$$path = removeWindowsDriveName(this.$$path, withoutHashUrl, appBase); + + this.$$compose(); + + /* + * In Windows, on an anchor node on documents loaded from + * the filesystem, the browser will return a pathname + * prefixed with the drive name ('/C:/path') when a + * pathname without a drive is set: + * * a.setAttribute('href', '/foo') + * * a.pathname === '/C:/foo' //true + * + * Inside of Angular, we're always using pathnames that + * do not include drive names for routing. + */ + function removeWindowsDriveName (path, url, base) { + /* + Matches paths for file protocol on windows, + such as /C:/foo/bar, and captures only /foo/bar. + */ + var windowsFilePathExp = /^\/[A-Z]:(\/.*)/; + + var firstPathSegmentMatch; + + //Get the relative path from the input URL. + if (url.indexOf(base) === 0) { + url = url.replace(base, ''); + } + + // The input URL intentionally contains a first path segment that ends with a colon. + if (windowsFilePathExp.exec(url)) { + return path; + } + + firstPathSegmentMatch = windowsFilePathExp.exec(path); + return firstPathSegmentMatch ? firstPathSegmentMatch[1] : path; + } + }; + + /** + * Compose hashbang url and update `absUrl` property + * @private + */ + this.$$compose = function() { + var search = toKeyValue(this.$$search), + hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; + + this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + this.$$absUrl = appBase + (this.$$url ? hashPrefix + this.$$url : ''); + }; + + this.$$rewrite = function(url) { + if(stripHash(appBase) == stripHash(url)) { + return url; + } + }; +} + + +/** + * LocationHashbangUrl represents url + * This object is exposed as $location service when html5 history api is enabled but the browser + * does not support it. + * + * @constructor + * @param {string} appBase application base URL + * @param {string} hashPrefix hashbang prefix + */ +function LocationHashbangInHtml5Url(appBase, hashPrefix) { + this.$$html5 = true; + LocationHashbangUrl.apply(this, arguments); + + var appBaseNoFile = stripFile(appBase); + + this.$$rewrite = function(url) { + var appUrl; + + if ( appBase == stripHash(url) ) { + return url; + } else if ( (appUrl = beginsWith(appBaseNoFile, url)) ) { + return appBase + hashPrefix + appUrl; + } else if ( appBaseNoFile === url + '/') { + return appBaseNoFile; + } + }; + + this.$$compose = function() { + var search = toKeyValue(this.$$search), + hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; + + this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + // include hashPrefix in $$absUrl when $$url is empty so IE8 & 9 do not reload page because of removal of '#' + this.$$absUrl = appBase + hashPrefix + this.$$url; + }; + +} + + +LocationHashbangInHtml5Url.prototype = + LocationHashbangUrl.prototype = + LocationHtml5Url.prototype = { + + /** + * Are we in html5 mode? + * @private + */ + $$html5: false, + + /** + * Has any change been replacing ? + * @private + */ + $$replace: false, + + /** + * @ngdoc method + * @name $location#absUrl + * + * @description + * This method is getter only. + * + * Return full url representation with all segments encoded according to rules specified in + * [RFC 3986](http://www.ietf.org/rfc/rfc3986.txt). + * + * @return {string} full url + */ + absUrl: locationGetter('$$absUrl'), + + /** + * @ngdoc method + * @name $location#url + * + * @description + * This method is getter / setter. + * + * Return url (e.g. `/path?a=b#hash`) when called without any parameter. + * + * Change path, search and hash, when called with parameter and return `$location`. + * + * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) + * @param {string=} replace The path that will be changed + * @return {string} url + */ + url: function(url, replace) { + if (isUndefined(url)) + return this.$$url; + + var match = PATH_MATCH.exec(url); + if (match[1]) this.path(decodeURIComponent(match[1])); + if (match[2] || match[1]) this.search(match[3] || ''); + this.hash(match[5] || '', replace); + + return this; + }, + + /** + * @ngdoc method + * @name $location#protocol + * + * @description + * This method is getter only. + * + * Return protocol of current url. + * + * @return {string} protocol of current url + */ + protocol: locationGetter('$$protocol'), + + /** + * @ngdoc method + * @name $location#host + * + * @description + * This method is getter only. + * + * Return host of current url. + * + * @return {string} host of current url. + */ + host: locationGetter('$$host'), + + /** + * @ngdoc method + * @name $location#port + * + * @description + * This method is getter only. + * + * Return port of current url. + * + * @return {Number} port + */ + port: locationGetter('$$port'), + + /** + * @ngdoc method + * @name $location#path + * + * @description + * This method is getter / setter. + * + * Return path of current url when called without any parameter. + * + * Change path when called with parameter and return `$location`. + * + * Note: Path should always begin with forward slash (/), this method will add the forward slash + * if it is missing. + * + * @param {string=} path New path + * @return {string} path + */ + path: locationGetterSetter('$$path', function(path) { + return path.charAt(0) == '/' ? path : '/' + path; + }), + + /** + * @ngdoc method + * @name $location#search + * + * @description + * This method is getter / setter. + * + * Return search part (as object) of current url when called without any parameter. + * + * Change search part when called with parameter and return `$location`. + * + * + * ```js + * // given url http://example.com/#/some/path?foo=bar&baz=xoxo + * var searchObject = $location.search(); + * // => {foo: 'bar', baz: 'xoxo'} + * + * + * // set foo to 'yipee' + * $location.search('foo', 'yipee'); + * // => $location + * ``` + * + * @param {string|Object.|Object.>} search New search params - string or + * hash object. + * + * When called with a single argument the method acts as a setter, setting the `search` component + * of `$location` to the specified value. + * + * If the argument is a hash object containing an array of values, these values will be encoded + * as duplicate search parameters in the url. + * + * @param {(string|Array|boolean)=} paramValue If `search` is a string, then `paramValue` + * will override only a single search property. + * + * If `paramValue` is an array, it will override the property of the `search` component of + * `$location` specified via the first argument. + * + * If `paramValue` is `null`, the property specified via the first argument will be deleted. + * + * If `paramValue` is `true`, the property specified via the first argument will be added with no + * value nor trailing equal sign. + * + * @return {Object} If called with no arguments returns the parsed `search` object. If called with + * one or more arguments returns `$location` object itself. + */ + search: function(search, paramValue) { + switch (arguments.length) { + case 0: + return this.$$search; + case 1: + if (isString(search)) { + this.$$search = parseKeyValue(search); + } else if (isObject(search)) { + // remove object undefined or null properties + forEach(search, function(value, key) { + if (value == null) delete search[key]; + }); + + this.$$search = search; + } else { + throw $locationMinErr('isrcharg', + 'The first argument of the `$location#search()` call must be a string or an object.'); + } + break; + default: + if (isUndefined(paramValue) || paramValue === null) { + delete this.$$search[search]; + } else { + this.$$search[search] = paramValue; + } + } + + this.$$compose(); + return this; + }, + + /** + * @ngdoc method + * @name $location#hash + * + * @description + * This method is getter / setter. + * + * Return hash fragment when called without any parameter. + * + * Change hash fragment when called with parameter and return `$location`. + * + * @param {string=} hash New hash fragment + * @return {string} hash + */ + hash: locationGetterSetter('$$hash', identity), + + /** + * @ngdoc method + * @name $location#replace + * + * @description + * If called, all changes to $location during current `$digest` will be replacing current history + * record, instead of adding new one. + */ + replace: function() { + this.$$replace = true; + return this; + } +}; + +function locationGetter(property) { + return function() { + return this[property]; + }; +} + + +function locationGetterSetter(property, preprocess) { + return function(value) { + if (isUndefined(value)) + return this[property]; + + this[property] = preprocess(value); + this.$$compose(); + + return this; + }; +} + + +/** + * @ngdoc service + * @name $location + * + * @requires $rootElement + * + * @description + * The $location service parses the URL in the browser address bar (based on the + * [window.location](https://developer.mozilla.org/en/window.location)) and makes the URL + * available to your application. Changes to the URL in the address bar are reflected into + * $location service and changes to $location are reflected into the browser address bar. + * + * **The $location service:** + * + * - Exposes the current URL in the browser address bar, so you can + * - Watch and observe the URL. + * - Change the URL. + * - Synchronizes the URL with the browser when the user + * - Changes the address bar. + * - Clicks the back or forward button (or clicks a History link). + * - Clicks on a link. + * - Represents the URL object as a set of methods (protocol, host, port, path, search, hash). + * + * For more information see {@link guide/$location Developer Guide: Using $location} + */ + +/** + * @ngdoc provider + * @name $locationProvider + * @description + * Use the `$locationProvider` to configure how the application deep linking paths are stored. + */ +function $LocationProvider(){ + var hashPrefix = '', + html5Mode = false; + + /** + * @ngdoc method + * @name $locationProvider#hashPrefix + * @description + * @param {string=} prefix Prefix for hash part (containing path and search) + * @returns {*} current value if used as getter or itself (chaining) if used as setter + */ + this.hashPrefix = function(prefix) { + if (isDefined(prefix)) { + hashPrefix = prefix; + return this; + } else { + return hashPrefix; + } + }; + + /** + * @ngdoc method + * @name $locationProvider#html5Mode + * @description + * @param {boolean=} mode Use HTML5 strategy if available. + * @returns {*} current value if used as getter or itself (chaining) if used as setter + */ + this.html5Mode = function(mode) { + if (isDefined(mode)) { + html5Mode = mode; + return this; + } else { + return html5Mode; + } + }; + + /** + * @ngdoc event + * @name $location#$locationChangeStart + * @eventType broadcast on root scope + * @description + * Broadcasted before a URL will change. This change can be prevented by calling + * `preventDefault` method of the event. See {@link ng.$rootScope.Scope#$on} for more + * details about event object. Upon successful change + * {@link ng.$location#events_$locationChangeSuccess $locationChangeSuccess} is fired. + * + * @param {Object} angularEvent Synthetic event object. + * @param {string} newUrl New URL + * @param {string=} oldUrl URL that was before it was changed. + */ + + /** + * @ngdoc event + * @name $location#$locationChangeSuccess + * @eventType broadcast on root scope + * @description + * Broadcasted after a URL was changed. + * + * @param {Object} angularEvent Synthetic event object. + * @param {string} newUrl New URL + * @param {string=} oldUrl URL that was before it was changed. + */ + + this.$get = ['$rootScope', '$browser', '$sniffer', '$rootElement', + function( $rootScope, $browser, $sniffer, $rootElement) { + var $location, + LocationMode, + baseHref = $browser.baseHref(), // if base[href] is undefined, it defaults to '' + initialUrl = $browser.url(), + appBase; + + if (html5Mode) { + appBase = serverBase(initialUrl) + (baseHref || '/'); + LocationMode = $sniffer.history ? LocationHtml5Url : LocationHashbangInHtml5Url; + } else { + appBase = stripHash(initialUrl); + LocationMode = LocationHashbangUrl; + } + $location = new LocationMode(appBase, '#' + hashPrefix); + $location.$$parse($location.$$rewrite(initialUrl)); + + $rootElement.on('click', function(event) { + // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) + // currently we open nice url link and redirect then + + if (event.ctrlKey || event.metaKey || event.which == 2) return; + + var elm = jqLite(event.target); + + // traverse the DOM up to find first A tag + while (lowercase(elm[0].nodeName) !== 'a') { + // ignore rewriting if no A tag (reached root element, or no parent - removed from document) + if (elm[0] === $rootElement[0] || !(elm = elm.parent())[0]) return; + } + + var absHref = elm.prop('href'); + + if (isObject(absHref) && absHref.toString() === '[object SVGAnimatedString]') { + // SVGAnimatedString.animVal should be identical to SVGAnimatedString.baseVal, unless during + // an animation. + absHref = urlResolve(absHref.animVal).href; + } + + // Make relative links work in HTML5 mode for legacy browsers (or at least IE8 & 9) + // The href should be a regular url e.g. /link/somewhere or link/somewhere or ../somewhere or + // somewhere#anchor or http://example.com/somewhere + if (LocationMode === LocationHashbangInHtml5Url) { + // get the actual href attribute - see + // http://msdn.microsoft.com/en-us/library/ie/dd347148(v=vs.85).aspx + var href = elm.attr('href') || elm.attr('xlink:href'); + + if (href.indexOf('://') < 0) { // Ignore absolute URLs + var prefix = '#' + hashPrefix; + if (href[0] == '/') { + // absolute path - replace old path + absHref = appBase + prefix + href; + } else if (href[0] == '#') { + // local anchor + absHref = appBase + prefix + ($location.path() || '/') + href; + } else { + // relative path - join with current path + var stack = $location.path().split("/"), + parts = href.split("/"); + for (var i=0; i html5 url + if ($location.absUrl() != initialUrl) { + $browser.url($location.absUrl(), true); + } + + // update $location when $browser url changes + $browser.onUrlChange(function(newUrl) { + if ($location.absUrl() != newUrl) { + $rootScope.$evalAsync(function() { + var oldUrl = $location.absUrl(); + + $location.$$parse(newUrl); + if ($rootScope.$broadcast('$locationChangeStart', newUrl, + oldUrl).defaultPrevented) { + $location.$$parse(oldUrl); + $browser.url(oldUrl); + } else { + afterLocationChange(oldUrl); + } + }); + if (!$rootScope.$$phase) $rootScope.$digest(); + } + }); + + // update browser + var changeCounter = 0; + $rootScope.$watch(function $locationWatch() { + var oldUrl = $browser.url(); + var currentReplace = $location.$$replace; + + if (!changeCounter || oldUrl != $location.absUrl()) { + changeCounter++; + $rootScope.$evalAsync(function() { + if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl). + defaultPrevented) { + $location.$$parse(oldUrl); + } else { + $browser.url($location.absUrl(), currentReplace); + afterLocationChange(oldUrl); + } + }); + } + $location.$$replace = false; + + return changeCounter; + }); + + return $location; + + function afterLocationChange(oldUrl) { + $rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl); + } +}]; +} + +/** + * @ngdoc service + * @name $log + * @requires $window + * + * @description + * Simple service for logging. Default implementation safely writes the message + * into the browser's console (if present). + * + * The main purpose of this service is to simplify debugging and troubleshooting. + * + * The default is to log `debug` messages. You can use + * {@link ng.$logProvider ng.$logProvider#debugEnabled} to change this. + * + * @example + + + angular.module('logExample', []) + .controller('LogController', ['$scope', '$log', function($scope, $log) { + $scope.$log = $log; + $scope.message = 'Hello World!'; + }]); + + +
+

Reload this page with open console, enter text and hit the log button...

+ Message: + + + + + +
+
+
+ */ + +/** + * @ngdoc provider + * @name $logProvider + * @description + * Use the `$logProvider` to configure how the application logs messages + */ +function $LogProvider(){ + var debug = true, + self = this; + + /** + * @ngdoc method + * @name $logProvider#debugEnabled + * @description + * @param {boolean=} flag enable or disable debug level messages + * @returns {*} current value if used as getter or itself (chaining) if used as setter + */ + this.debugEnabled = function(flag) { + if (isDefined(flag)) { + debug = flag; + return this; + } else { + return debug; + } + }; + + this.$get = ['$window', function($window){ + return { + /** + * @ngdoc method + * @name $log#log + * + * @description + * Write a log message + */ + log: consoleLog('log'), + + /** + * @ngdoc method + * @name $log#info + * + * @description + * Write an information message + */ + info: consoleLog('info'), + + /** + * @ngdoc method + * @name $log#warn + * + * @description + * Write a warning message + */ + warn: consoleLog('warn'), + + /** + * @ngdoc method + * @name $log#error + * + * @description + * Write an error message + */ + error: consoleLog('error'), + + /** + * @ngdoc method + * @name $log#debug + * + * @description + * Write a debug message + */ + debug: (function () { + var fn = consoleLog('debug'); + + return function() { + if (debug) { + fn.apply(self, arguments); + } + }; + }()) + }; + + function formatError(arg) { + if (arg instanceof Error) { + if (arg.stack) { + arg = (arg.message && arg.stack.indexOf(arg.message) === -1) + ? 'Error: ' + arg.message + '\n' + arg.stack + : arg.stack; + } else if (arg.sourceURL) { + arg = arg.message + '\n' + arg.sourceURL + ':' + arg.line; + } + } + return arg; + } + + function consoleLog(type) { + var console = $window.console || {}, + logFn = console[type] || console.log || noop, + hasApply = false; + + // Note: reading logFn.apply throws an error in IE11 in IE8 document mode. + // The reason behind this is that console.log has type "object" in IE8... + try { + hasApply = !!logFn.apply; + } catch (e) {} + + if (hasApply) { + return function() { + var args = []; + forEach(arguments, function(arg) { + args.push(formatError(arg)); + }); + return logFn.apply(console, args); + }; + } + + // we are IE which either doesn't have window.console => this is noop and we do nothing, + // or we are IE where console.log doesn't have apply so we log at least first 2 args + return function(arg1, arg2) { + logFn(arg1, arg2 == null ? '' : arg2); + }; + } + }]; +} + +var $parseMinErr = minErr('$parse'); +var promiseWarningCache = {}; +var promiseWarning; + +// Sandboxing Angular Expressions +// ------------------------------ +// Angular expressions are generally considered safe because these expressions only have direct +// access to $scope and locals. However, one can obtain the ability to execute arbitrary JS code by +// obtaining a reference to native JS functions such as the Function constructor. +// +// As an example, consider the following Angular expression: +// +// {}.toString.constructor('alert("evil JS code")') +// +// This sandboxing technique is not perfect and doesn't aim to be. The goal is to prevent exploits +// against the expression language, but not to prevent exploits that were enabled by exposing +// sensitive JavaScript or browser apis on Scope. Exposing such objects on a Scope is never a good +// practice and therefore we are not even trying to protect against interaction with an object +// explicitly exposed in this way. +// +// In general, it is not possible to access a Window object from an angular expression unless a +// window or some DOM object that has a reference to window is published onto a Scope. +// Similarly we prevent invocations of function known to be dangerous, as well as assignments to +// native objects. + + +function ensureSafeMemberName(name, fullExpression) { + if (name === "__defineGetter__" || name === "__defineSetter__" + || name === "__lookupGetter__" || name === "__lookupSetter__" + || name === "__proto__") { + throw $parseMinErr('isecfld', + 'Attempting to access a disallowed field in Angular expressions! ' + +'Expression: {0}', fullExpression); + } + return name; +} + +function ensureSafeObject(obj, fullExpression) { + // nifty check if obj is Function that is fast and works across iframes and other contexts + if (obj) { + if (obj.constructor === obj) { + throw $parseMinErr('isecfn', + 'Referencing Function in Angular expressions is disallowed! Expression: {0}', + fullExpression); + } else if (// isWindow(obj) + obj.document && obj.location && obj.alert && obj.setInterval) { + throw $parseMinErr('isecwindow', + 'Referencing the Window in Angular expressions is disallowed! Expression: {0}', + fullExpression); + } else if (// isElement(obj) + obj.children && (obj.nodeName || (obj.prop && obj.attr && obj.find))) { + throw $parseMinErr('isecdom', + 'Referencing DOM nodes in Angular expressions is disallowed! Expression: {0}', + fullExpression); + } else if (// block Object so that we can't get hold of dangerous Object.* methods + obj === Object) { + throw $parseMinErr('isecobj', + 'Referencing Object in Angular expressions is disallowed! Expression: {0}', + fullExpression); + } + } + return obj; +} + +var CALL = Function.prototype.call; +var APPLY = Function.prototype.apply; +var BIND = Function.prototype.bind; + +function ensureSafeFunction(obj, fullExpression) { + if (obj) { + if (obj.constructor === obj) { + throw $parseMinErr('isecfn', + 'Referencing Function in Angular expressions is disallowed! Expression: {0}', + fullExpression); + } else if (obj === CALL || obj === APPLY || (BIND && obj === BIND)) { + throw $parseMinErr('isecff', + 'Referencing call, apply or bind in Angular expressions is disallowed! Expression: {0}', + fullExpression); + } + } +} + +var OPERATORS = { + /* jshint bitwise : false */ + 'null':function(){return null;}, + 'true':function(){return true;}, + 'false':function(){return false;}, + undefined:noop, + '+':function(self, locals, a,b){ + a=a(self, locals); b=b(self, locals); + if (isDefined(a)) { + if (isDefined(b)) { + return a + b; + } + return a; + } + return isDefined(b)?b:undefined;}, + '-':function(self, locals, a,b){ + a=a(self, locals); b=b(self, locals); + return (isDefined(a)?a:0)-(isDefined(b)?b:0); + }, + '*':function(self, locals, a,b){return a(self, locals)*b(self, locals);}, + '/':function(self, locals, a,b){return a(self, locals)/b(self, locals);}, + '%':function(self, locals, a,b){return a(self, locals)%b(self, locals);}, + '^':function(self, locals, a,b){return a(self, locals)^b(self, locals);}, + '=':noop, + '===':function(self, locals, a, b){return a(self, locals)===b(self, locals);}, + '!==':function(self, locals, a, b){return a(self, locals)!==b(self, locals);}, + '==':function(self, locals, a,b){return a(self, locals)==b(self, locals);}, + '!=':function(self, locals, a,b){return a(self, locals)!=b(self, locals);}, + '<':function(self, locals, a,b){return a(self, locals)':function(self, locals, a,b){return a(self, locals)>b(self, locals);}, + '<=':function(self, locals, a,b){return a(self, locals)<=b(self, locals);}, + '>=':function(self, locals, a,b){return a(self, locals)>=b(self, locals);}, + '&&':function(self, locals, a,b){return a(self, locals)&&b(self, locals);}, + '||':function(self, locals, a,b){return a(self, locals)||b(self, locals);}, + '&':function(self, locals, a,b){return a(self, locals)&b(self, locals);}, +// '|':function(self, locals, a,b){return a|b;}, + '|':function(self, locals, a,b){return b(self, locals)(self, locals, a(self, locals));}, + '!':function(self, locals, a){return !a(self, locals);} +}; +/* jshint bitwise: true */ +var ESCAPE = {"n":"\n", "f":"\f", "r":"\r", "t":"\t", "v":"\v", "'":"'", '"':'"'}; + + +///////////////////////////////////////// + + +/** + * @constructor + */ +var Lexer = function (options) { + this.options = options; +}; + +Lexer.prototype = { + constructor: Lexer, + + lex: function (text) { + this.text = text; + + this.index = 0; + this.ch = undefined; + this.lastCh = ':'; // can start regexp + + this.tokens = []; + + while (this.index < this.text.length) { + this.ch = this.text.charAt(this.index); + if (this.is('"\'')) { + this.readString(this.ch); + } else if (this.isNumber(this.ch) || this.is('.') && this.isNumber(this.peek())) { + this.readNumber(); + } else if (this.isIdent(this.ch)) { + this.readIdent(); + } else if (this.is('(){}[].,;:?')) { + this.tokens.push({ + index: this.index, + text: this.ch + }); + this.index++; + } else if (this.isWhitespace(this.ch)) { + this.index++; + continue; + } else { + var ch2 = this.ch + this.peek(); + var ch3 = ch2 + this.peek(2); + var fn = OPERATORS[this.ch]; + var fn2 = OPERATORS[ch2]; + var fn3 = OPERATORS[ch3]; + if (fn3) { + this.tokens.push({index: this.index, text: ch3, fn: fn3}); + this.index += 3; + } else if (fn2) { + this.tokens.push({index: this.index, text: ch2, fn: fn2}); + this.index += 2; + } else if (fn) { + this.tokens.push({ + index: this.index, + text: this.ch, + fn: fn + }); + this.index += 1; + } else { + this.throwError('Unexpected next character ', this.index, this.index + 1); + } + } + this.lastCh = this.ch; + } + return this.tokens; + }, + + is: function(chars) { + return chars.indexOf(this.ch) !== -1; + }, + + was: function(chars) { + return chars.indexOf(this.lastCh) !== -1; + }, + + peek: function(i) { + var num = i || 1; + return (this.index + num < this.text.length) ? this.text.charAt(this.index + num) : false; + }, + + isNumber: function(ch) { + return ('0' <= ch && ch <= '9'); + }, + + isWhitespace: function(ch) { + // IE treats non-breaking space as \u00A0 + return (ch === ' ' || ch === '\r' || ch === '\t' || + ch === '\n' || ch === '\v' || ch === '\u00A0'); + }, + + isIdent: function(ch) { + return ('a' <= ch && ch <= 'z' || + 'A' <= ch && ch <= 'Z' || + '_' === ch || ch === '$'); + }, + + isExpOperator: function(ch) { + return (ch === '-' || ch === '+' || this.isNumber(ch)); + }, + + throwError: function(error, start, end) { + end = end || this.index; + var colStr = (isDefined(start) + ? 's ' + start + '-' + this.index + ' [' + this.text.substring(start, end) + ']' + : ' ' + end); + throw $parseMinErr('lexerr', 'Lexer Error: {0} at column{1} in expression [{2}].', + error, colStr, this.text); + }, + + readNumber: function() { + var number = ''; + var start = this.index; + while (this.index < this.text.length) { + var ch = lowercase(this.text.charAt(this.index)); + if (ch == '.' || this.isNumber(ch)) { + number += ch; + } else { + var peekCh = this.peek(); + if (ch == 'e' && this.isExpOperator(peekCh)) { + number += ch; + } else if (this.isExpOperator(ch) && + peekCh && this.isNumber(peekCh) && + number.charAt(number.length - 1) == 'e') { + number += ch; + } else if (this.isExpOperator(ch) && + (!peekCh || !this.isNumber(peekCh)) && + number.charAt(number.length - 1) == 'e') { + this.throwError('Invalid exponent'); + } else { + break; + } + } + this.index++; + } + number = 1 * number; + this.tokens.push({ + index: start, + text: number, + literal: true, + constant: true, + fn: function() { return number; } + }); + }, + + readIdent: function() { + var parser = this; + + var ident = ''; + var start = this.index; + + var lastDot, peekIndex, methodName, ch; + + while (this.index < this.text.length) { + ch = this.text.charAt(this.index); + if (ch === '.' || this.isIdent(ch) || this.isNumber(ch)) { + if (ch === '.') lastDot = this.index; + ident += ch; + } else { + break; + } + this.index++; + } + + //check if this is not a method invocation and if it is back out to last dot + if (lastDot) { + peekIndex = this.index; + while (peekIndex < this.text.length) { + ch = this.text.charAt(peekIndex); + if (ch === '(') { + methodName = ident.substr(lastDot - start + 1); + ident = ident.substr(0, lastDot - start); + this.index = peekIndex; + break; + } + if (this.isWhitespace(ch)) { + peekIndex++; + } else { + break; + } + } + } + + + var token = { + index: start, + text: ident + }; + + // OPERATORS is our own object so we don't need to use special hasOwnPropertyFn + if (OPERATORS.hasOwnProperty(ident)) { + token.fn = OPERATORS[ident]; + token.literal = true; + token.constant = true; + } else { + var getter = getterFn(ident, this.options, this.text); + token.fn = extend(function(self, locals) { + return (getter(self, locals)); + }, { + assign: function(self, value) { + return setter(self, ident, value, parser.text, parser.options); + } + }); + } + + this.tokens.push(token); + + if (methodName) { + this.tokens.push({ + index:lastDot, + text: '.' + }); + this.tokens.push({ + index: lastDot + 1, + text: methodName + }); + } + }, + + readString: function(quote) { + var start = this.index; + this.index++; + var string = ''; + var rawString = quote; + var escape = false; + while (this.index < this.text.length) { + var ch = this.text.charAt(this.index); + rawString += ch; + if (escape) { + if (ch === 'u') { + var hex = this.text.substring(this.index + 1, this.index + 5); + if (!hex.match(/[\da-f]{4}/i)) + this.throwError('Invalid unicode escape [\\u' + hex + ']'); + this.index += 4; + string += String.fromCharCode(parseInt(hex, 16)); + } else { + var rep = ESCAPE[ch]; + if (rep) { + string += rep; + } else { + string += ch; + } + } + escape = false; + } else if (ch === '\\') { + escape = true; + } else if (ch === quote) { + this.index++; + this.tokens.push({ + index: start, + text: rawString, + string: string, + literal: true, + constant: true, + fn: function() { return string; } + }); + return; + } else { + string += ch; + } + this.index++; + } + this.throwError('Unterminated quote', start); + } +}; + + +/** + * @constructor + */ +var Parser = function (lexer, $filter, options) { + this.lexer = lexer; + this.$filter = $filter; + this.options = options; +}; + +Parser.ZERO = extend(function () { + return 0; +}, { + constant: true +}); + +Parser.prototype = { + constructor: Parser, + + parse: function (text) { + this.text = text; + + this.tokens = this.lexer.lex(text); + + var value = this.statements(); + + if (this.tokens.length !== 0) { + this.throwError('is an unexpected token', this.tokens[0]); + } + + value.literal = !!value.literal; + value.constant = !!value.constant; + + return value; + }, + + primary: function () { + var primary; + if (this.expect('(')) { + primary = this.filterChain(); + this.consume(')'); + } else if (this.expect('[')) { + primary = this.arrayDeclaration(); + } else if (this.expect('{')) { + primary = this.object(); + } else { + var token = this.expect(); + primary = token.fn; + if (!primary) { + this.throwError('not a primary expression', token); + } + primary.literal = !!token.literal; + primary.constant = !!token.constant; + } + + var next, context; + while ((next = this.expect('(', '[', '.'))) { + if (next.text === '(') { + primary = this.functionCall(primary, context); + context = null; + } else if (next.text === '[') { + context = primary; + primary = this.objectIndex(primary); + } else if (next.text === '.') { + context = primary; + primary = this.fieldAccess(primary); + } else { + this.throwError('IMPOSSIBLE'); + } + } + return primary; + }, + + throwError: function(msg, token) { + throw $parseMinErr('syntax', + 'Syntax Error: Token \'{0}\' {1} at column {2} of the expression [{3}] starting at [{4}].', + token.text, msg, (token.index + 1), this.text, this.text.substring(token.index)); + }, + + peekToken: function() { + if (this.tokens.length === 0) + throw $parseMinErr('ueoe', 'Unexpected end of expression: {0}', this.text); + return this.tokens[0]; + }, + + peek: function(e1, e2, e3, e4) { + if (this.tokens.length > 0) { + var token = this.tokens[0]; + var t = token.text; + if (t === e1 || t === e2 || t === e3 || t === e4 || + (!e1 && !e2 && !e3 && !e4)) { + return token; + } + } + return false; + }, + + expect: function(e1, e2, e3, e4){ + var token = this.peek(e1, e2, e3, e4); + if (token) { + this.tokens.shift(); + return token; + } + return false; + }, + + consume: function(e1){ + if (!this.expect(e1)) { + this.throwError('is unexpected, expecting [' + e1 + ']', this.peek()); + } + }, + + unaryFn: function(fn, right) { + return extend(function(self, locals) { + return fn(self, locals, right); + }, { + constant:right.constant + }); + }, + + ternaryFn: function(left, middle, right){ + return extend(function(self, locals){ + return left(self, locals) ? middle(self, locals) : right(self, locals); + }, { + constant: left.constant && middle.constant && right.constant + }); + }, + + binaryFn: function(left, fn, right) { + return extend(function(self, locals) { + return fn(self, locals, left, right); + }, { + constant:left.constant && right.constant + }); + }, + + statements: function() { + var statements = []; + while (true) { + if (this.tokens.length > 0 && !this.peek('}', ')', ';', ']')) + statements.push(this.filterChain()); + if (!this.expect(';')) { + // optimize for the common case where there is only one statement. + // TODO(size): maybe we should not support multiple statements? + return (statements.length === 1) + ? statements[0] + : function(self, locals) { + var value; + for (var i = 0; i < statements.length; i++) { + var statement = statements[i]; + if (statement) { + value = statement(self, locals); + } + } + return value; + }; + } + } + }, + + filterChain: function() { + var left = this.expression(); + var token; + while (true) { + if ((token = this.expect('|'))) { + left = this.binaryFn(left, token.fn, this.filter()); + } else { + return left; + } + } + }, + + filter: function() { + var token = this.expect(); + var fn = this.$filter(token.text); + var argsFn = []; + while (true) { + if ((token = this.expect(':'))) { + argsFn.push(this.expression()); + } else { + var fnInvoke = function(self, locals, input) { + var args = [input]; + for (var i = 0; i < argsFn.length; i++) { + args.push(argsFn[i](self, locals)); + } + return fn.apply(self, args); + }; + return function() { + return fnInvoke; + }; + } + } + }, + + expression: function() { + return this.assignment(); + }, + + assignment: function() { + var left = this.ternary(); + var right; + var token; + if ((token = this.expect('='))) { + if (!left.assign) { + this.throwError('implies assignment but [' + + this.text.substring(0, token.index) + '] can not be assigned to', token); + } + right = this.ternary(); + return function(scope, locals) { + return left.assign(scope, right(scope, locals), locals); + }; + } + return left; + }, + + ternary: function() { + var left = this.logicalOR(); + var middle; + var token; + if ((token = this.expect('?'))) { + middle = this.ternary(); + if ((token = this.expect(':'))) { + return this.ternaryFn(left, middle, this.ternary()); + } else { + this.throwError('expected :', token); + } + } else { + return left; + } + }, + + logicalOR: function() { + var left = this.logicalAND(); + var token; + while (true) { + if ((token = this.expect('||'))) { + left = this.binaryFn(left, token.fn, this.logicalAND()); + } else { + return left; + } + } + }, + + logicalAND: function() { + var left = this.equality(); + var token; + if ((token = this.expect('&&'))) { + left = this.binaryFn(left, token.fn, this.logicalAND()); + } + return left; + }, + + equality: function() { + var left = this.relational(); + var token; + if ((token = this.expect('==','!=','===','!=='))) { + left = this.binaryFn(left, token.fn, this.equality()); + } + return left; + }, + + relational: function() { + var left = this.additive(); + var token; + if ((token = this.expect('<', '>', '<=', '>='))) { + left = this.binaryFn(left, token.fn, this.relational()); + } + return left; + }, + + additive: function() { + var left = this.multiplicative(); + var token; + while ((token = this.expect('+','-'))) { + left = this.binaryFn(left, token.fn, this.multiplicative()); + } + return left; + }, + + multiplicative: function() { + var left = this.unary(); + var token; + while ((token = this.expect('*','/','%'))) { + left = this.binaryFn(left, token.fn, this.unary()); + } + return left; + }, + + unary: function() { + var token; + if (this.expect('+')) { + return this.primary(); + } else if ((token = this.expect('-'))) { + return this.binaryFn(Parser.ZERO, token.fn, this.unary()); + } else if ((token = this.expect('!'))) { + return this.unaryFn(token.fn, this.unary()); + } else { + return this.primary(); + } + }, + + fieldAccess: function(object) { + var parser = this; + var field = this.expect().text; + var getter = getterFn(field, this.options, this.text); + + return extend(function(scope, locals, self) { + return getter(self || object(scope, locals)); + }, { + assign: function(scope, value, locals) { + return setter(object(scope, locals), field, value, parser.text, parser.options); + } + }); + }, + + objectIndex: function(obj) { + var parser = this; + + var indexFn = this.expression(); + this.consume(']'); + + return extend(function(self, locals) { + var o = obj(self, locals), + i = indexFn(self, locals), + v, p; + + ensureSafeMemberName(i, parser.text); + if (!o) return undefined; + v = ensureSafeObject(o[i], parser.text); + if (v && v.then && parser.options.unwrapPromises) { + p = v; + if (!('$$v' in v)) { + p.$$v = undefined; + p.then(function(val) { p.$$v = val; }); + } + v = v.$$v; + } + return v; + }, { + assign: function(self, value, locals) { + var key = indexFn(self, locals); + // prevent overwriting of Function.constructor which would break ensureSafeObject check + var safe = ensureSafeObject(obj(self, locals), parser.text); + return safe[key] = value; + } + }); + }, + + functionCall: function(fn, contextGetter) { + var argsFn = []; + if (this.peekToken().text !== ')') { + do { + argsFn.push(this.expression()); + } while (this.expect(',')); + } + this.consume(')'); + + var parser = this; + + return function(scope, locals) { + var args = []; + var context = contextGetter ? contextGetter(scope, locals) : scope; + + for (var i = 0; i < argsFn.length; i++) { + args.push(argsFn[i](scope, locals)); + } + var fnPtr = fn(scope, locals, context) || noop; + + ensureSafeObject(context, parser.text); + ensureSafeFunction(fnPtr, parser.text); + + // IE stupidity! (IE doesn't have apply for some native functions) + var v = fnPtr.apply + ? fnPtr.apply(context, args) + : fnPtr(args[0], args[1], args[2], args[3], args[4]); + + return ensureSafeObject(v, parser.text); + }; + }, + + // This is used with json array declaration + arrayDeclaration: function () { + var elementFns = []; + var allConstant = true; + if (this.peekToken().text !== ']') { + do { + if (this.peek(']')) { + // Support trailing commas per ES5.1. + break; + } + var elementFn = this.expression(); + elementFns.push(elementFn); + if (!elementFn.constant) { + allConstant = false; + } + } while (this.expect(',')); + } + this.consume(']'); + + return extend(function(self, locals) { + var array = []; + for (var i = 0; i < elementFns.length; i++) { + array.push(elementFns[i](self, locals)); + } + return array; + }, { + literal: true, + constant: allConstant + }); + }, + + object: function () { + var keyValues = []; + var allConstant = true; + if (this.peekToken().text !== '}') { + do { + if (this.peek('}')) { + // Support trailing commas per ES5.1. + break; + } + var token = this.expect(), + key = token.string || token.text; + this.consume(':'); + var value = this.expression(); + keyValues.push({key: key, value: value}); + if (!value.constant) { + allConstant = false; + } + } while (this.expect(',')); + } + this.consume('}'); + + return extend(function(self, locals) { + var object = {}; + for (var i = 0; i < keyValues.length; i++) { + var keyValue = keyValues[i]; + object[keyValue.key] = keyValue.value(self, locals); + } + return object; + }, { + literal: true, + constant: allConstant + }); + } +}; + + +////////////////////////////////////////////////// +// Parser helper functions +////////////////////////////////////////////////// + +function setter(obj, path, setValue, fullExp, options) { + //needed? + options = options || {}; + + var element = path.split('.'), key; + for (var i = 0; element.length > 1; i++) { + key = ensureSafeMemberName(element.shift(), fullExp); + var propertyObj = obj[key]; + if (!propertyObj) { + propertyObj = {}; + obj[key] = propertyObj; + } + obj = propertyObj; + if (obj.then && options.unwrapPromises) { + promiseWarning(fullExp); + if (!("$$v" in obj)) { + (function(promise) { + promise.then(function(val) { promise.$$v = val; }); } + )(obj); + } + if (obj.$$v === undefined) { + obj.$$v = {}; + } + obj = obj.$$v; + } + } + key = ensureSafeMemberName(element.shift(), fullExp); + ensureSafeObject(obj, fullExp); + ensureSafeObject(obj[key], fullExp); + obj[key] = setValue; + return setValue; +} + +var getterFnCache = {}; + +/** + * Implementation of the "Black Hole" variant from: + * - http://jsperf.com/angularjs-parse-getter/4 + * - http://jsperf.com/path-evaluation-simplified/7 + */ +function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp, options) { + ensureSafeMemberName(key0, fullExp); + ensureSafeMemberName(key1, fullExp); + ensureSafeMemberName(key2, fullExp); + ensureSafeMemberName(key3, fullExp); + ensureSafeMemberName(key4, fullExp); + + return !options.unwrapPromises + ? function cspSafeGetter(scope, locals) { + var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope; + + if (pathVal == null) return pathVal; + pathVal = pathVal[key0]; + + if (!key1) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key1]; + + if (!key2) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key2]; + + if (!key3) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key3]; + + if (!key4) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key4]; + + return pathVal; + } + : function cspSafePromiseEnabledGetter(scope, locals) { + var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope, + promise; + + if (pathVal == null) return pathVal; + + pathVal = pathVal[key0]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + + if (!key1) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key1]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + + if (!key2) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key2]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + + if (!key3) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key3]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + + if (!key4) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key4]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + return pathVal; + }; +} + +function getterFn(path, options, fullExp) { + // Check whether the cache has this getter already. + // We can use hasOwnProperty directly on the cache because we ensure, + // see below, that the cache never stores a path called 'hasOwnProperty' + if (getterFnCache.hasOwnProperty(path)) { + return getterFnCache[path]; + } + + var pathKeys = path.split('.'), + pathKeysLength = pathKeys.length, + fn; + + // http://jsperf.com/angularjs-parse-getter/6 + if (options.csp) { + if (pathKeysLength < 6) { + fn = cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp, + options); + } else { + fn = function(scope, locals) { + var i = 0, val; + do { + val = cspSafeGetterFn(pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], + pathKeys[i++], fullExp, options)(scope, locals); + + locals = undefined; // clear after first iteration + scope = val; + } while (i < pathKeysLength); + return val; + }; + } + } else { + var code = 'var p;\n'; + forEach(pathKeys, function(key, index) { + ensureSafeMemberName(key, fullExp); + code += 'if(s == null) return undefined;\n' + + 's='+ (index + // we simply dereference 's' on any .dot notation + ? 's' + // but if we are first then we check locals first, and if so read it first + : '((k&&k.hasOwnProperty("' + key + '"))?k:s)') + '["' + key + '"]' + ';\n' + + (options.unwrapPromises + ? 'if (s && s.then) {\n' + + ' pw("' + fullExp.replace(/(["\r\n])/g, '\\$1') + '");\n' + + ' if (!("$$v" in s)) {\n' + + ' p=s;\n' + + ' p.$$v = undefined;\n' + + ' p.then(function(v) {p.$$v=v;});\n' + + '}\n' + + ' s=s.$$v\n' + + '}\n' + : ''); + }); + code += 'return s;'; + + /* jshint -W054 */ + var evaledFnGetter = new Function('s', 'k', 'pw', code); // s=scope, k=locals, pw=promiseWarning + /* jshint +W054 */ + evaledFnGetter.toString = valueFn(code); + fn = options.unwrapPromises ? function(scope, locals) { + return evaledFnGetter(scope, locals, promiseWarning); + } : evaledFnGetter; + } + + // Only cache the value if it's not going to mess up the cache object + // This is more performant that using Object.prototype.hasOwnProperty.call + if (path !== 'hasOwnProperty') { + getterFnCache[path] = fn; + } + return fn; +} + +/////////////////////////////////// + +/** + * @ngdoc service + * @name $parse + * @kind function + * + * @description + * + * Converts Angular {@link guide/expression expression} into a function. + * + * ```js + * var getter = $parse('user.name'); + * var setter = getter.assign; + * var context = {user:{name:'angular'}}; + * var locals = {user:{name:'local'}}; + * + * expect(getter(context)).toEqual('angular'); + * setter(context, 'newValue'); + * expect(context.user.name).toEqual('newValue'); + * expect(getter(context, locals)).toEqual('local'); + * ``` + * + * + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. + * + * The returned function also has the following properties: + * * `literal` – `{boolean}` – whether the expression's top-level node is a JavaScript + * literal. + * * `constant` – `{boolean}` – whether the expression is made entirely of JavaScript + * constant literals. + * * `assign` – `{?function(context, value)}` – if the expression is assignable, this will be + * set to a function to change its value on the given context. + * + */ + + +/** + * @ngdoc provider + * @name $parseProvider + * @kind function + * + * @description + * `$parseProvider` can be used for configuring the default behavior of the {@link ng.$parse $parse} + * service. + */ +function $ParseProvider() { + var cache = {}; + + var $parseOptions = { + csp: false, + unwrapPromises: false, + logPromiseWarnings: true + }; + + + /** + * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. + * + * @ngdoc method + * @name $parseProvider#unwrapPromises + * @description + * + * **This feature is deprecated, see deprecation notes below for more info** + * + * If set to true (default is false), $parse will unwrap promises automatically when a promise is + * found at any part of the expression. In other words, if set to true, the expression will always + * result in a non-promise value. + * + * While the promise is unresolved, it's treated as undefined, but once resolved and fulfilled, + * the fulfillment value is used in place of the promise while evaluating the expression. + * + * **Deprecation notice** + * + * This is a feature that didn't prove to be wildly useful or popular, primarily because of the + * dichotomy between data access in templates (accessed as raw values) and controller code + * (accessed as promises). + * + * In most code we ended up resolving promises manually in controllers anyway and thus unifying + * the model access there. + * + * Other downsides of automatic promise unwrapping: + * + * - when building components it's often desirable to receive the raw promises + * - adds complexity and slows down expression evaluation + * - makes expression code pre-generation unattractive due to the amount of code that needs to be + * generated + * - makes IDE auto-completion and tool support hard + * + * **Warning Logs** + * + * If the unwrapping is enabled, Angular will log a warning about each expression that unwraps a + * promise (to reduce the noise, each expression is logged only once). To disable this logging use + * `$parseProvider.logPromiseWarnings(false)` api. + * + * + * @param {boolean=} value New value. + * @returns {boolean|self} Returns the current setting when used as getter and self if used as + * setter. + */ + this.unwrapPromises = function(value) { + if (isDefined(value)) { + $parseOptions.unwrapPromises = !!value; + return this; + } else { + return $parseOptions.unwrapPromises; + } + }; + + + /** + * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. + * + * @ngdoc method + * @name $parseProvider#logPromiseWarnings + * @description + * + * Controls whether Angular should log a warning on any encounter of a promise in an expression. + * + * The default is set to `true`. + * + * This setting applies only if `$parseProvider.unwrapPromises` setting is set to true as well. + * + * @param {boolean=} value New value. + * @returns {boolean|self} Returns the current setting when used as getter and self if used as + * setter. + */ + this.logPromiseWarnings = function(value) { + if (isDefined(value)) { + $parseOptions.logPromiseWarnings = value; + return this; + } else { + return $parseOptions.logPromiseWarnings; + } + }; + + + this.$get = ['$filter', '$sniffer', '$log', function($filter, $sniffer, $log) { + $parseOptions.csp = $sniffer.csp; + + promiseWarning = function promiseWarningFn(fullExp) { + if (!$parseOptions.logPromiseWarnings || promiseWarningCache.hasOwnProperty(fullExp)) return; + promiseWarningCache[fullExp] = true; + $log.warn('[$parse] Promise found in the expression `' + fullExp + '`. ' + + 'Automatic unwrapping of promises in Angular expressions is deprecated.'); + }; + + return function(exp) { + var parsedExpression; + + switch (typeof exp) { + case 'string': + + if (cache.hasOwnProperty(exp)) { + return cache[exp]; + } + + var lexer = new Lexer($parseOptions); + var parser = new Parser(lexer, $filter, $parseOptions); + parsedExpression = parser.parse(exp); + + if (exp !== 'hasOwnProperty') { + // Only cache the value if it's not going to mess up the cache object + // This is more performant that using Object.prototype.hasOwnProperty.call + cache[exp] = parsedExpression; + } + + return parsedExpression; + + case 'function': + return exp; + + default: + return noop; + } + }; + }]; +} + +/** + * @ngdoc service + * @name $q + * @requires $rootScope + * + * @description + * A promise/deferred implementation inspired by [Kris Kowal's Q](https://github.com/kriskowal/q). + * + * [The CommonJS Promise proposal](http://wiki.commonjs.org/wiki/Promises) describes a promise as an + * interface for interacting with an object that represents the result of an action that is + * performed asynchronously, and may or may not be finished at any given point in time. + * + * From the perspective of dealing with error handling, deferred and promise APIs are to + * asynchronous programming what `try`, `catch` and `throw` keywords are to synchronous programming. + * + * ```js + * // for the purpose of this example let's assume that variables `$q`, `scope` and `okToGreet` + * // are available in the current lexical scope (they could have been injected or passed in). + * + * function asyncGreet(name) { + * var deferred = $q.defer(); + * + * setTimeout(function() { + * deferred.notify('About to greet ' + name + '.'); + * + * if (okToGreet(name)) { + * deferred.resolve('Hello, ' + name + '!'); + * } else { + * deferred.reject('Greeting ' + name + ' is not allowed.'); + * } + * }, 1000); + * + * return deferred.promise; + * } + * + * var promise = asyncGreet('Robin Hood'); + * promise.then(function(greeting) { + * alert('Success: ' + greeting); + * }, function(reason) { + * alert('Failed: ' + reason); + * }, function(update) { + * alert('Got notification: ' + update); + * }); + * ``` + * + * At first it might not be obvious why this extra complexity is worth the trouble. The payoff + * comes in the way of guarantees that promise and deferred APIs make, see + * https://github.com/kriskowal/uncommonjs/blob/master/promises/specification.md. + * + * Additionally the promise api allows for composition that is very hard to do with the + * traditional callback ([CPS](http://en.wikipedia.org/wiki/Continuation-passing_style)) approach. + * For more on this please see the [Q documentation](https://github.com/kriskowal/q) especially the + * section on serial or parallel joining of promises. + * + * + * # The Deferred API + * + * A new instance of deferred is constructed by calling `$q.defer()`. + * + * The purpose of the deferred object is to expose the associated Promise instance as well as APIs + * that can be used for signaling the successful or unsuccessful completion, as well as the status + * of the task. + * + * **Methods** + * + * - `resolve(value)` – resolves the derived promise with the `value`. If the value is a rejection + * constructed via `$q.reject`, the promise will be rejected instead. + * - `reject(reason)` – rejects the derived promise with the `reason`. This is equivalent to + * resolving it with a rejection constructed via `$q.reject`. + * - `notify(value)` - provides updates on the status of the promise's execution. This may be called + * multiple times before the promise is either resolved or rejected. + * + * **Properties** + * + * - promise – `{Promise}` – promise object associated with this deferred. + * + * + * # The Promise API + * + * A new promise instance is created when a deferred instance is created and can be retrieved by + * calling `deferred.promise`. + * + * The purpose of the promise object is to allow for interested parties to get access to the result + * of the deferred task when it completes. + * + * **Methods** + * + * - `then(successCallback, errorCallback, notifyCallback)` – regardless of when the promise was or + * will be resolved or rejected, `then` calls one of the success or error callbacks asynchronously + * as soon as the result is available. The callbacks are called with a single argument: the result + * or rejection reason. Additionally, the notify callback may be called zero or more times to + * provide a progress indication, before the promise is resolved or rejected. + * + * This method *returns a new promise* which is resolved or rejected via the return value of the + * `successCallback`, `errorCallback`. It also notifies via the return value of the + * `notifyCallback` method. The promise can not be resolved or rejected from the notifyCallback + * method. + * + * - `catch(errorCallback)` – shorthand for `promise.then(null, errorCallback)` + * + * - `finally(callback)` – allows you to observe either the fulfillment or rejection of a promise, + * but to do so without modifying the final value. This is useful to release resources or do some + * clean-up that needs to be done whether the promise was rejected or resolved. See the [full + * specification](https://github.com/kriskowal/q/wiki/API-Reference#promisefinallycallback) for + * more information. + * + * Because `finally` is a reserved word in JavaScript and reserved keywords are not supported as + * property names by ES3, you'll need to invoke the method like `promise['finally'](callback)` to + * make your code IE8 and Android 2.x compatible. + * + * # Chaining promises + * + * Because calling the `then` method of a promise returns a new derived promise, it is easily + * possible to create a chain of promises: + * + * ```js + * promiseB = promiseA.then(function(result) { + * return result + 1; + * }); + * + * // promiseB will be resolved immediately after promiseA is resolved and its value + * // will be the result of promiseA incremented by 1 + * ``` + * + * It is possible to create chains of any length and since a promise can be resolved with another + * promise (which will defer its resolution further), it is possible to pause/defer resolution of + * the promises at any point in the chain. This makes it possible to implement powerful APIs like + * $http's response interceptors. + * + * + * # Differences between Kris Kowal's Q and $q + * + * There are two main differences: + * + * - $q is integrated with the {@link ng.$rootScope.Scope} Scope model observation + * mechanism in angular, which means faster propagation of resolution or rejection into your + * models and avoiding unnecessary browser repaints, which would result in flickering UI. + * - Q has many more features than $q, but that comes at a cost of bytes. $q is tiny, but contains + * all the important functionality needed for common async tasks. + * + * # Testing + * + * ```js + * it('should simulate promise', inject(function($q, $rootScope) { + * var deferred = $q.defer(); + * var promise = deferred.promise; + * var resolvedValue; + * + * promise.then(function(value) { resolvedValue = value; }); + * expect(resolvedValue).toBeUndefined(); + * + * // Simulate resolving of promise + * deferred.resolve(123); + * // Note that the 'then' function does not get called synchronously. + * // This is because we want the promise API to always be async, whether or not + * // it got called synchronously or asynchronously. + * expect(resolvedValue).toBeUndefined(); + * + * // Propagate promise resolution to 'then' functions using $apply(). + * $rootScope.$apply(); + * expect(resolvedValue).toEqual(123); + * })); + * ``` + */ +function $QProvider() { + + this.$get = ['$rootScope', '$exceptionHandler', function($rootScope, $exceptionHandler) { + return qFactory(function(callback) { + $rootScope.$evalAsync(callback); + }, $exceptionHandler); + }]; +} + + +/** + * Constructs a promise manager. + * + * @param {function(Function)} nextTick Function for executing functions in the next turn. + * @param {function(...*)} exceptionHandler Function into which unexpected exceptions are passed for + * debugging purposes. + * @returns {object} Promise manager. + */ +function qFactory(nextTick, exceptionHandler) { + + /** + * @ngdoc method + * @name $q#defer + * @kind function + * + * @description + * Creates a `Deferred` object which represents a task which will finish in the future. + * + * @returns {Deferred} Returns a new instance of deferred. + */ + var defer = function() { + var pending = [], + value, deferred; + + deferred = { + + resolve: function(val) { + if (pending) { + var callbacks = pending; + pending = undefined; + value = ref(val); + + if (callbacks.length) { + nextTick(function() { + var callback; + for (var i = 0, ii = callbacks.length; i < ii; i++) { + callback = callbacks[i]; + value.then(callback[0], callback[1], callback[2]); + } + }); + } + } + }, + + + reject: function(reason) { + deferred.resolve(createInternalRejectedPromise(reason)); + }, + + + notify: function(progress) { + if (pending) { + var callbacks = pending; + + if (pending.length) { + nextTick(function() { + var callback; + for (var i = 0, ii = callbacks.length; i < ii; i++) { + callback = callbacks[i]; + callback[2](progress); + } + }); + } + } + }, + + + promise: { + then: function(callback, errback, progressback) { + var result = defer(); + + var wrappedCallback = function(value) { + try { + result.resolve((isFunction(callback) ? callback : defaultCallback)(value)); + } catch(e) { + result.reject(e); + exceptionHandler(e); + } + }; + + var wrappedErrback = function(reason) { + try { + result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); + } catch(e) { + result.reject(e); + exceptionHandler(e); + } + }; + + var wrappedProgressback = function(progress) { + try { + result.notify((isFunction(progressback) ? progressback : defaultCallback)(progress)); + } catch(e) { + exceptionHandler(e); + } + }; + + if (pending) { + pending.push([wrappedCallback, wrappedErrback, wrappedProgressback]); + } else { + value.then(wrappedCallback, wrappedErrback, wrappedProgressback); + } + + return result.promise; + }, + + "catch": function(callback) { + return this.then(null, callback); + }, + + "finally": function(callback) { + + function makePromise(value, resolved) { + var result = defer(); + if (resolved) { + result.resolve(value); + } else { + result.reject(value); + } + return result.promise; + } + + function handleCallback(value, isResolved) { + var callbackOutput = null; + try { + callbackOutput = (callback ||defaultCallback)(); + } catch(e) { + return makePromise(e, false); + } + if (callbackOutput && isFunction(callbackOutput.then)) { + return callbackOutput.then(function() { + return makePromise(value, isResolved); + }, function(error) { + return makePromise(error, false); + }); + } else { + return makePromise(value, isResolved); + } + } + + return this.then(function(value) { + return handleCallback(value, true); + }, function(error) { + return handleCallback(error, false); + }); + } + } + }; + + return deferred; + }; + + + var ref = function(value) { + if (value && isFunction(value.then)) return value; + return { + then: function(callback) { + var result = defer(); + nextTick(function() { + result.resolve(callback(value)); + }); + return result.promise; + } + }; + }; + + + /** + * @ngdoc method + * @name $q#reject + * @kind function + * + * @description + * Creates a promise that is resolved as rejected with the specified `reason`. This api should be + * used to forward rejection in a chain of promises. If you are dealing with the last promise in + * a promise chain, you don't need to worry about it. + * + * When comparing deferreds/promises to the familiar behavior of try/catch/throw, think of + * `reject` as the `throw` keyword in JavaScript. This also means that if you "catch" an error via + * a promise error callback and you want to forward the error to the promise derived from the + * current promise, you have to "rethrow" the error by returning a rejection constructed via + * `reject`. + * + * ```js + * promiseB = promiseA.then(function(result) { + * // success: do something and resolve promiseB + * // with the old or a new result + * return result; + * }, function(reason) { + * // error: handle the error if possible and + * // resolve promiseB with newPromiseOrValue, + * // otherwise forward the rejection to promiseB + * if (canHandle(reason)) { + * // handle the error and recover + * return newPromiseOrValue; + * } + * return $q.reject(reason); + * }); + * ``` + * + * @param {*} reason Constant, message, exception or an object representing the rejection reason. + * @returns {Promise} Returns a promise that was already resolved as rejected with the `reason`. + */ + var reject = function(reason) { + var result = defer(); + result.reject(reason); + return result.promise; + }; + + var createInternalRejectedPromise = function(reason) { + return { + then: function(callback, errback) { + var result = defer(); + nextTick(function() { + try { + result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); + } catch(e) { + result.reject(e); + exceptionHandler(e); + } + }); + return result.promise; + } + }; + }; + + + /** + * @ngdoc method + * @name $q#when + * @kind function + * + * @description + * Wraps an object that might be a value or a (3rd party) then-able promise into a $q promise. + * This is useful when you are dealing with an object that might or might not be a promise, or if + * the promise comes from a source that can't be trusted. + * + * @param {*} value Value or a promise + * @returns {Promise} Returns a promise of the passed value or promise + */ + var when = function(value, callback, errback, progressback) { + var result = defer(), + done; + + var wrappedCallback = function(value) { + try { + return (isFunction(callback) ? callback : defaultCallback)(value); + } catch (e) { + exceptionHandler(e); + return reject(e); + } + }; + + var wrappedErrback = function(reason) { + try { + return (isFunction(errback) ? errback : defaultErrback)(reason); + } catch (e) { + exceptionHandler(e); + return reject(e); + } + }; + + var wrappedProgressback = function(progress) { + try { + return (isFunction(progressback) ? progressback : defaultCallback)(progress); + } catch (e) { + exceptionHandler(e); + } + }; + + nextTick(function() { + ref(value).then(function(value) { + if (done) return; + done = true; + result.resolve(ref(value).then(wrappedCallback, wrappedErrback, wrappedProgressback)); + }, function(reason) { + if (done) return; + done = true; + result.resolve(wrappedErrback(reason)); + }, function(progress) { + if (done) return; + result.notify(wrappedProgressback(progress)); + }); + }); + + return result.promise; + }; + + + function defaultCallback(value) { + return value; + } + + + function defaultErrback(reason) { + return reject(reason); + } + + + /** + * @ngdoc method + * @name $q#all + * @kind function + * + * @description + * Combines multiple promises into a single promise that is resolved when all of the input + * promises are resolved. + * + * @param {Array.|Object.} promises An array or hash of promises. + * @returns {Promise} Returns a single promise that will be resolved with an array/hash of values, + * each value corresponding to the promise at the same index/key in the `promises` array/hash. + * If any of the promises is resolved with a rejection, this resulting promise will be rejected + * with the same rejection value. + */ + function all(promises) { + var deferred = defer(), + counter = 0, + results = isArray(promises) ? [] : {}; + + forEach(promises, function(promise, key) { + counter++; + ref(promise).then(function(value) { + if (results.hasOwnProperty(key)) return; + results[key] = value; + if (!(--counter)) deferred.resolve(results); + }, function(reason) { + if (results.hasOwnProperty(key)) return; + deferred.reject(reason); + }); + }); + + if (counter === 0) { + deferred.resolve(results); + } + + return deferred.promise; + } + + return { + defer: defer, + reject: reject, + when: when, + all: all + }; +} + +function $$RAFProvider(){ //rAF + this.$get = ['$window', '$timeout', function($window, $timeout) { + var requestAnimationFrame = $window.requestAnimationFrame || + $window.webkitRequestAnimationFrame || + $window.mozRequestAnimationFrame; + + var cancelAnimationFrame = $window.cancelAnimationFrame || + $window.webkitCancelAnimationFrame || + $window.mozCancelAnimationFrame || + $window.webkitCancelRequestAnimationFrame; + + var rafSupported = !!requestAnimationFrame; + var raf = rafSupported + ? function(fn) { + var id = requestAnimationFrame(fn); + return function() { + cancelAnimationFrame(id); + }; + } + : function(fn) { + var timer = $timeout(fn, 16.66, false); // 1000 / 60 = 16.666 + return function() { + $timeout.cancel(timer); + }; + }; + + raf.supported = rafSupported; + + return raf; + }]; +} + +/** + * DESIGN NOTES + * + * The design decisions behind the scope are heavily favored for speed and memory consumption. + * + * The typical use of scope is to watch the expressions, which most of the time return the same + * value as last time so we optimize the operation. + * + * Closures construction is expensive in terms of speed as well as memory: + * - No closures, instead use prototypical inheritance for API + * - Internal state needs to be stored on scope directly, which means that private state is + * exposed as $$____ properties + * + * Loop operations are optimized by using while(count--) { ... } + * - this means that in order to keep the same order of execution as addition we have to add + * items to the array at the beginning (unshift) instead of at the end (push) + * + * Child scopes are created and removed often + * - Using an array would be slow since inserts in middle are expensive so we use linked list + * + * There are few watches then a lot of observers. This is why you don't want the observer to be + * implemented in the same way as watch. Watch requires return of initialization function which + * are expensive to construct. + */ + + +/** + * @ngdoc provider + * @name $rootScopeProvider + * @description + * + * Provider for the $rootScope service. + */ + +/** + * @ngdoc method + * @name $rootScopeProvider#digestTtl + * @description + * + * Sets the number of `$digest` iterations the scope should attempt to execute before giving up and + * assuming that the model is unstable. + * + * The current default is 10 iterations. + * + * In complex applications it's possible that the dependencies between `$watch`s will result in + * several digest iterations. However if an application needs more than the default 10 digest + * iterations for its model to stabilize then you should investigate what is causing the model to + * continuously change during the digest. + * + * Increasing the TTL could have performance implications, so you should not change it without + * proper justification. + * + * @param {number} limit The number of digest iterations. + */ + + +/** + * @ngdoc service + * @name $rootScope + * @description + * + * Every application has a single root {@link ng.$rootScope.Scope scope}. + * All other scopes are descendant scopes of the root scope. Scopes provide separation + * between the model and the view, via a mechanism for watching the model for changes. + * They also provide an event emission/broadcast and subscription facility. See the + * {@link guide/scope developer guide on scopes}. + */ +function $RootScopeProvider(){ + var TTL = 10; + var $rootScopeMinErr = minErr('$rootScope'); + var lastDirtyWatch = null; + + this.digestTtl = function(value) { + if (arguments.length) { + TTL = value; + } + return TTL; + }; + + this.$get = ['$injector', '$exceptionHandler', '$parse', '$browser', + function( $injector, $exceptionHandler, $parse, $browser) { + + /** + * @ngdoc type + * @name $rootScope.Scope + * + * @description + * A root scope can be retrieved using the {@link ng.$rootScope $rootScope} key from the + * {@link auto.$injector $injector}. Child scopes are created using the + * {@link ng.$rootScope.Scope#$new $new()} method. (Most scopes are created automatically when + * compiled HTML template is executed.) + * + * Here is a simple scope snippet to show how you can interact with the scope. + * ```html + * + * ``` + * + * # Inheritance + * A scope can inherit from a parent scope, as in this example: + * ```js + var parent = $rootScope; + var child = parent.$new(); + + parent.salutation = "Hello"; + child.name = "World"; + expect(child.salutation).toEqual('Hello'); + + child.salutation = "Welcome"; + expect(child.salutation).toEqual('Welcome'); + expect(parent.salutation).toEqual('Hello'); + * ``` + * + * + * @param {Object.=} providers Map of service factory which need to be + * provided for the current scope. Defaults to {@link ng}. + * @param {Object.=} instanceCache Provides pre-instantiated services which should + * append/override services provided by `providers`. This is handy + * when unit-testing and having the need to override a default + * service. + * @returns {Object} Newly created scope. + * + */ + function Scope() { + this.$id = nextUid(); + this.$$phase = this.$parent = this.$$watchers = + this.$$nextSibling = this.$$prevSibling = + this.$$childHead = this.$$childTail = null; + this['this'] = this.$root = this; + this.$$destroyed = false; + this.$$asyncQueue = []; + this.$$postDigestQueue = []; + this.$$listeners = {}; + this.$$listenerCount = {}; + this.$$isolateBindings = {}; + } + + /** + * @ngdoc property + * @name $rootScope.Scope#$id + * @returns {number} Unique scope ID (monotonically increasing alphanumeric sequence) useful for + * debugging. + */ + + + Scope.prototype = { + constructor: Scope, + /** + * @ngdoc method + * @name $rootScope.Scope#$new + * @kind function + * + * @description + * Creates a new child {@link ng.$rootScope.Scope scope}. + * + * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} and + * {@link ng.$rootScope.Scope#$digest $digest()} events. The scope can be removed from the + * scope hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. + * + * {@link ng.$rootScope.Scope#$destroy $destroy()} must be called on a scope when it is + * desired for the scope and its child scopes to be permanently detached from the parent and + * thus stop participating in model change detection and listener notification by invoking. + * + * @param {boolean} isolate If true, then the scope does not prototypically inherit from the + * parent scope. The scope is isolated, as it can not see parent scope properties. + * When creating widgets, it is useful for the widget to not accidentally read parent + * state. + * + * @returns {Object} The newly created child scope. + * + */ + $new: function(isolate) { + var ChildScope, + child; + + if (isolate) { + child = new Scope(); + child.$root = this.$root; + // ensure that there is just one async queue per $rootScope and its children + child.$$asyncQueue = this.$$asyncQueue; + child.$$postDigestQueue = this.$$postDigestQueue; + } else { + // Only create a child scope class if somebody asks for one, + // but cache it to allow the VM to optimize lookups. + if (!this.$$childScopeClass) { + this.$$childScopeClass = function() { + this.$$watchers = this.$$nextSibling = + this.$$childHead = this.$$childTail = null; + this.$$listeners = {}; + this.$$listenerCount = {}; + this.$id = nextUid(); + this.$$childScopeClass = null; + }; + this.$$childScopeClass.prototype = this; + } + child = new this.$$childScopeClass(); + } + child['this'] = child; + child.$parent = this; + child.$$prevSibling = this.$$childTail; + if (this.$$childHead) { + this.$$childTail.$$nextSibling = child; + this.$$childTail = child; + } else { + this.$$childHead = this.$$childTail = child; + } + return child; + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$watch + * @kind function + * + * @description + * Registers a `listener` callback to be executed whenever the `watchExpression` changes. + * + * - The `watchExpression` is called on every call to {@link ng.$rootScope.Scope#$digest + * $digest()} and should return the value that will be watched. (Since + * {@link ng.$rootScope.Scope#$digest $digest()} reruns when it detects changes the + * `watchExpression` can execute multiple times per + * {@link ng.$rootScope.Scope#$digest $digest()} and should be idempotent.) + * - The `listener` is called only when the value from the current `watchExpression` and the + * previous call to `watchExpression` are not equal (with the exception of the initial run, + * see below). Inequality is determined according to reference inequality, + * [strict comparison](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comparison_Operators) + * via the `!==` Javascript operator, unless `objectEquality == true` + * (see next point) + * - When `objectEquality == true`, inequality of the `watchExpression` is determined + * according to the {@link angular.equals} function. To save the value of the object for + * later comparison, the {@link angular.copy} function is used. This therefore means that + * watching complex objects will have adverse memory and performance implications. + * - The watch `listener` may change the model, which may trigger other `listener`s to fire. + * This is achieved by rerunning the watchers until no changes are detected. The rerun + * iteration limit is 10 to prevent an infinite loop deadlock. + * + * + * If you want to be notified whenever {@link ng.$rootScope.Scope#$digest $digest} is called, + * you can register a `watchExpression` function with no `listener`. (Since `watchExpression` + * can execute multiple times per {@link ng.$rootScope.Scope#$digest $digest} cycle when a + * change is detected, be prepared for multiple calls to your listener.) + * + * After a watcher is registered with the scope, the `listener` fn is called asynchronously + * (via {@link ng.$rootScope.Scope#$evalAsync $evalAsync}) to initialize the + * watcher. In rare cases, this is undesirable because the listener is called when the result + * of `watchExpression` didn't change. To detect this scenario within the `listener` fn, you + * can compare the `newVal` and `oldVal`. If these two values are identical (`===`) then the + * listener was called due to initialization. + * + * The example below contains an illustration of using a function as your $watch listener + * + * + * # Example + * ```js + // let's assume that scope was dependency injected as the $rootScope + var scope = $rootScope; + scope.name = 'misko'; + scope.counter = 0; + + expect(scope.counter).toEqual(0); + scope.$watch('name', function(newValue, oldValue) { + scope.counter = scope.counter + 1; + }); + expect(scope.counter).toEqual(0); + + scope.$digest(); + // the listener is always called during the first $digest loop after it was registered + expect(scope.counter).toEqual(1); + + scope.$digest(); + // but now it will not be called unless the value changes + expect(scope.counter).toEqual(1); + + scope.name = 'adam'; + scope.$digest(); + expect(scope.counter).toEqual(2); + + + + // Using a listener function + var food; + scope.foodCounter = 0; + expect(scope.foodCounter).toEqual(0); + scope.$watch( + // This is the listener function + function() { return food; }, + // This is the change handler + function(newValue, oldValue) { + if ( newValue !== oldValue ) { + // Only increment the counter if the value changed + scope.foodCounter = scope.foodCounter + 1; + } + } + ); + // No digest has been run so the counter will be zero + expect(scope.foodCounter).toEqual(0); + + // Run the digest but since food has not changed count will still be zero + scope.$digest(); + expect(scope.foodCounter).toEqual(0); + + // Update food and run digest. Now the counter will increment + food = 'cheeseburger'; + scope.$digest(); + expect(scope.foodCounter).toEqual(1); + + * ``` + * + * + * + * @param {(function()|string)} watchExpression Expression that is evaluated on each + * {@link ng.$rootScope.Scope#$digest $digest} cycle. A change in the return value triggers + * a call to the `listener`. + * + * - `string`: Evaluated as {@link guide/expression expression} + * - `function(scope)`: called with current `scope` as a parameter. + * @param {(function()|string)=} listener Callback called whenever the return value of + * the `watchExpression` changes. + * + * - `string`: Evaluated as {@link guide/expression expression} + * - `function(newValue, oldValue, scope)`: called with current and previous values as + * parameters. + * + * @param {boolean=} objectEquality Compare for object equality using {@link angular.equals} instead of + * comparing for reference equality. + * @returns {function()} Returns a deregistration function for this listener. + */ + $watch: function(watchExp, listener, objectEquality) { + var scope = this, + get = compileToFn(watchExp, 'watch'), + array = scope.$$watchers, + watcher = { + fn: listener, + last: initWatchVal, + get: get, + exp: watchExp, + eq: !!objectEquality + }; + + lastDirtyWatch = null; + + // in the case user pass string, we need to compile it, do we really need this ? + if (!isFunction(listener)) { + var listenFn = compileToFn(listener || noop, 'listener'); + watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);}; + } + + if (typeof watchExp == 'string' && get.constant) { + var originalFn = watcher.fn; + watcher.fn = function(newVal, oldVal, scope) { + originalFn.call(this, newVal, oldVal, scope); + arrayRemove(array, watcher); + }; + } + + if (!array) { + array = scope.$$watchers = []; + } + // we use unshift since we use a while loop in $digest for speed. + // the while loop reads in reverse order. + array.unshift(watcher); + + return function deregisterWatch() { + arrayRemove(array, watcher); + lastDirtyWatch = null; + }; + }, + + + /** + * @ngdoc method + * @name $rootScope.Scope#$watchCollection + * @kind function + * + * @description + * Shallow watches the properties of an object and fires whenever any of the properties change + * (for arrays, this implies watching the array items; for object maps, this implies watching + * the properties). If a change is detected, the `listener` callback is fired. + * + * - The `obj` collection is observed via standard $watch operation and is examined on every + * call to $digest() to see if any items have been added, removed, or moved. + * - The `listener` is called whenever anything within the `obj` has changed. Examples include + * adding, removing, and moving items belonging to an object or array. + * + * + * # Example + * ```js + $scope.names = ['igor', 'matias', 'misko', 'james']; + $scope.dataCount = 4; + + $scope.$watchCollection('names', function(newNames, oldNames) { + $scope.dataCount = newNames.length; + }); + + expect($scope.dataCount).toEqual(4); + $scope.$digest(); + + //still at 4 ... no changes + expect($scope.dataCount).toEqual(4); + + $scope.names.pop(); + $scope.$digest(); + + //now there's been a change + expect($scope.dataCount).toEqual(3); + * ``` + * + * + * @param {string|function(scope)} obj Evaluated as {@link guide/expression expression}. The + * expression value should evaluate to an object or an array which is observed on each + * {@link ng.$rootScope.Scope#$digest $digest} cycle. Any shallow change within the + * collection will trigger a call to the `listener`. + * + * @param {function(newCollection, oldCollection, scope)} listener a callback function called + * when a change is detected. + * - The `newCollection` object is the newly modified data obtained from the `obj` expression + * - The `oldCollection` object is a copy of the former collection data. + * Due to performance considerations, the`oldCollection` value is computed only if the + * `listener` function declares two or more arguments. + * - The `scope` argument refers to the current scope. + * + * @returns {function()} Returns a de-registration function for this listener. When the + * de-registration function is executed, the internal watch operation is terminated. + */ + $watchCollection: function(obj, listener) { + var self = this; + // the current value, updated on each dirty-check run + var newValue; + // a shallow copy of the newValue from the last dirty-check run, + // updated to match newValue during dirty-check run + var oldValue; + // a shallow copy of the newValue from when the last change happened + var veryOldValue; + // only track veryOldValue if the listener is asking for it + var trackVeryOldValue = (listener.length > 1); + var changeDetected = 0; + var objGetter = $parse(obj); + var internalArray = []; + var internalObject = {}; + var initRun = true; + var oldLength = 0; + + function $watchCollectionWatch() { + newValue = objGetter(self); + var newLength, key; + + if (!isObject(newValue)) { // if primitive + if (oldValue !== newValue) { + oldValue = newValue; + changeDetected++; + } + } else if (isArrayLike(newValue)) { + if (oldValue !== internalArray) { + // we are transitioning from something which was not an array into array. + oldValue = internalArray; + oldLength = oldValue.length = 0; + changeDetected++; + } + + newLength = newValue.length; + + if (oldLength !== newLength) { + // if lengths do not match we need to trigger change notification + changeDetected++; + oldValue.length = oldLength = newLength; + } + // copy the items to oldValue and look for changes. + for (var i = 0; i < newLength; i++) { + var bothNaN = (oldValue[i] !== oldValue[i]) && + (newValue[i] !== newValue[i]); + if (!bothNaN && (oldValue[i] !== newValue[i])) { + changeDetected++; + oldValue[i] = newValue[i]; + } + } + } else { + if (oldValue !== internalObject) { + // we are transitioning from something which was not an object into object. + oldValue = internalObject = {}; + oldLength = 0; + changeDetected++; + } + // copy the items to oldValue and look for changes. + newLength = 0; + for (key in newValue) { + if (newValue.hasOwnProperty(key)) { + newLength++; + if (oldValue.hasOwnProperty(key)) { + if (oldValue[key] !== newValue[key]) { + changeDetected++; + oldValue[key] = newValue[key]; + } + } else { + oldLength++; + oldValue[key] = newValue[key]; + changeDetected++; + } + } + } + if (oldLength > newLength) { + // we used to have more keys, need to find them and destroy them. + changeDetected++; + for(key in oldValue) { + if (oldValue.hasOwnProperty(key) && !newValue.hasOwnProperty(key)) { + oldLength--; + delete oldValue[key]; + } + } + } + } + return changeDetected; + } + + function $watchCollectionAction() { + if (initRun) { + initRun = false; + listener(newValue, newValue, self); + } else { + listener(newValue, veryOldValue, self); + } + + // make a copy for the next time a collection is changed + if (trackVeryOldValue) { + if (!isObject(newValue)) { + //primitive + veryOldValue = newValue; + } else if (isArrayLike(newValue)) { + veryOldValue = new Array(newValue.length); + for (var i = 0; i < newValue.length; i++) { + veryOldValue[i] = newValue[i]; + } + } else { // if object + veryOldValue = {}; + for (var key in newValue) { + if (hasOwnProperty.call(newValue, key)) { + veryOldValue[key] = newValue[key]; + } + } + } + } + } + + return this.$watch($watchCollectionWatch, $watchCollectionAction); + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$digest + * @kind function + * + * @description + * Processes all of the {@link ng.$rootScope.Scope#$watch watchers} of the current scope and + * its children. Because a {@link ng.$rootScope.Scope#$watch watcher}'s listener can change + * the model, the `$digest()` keeps calling the {@link ng.$rootScope.Scope#$watch watchers} + * until no more listeners are firing. This means that it is possible to get into an infinite + * loop. This function will throw `'Maximum iteration limit exceeded.'` if the number of + * iterations exceeds 10. + * + * Usually, you don't call `$digest()` directly in + * {@link ng.directive:ngController controllers} or in + * {@link ng.$compileProvider#directive directives}. + * Instead, you should call {@link ng.$rootScope.Scope#$apply $apply()} (typically from within + * a {@link ng.$compileProvider#directive directives}), which will force a `$digest()`. + * + * If you want to be notified whenever `$digest()` is called, + * you can register a `watchExpression` function with + * {@link ng.$rootScope.Scope#$watch $watch()} with no `listener`. + * + * In unit tests, you may need to call `$digest()` to simulate the scope life cycle. + * + * # Example + * ```js + var scope = ...; + scope.name = 'misko'; + scope.counter = 0; + + expect(scope.counter).toEqual(0); + scope.$watch('name', function(newValue, oldValue) { + scope.counter = scope.counter + 1; + }); + expect(scope.counter).toEqual(0); + + scope.$digest(); + // the listener is always called during the first $digest loop after it was registered + expect(scope.counter).toEqual(1); + + scope.$digest(); + // but now it will not be called unless the value changes + expect(scope.counter).toEqual(1); + + scope.name = 'adam'; + scope.$digest(); + expect(scope.counter).toEqual(2); + * ``` + * + */ + $digest: function() { + var watch, value, last, + watchers, + asyncQueue = this.$$asyncQueue, + postDigestQueue = this.$$postDigestQueue, + length, + dirty, ttl = TTL, + next, current, target = this, + watchLog = [], + logIdx, logMsg, asyncTask; + + beginPhase('$digest'); + + lastDirtyWatch = null; + + do { // "while dirty" loop + dirty = false; + current = target; + + while(asyncQueue.length) { + try { + asyncTask = asyncQueue.shift(); + asyncTask.scope.$eval(asyncTask.expression); + } catch (e) { + clearPhase(); + $exceptionHandler(e); + } + lastDirtyWatch = null; + } + + traverseScopesLoop: + do { // "traverse the scopes" loop + if ((watchers = current.$$watchers)) { + // process our watches + length = watchers.length; + while (length--) { + try { + watch = watchers[length]; + // Most common watches are on primitives, in which case we can short + // circuit it with === operator, only when === fails do we use .equals + if (watch) { + if ((value = watch.get(current)) !== (last = watch.last) && + !(watch.eq + ? equals(value, last) + : (typeof value === 'number' && typeof last === 'number' + && isNaN(value) && isNaN(last)))) { + dirty = true; + lastDirtyWatch = watch; + watch.last = watch.eq ? copy(value, null) : value; + watch.fn(value, ((last === initWatchVal) ? value : last), current); + if (ttl < 5) { + logIdx = 4 - ttl; + if (!watchLog[logIdx]) watchLog[logIdx] = []; + logMsg = (isFunction(watch.exp)) + ? 'fn: ' + (watch.exp.name || watch.exp.toString()) + : watch.exp; + logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last); + watchLog[logIdx].push(logMsg); + } + } else if (watch === lastDirtyWatch) { + // If the most recently dirty watcher is now clean, short circuit since the remaining watchers + // have already been tested. + dirty = false; + break traverseScopesLoop; + } + } + } catch (e) { + clearPhase(); + $exceptionHandler(e); + } + } + } + + // Insanity Warning: scope depth-first traversal + // yes, this code is a bit crazy, but it works and we have tests to prove it! + // this piece should be kept in sync with the traversal in $broadcast + if (!(next = (current.$$childHead || + (current !== target && current.$$nextSibling)))) { + while(current !== target && !(next = current.$$nextSibling)) { + current = current.$parent; + } + } + } while ((current = next)); + + // `break traverseScopesLoop;` takes us to here + + if((dirty || asyncQueue.length) && !(ttl--)) { + clearPhase(); + throw $rootScopeMinErr('infdig', + '{0} $digest() iterations reached. Aborting!\n' + + 'Watchers fired in the last 5 iterations: {1}', + TTL, toJson(watchLog)); + } + + } while (dirty || asyncQueue.length); + + clearPhase(); + + while(postDigestQueue.length) { + try { + postDigestQueue.shift()(); + } catch (e) { + $exceptionHandler(e); + } + } + }, + + + /** + * @ngdoc event + * @name $rootScope.Scope#$destroy + * @eventType broadcast on scope being destroyed + * + * @description + * Broadcasted when a scope and its children are being destroyed. + * + * Note that, in AngularJS, there is also a `$destroy` jQuery event, which can be used to + * clean up DOM bindings before an element is removed from the DOM. + */ + + /** + * @ngdoc method + * @name $rootScope.Scope#$destroy + * @kind function + * + * @description + * Removes the current scope (and all of its children) from the parent scope. Removal implies + * that calls to {@link ng.$rootScope.Scope#$digest $digest()} will no longer + * propagate to the current scope and its children. Removal also implies that the current + * scope is eligible for garbage collection. + * + * The `$destroy()` is usually used by directives such as + * {@link ng.directive:ngRepeat ngRepeat} for managing the + * unrolling of the loop. + * + * Just before a scope is destroyed, a `$destroy` event is broadcasted on this scope. + * Application code can register a `$destroy` event handler that will give it a chance to + * perform any necessary cleanup. + * + * Note that, in AngularJS, there is also a `$destroy` jQuery event, which can be used to + * clean up DOM bindings before an element is removed from the DOM. + */ + $destroy: function() { + // we can't destroy the root scope or a scope that has been already destroyed + if (this.$$destroyed) return; + var parent = this.$parent; + + this.$broadcast('$destroy'); + this.$$destroyed = true; + if (this === $rootScope) return; + + forEach(this.$$listenerCount, bind(null, decrementListenerCount, this)); + + // sever all the references to parent scopes (after this cleanup, the current scope should + // not be retained by any of our references and should be eligible for garbage collection) + if (parent.$$childHead == this) parent.$$childHead = this.$$nextSibling; + if (parent.$$childTail == this) parent.$$childTail = this.$$prevSibling; + if (this.$$prevSibling) this.$$prevSibling.$$nextSibling = this.$$nextSibling; + if (this.$$nextSibling) this.$$nextSibling.$$prevSibling = this.$$prevSibling; + + + // All of the code below is bogus code that works around V8's memory leak via optimized code + // and inline caches. + // + // see: + // - https://code.google.com/p/v8/issues/detail?id=2073#c26 + // - https://github.com/angular/angular.js/issues/6794#issuecomment-38648909 + // - https://github.com/angular/angular.js/issues/1313#issuecomment-10378451 + + this.$parent = this.$$nextSibling = this.$$prevSibling = this.$$childHead = + this.$$childTail = this.$root = null; + + // don't reset these to null in case some async task tries to register a listener/watch/task + this.$$listeners = {}; + this.$$watchers = this.$$asyncQueue = this.$$postDigestQueue = []; + + // prevent NPEs since these methods have references to properties we nulled out + this.$destroy = this.$digest = this.$apply = noop; + this.$on = this.$watch = function() { return noop; }; + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$eval + * @kind function + * + * @description + * Executes the `expression` on the current scope and returns the result. Any exceptions in + * the expression are propagated (uncaught). This is useful when evaluating Angular + * expressions. + * + * # Example + * ```js + var scope = ng.$rootScope.Scope(); + scope.a = 1; + scope.b = 2; + + expect(scope.$eval('a+b')).toEqual(3); + expect(scope.$eval(function(scope){ return scope.a + scope.b; })).toEqual(3); + * ``` + * + * @param {(string|function())=} expression An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with the current `scope` parameter. + * + * @param {(object)=} locals Local variables object, useful for overriding values in scope. + * @returns {*} The result of evaluating the expression. + */ + $eval: function(expr, locals) { + return $parse(expr)(this, locals); + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$evalAsync + * @kind function + * + * @description + * Executes the expression on the current scope at a later point in time. + * + * The `$evalAsync` makes no guarantees as to when the `expression` will be executed, only + * that: + * + * - it will execute after the function that scheduled the evaluation (preferably before DOM + * rendering). + * - at least one {@link ng.$rootScope.Scope#$digest $digest cycle} will be performed after + * `expression` execution. + * + * Any exceptions from the execution of the expression are forwarded to the + * {@link ng.$exceptionHandler $exceptionHandler} service. + * + * __Note:__ if this function is called outside of a `$digest` cycle, a new `$digest` cycle + * will be scheduled. However, it is encouraged to always call code that changes the model + * from within an `$apply` call. That includes code evaluated via `$evalAsync`. + * + * @param {(string|function())=} expression An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with the current `scope` parameter. + * + */ + $evalAsync: function(expr) { + // if we are outside of an $digest loop and this is the first time we are scheduling async + // task also schedule async auto-flush + if (!$rootScope.$$phase && !$rootScope.$$asyncQueue.length) { + $browser.defer(function() { + if ($rootScope.$$asyncQueue.length) { + $rootScope.$digest(); + } + }); + } + + this.$$asyncQueue.push({scope: this, expression: expr}); + }, + + $$postDigest : function(fn) { + this.$$postDigestQueue.push(fn); + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$apply + * @kind function + * + * @description + * `$apply()` is used to execute an expression in angular from outside of the angular + * framework. (For example from browser DOM events, setTimeout, XHR or third party libraries). + * Because we are calling into the angular framework we need to perform proper scope life + * cycle of {@link ng.$exceptionHandler exception handling}, + * {@link ng.$rootScope.Scope#$digest executing watches}. + * + * ## Life cycle + * + * # Pseudo-Code of `$apply()` + * ```js + function $apply(expr) { + try { + return $eval(expr); + } catch (e) { + $exceptionHandler(e); + } finally { + $root.$digest(); + } + } + * ``` + * + * + * Scope's `$apply()` method transitions through the following stages: + * + * 1. The {@link guide/expression expression} is executed using the + * {@link ng.$rootScope.Scope#$eval $eval()} method. + * 2. Any exceptions from the execution of the expression are forwarded to the + * {@link ng.$exceptionHandler $exceptionHandler} service. + * 3. The {@link ng.$rootScope.Scope#$watch watch} listeners are fired immediately after the + * expression was executed using the {@link ng.$rootScope.Scope#$digest $digest()} method. + * + * + * @param {(string|function())=} exp An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + * + * @returns {*} The result of evaluating the expression. + */ + $apply: function(expr) { + try { + beginPhase('$apply'); + return this.$eval(expr); + } catch (e) { + $exceptionHandler(e); + } finally { + clearPhase(); + try { + $rootScope.$digest(); + } catch (e) { + $exceptionHandler(e); + throw e; + } + } + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$on + * @kind function + * + * @description + * Listens on events of a given type. See {@link ng.$rootScope.Scope#$emit $emit} for + * discussion of event life cycle. + * + * The event listener function format is: `function(event, args...)`. The `event` object + * passed into the listener has the following attributes: + * + * - `targetScope` - `{Scope}`: the scope on which the event was `$emit`-ed or + * `$broadcast`-ed. + * - `currentScope` - `{Scope}`: the current scope which is handling the event. + * - `name` - `{string}`: name of the event. + * - `stopPropagation` - `{function=}`: calling `stopPropagation` function will cancel + * further event propagation (available only for events that were `$emit`-ed). + * - `preventDefault` - `{function}`: calling `preventDefault` sets `defaultPrevented` flag + * to true. + * - `defaultPrevented` - `{boolean}`: true if `preventDefault` was called. + * + * @param {string} name Event name to listen on. + * @param {function(event, ...args)} listener Function to call when the event is emitted. + * @returns {function()} Returns a deregistration function for this listener. + */ + $on: function(name, listener) { + var namedListeners = this.$$listeners[name]; + if (!namedListeners) { + this.$$listeners[name] = namedListeners = []; + } + namedListeners.push(listener); + + var current = this; + do { + if (!current.$$listenerCount[name]) { + current.$$listenerCount[name] = 0; + } + current.$$listenerCount[name]++; + } while ((current = current.$parent)); + + var self = this; + return function() { + namedListeners[indexOf(namedListeners, listener)] = null; + decrementListenerCount(self, 1, name); + }; + }, + + + /** + * @ngdoc method + * @name $rootScope.Scope#$emit + * @kind function + * + * @description + * Dispatches an event `name` upwards through the scope hierarchy notifying the + * registered {@link ng.$rootScope.Scope#$on} listeners. + * + * The event life cycle starts at the scope on which `$emit` was called. All + * {@link ng.$rootScope.Scope#$on listeners} listening for `name` event on this scope get + * notified. Afterwards, the event traverses upwards toward the root scope and calls all + * registered listeners along the way. The event will stop propagating if one of the listeners + * cancels it. + * + * Any exception emitted from the {@link ng.$rootScope.Scope#$on listeners} will be passed + * onto the {@link ng.$exceptionHandler $exceptionHandler} service. + * + * @param {string} name Event name to emit. + * @param {...*} args Optional one or more arguments which will be passed onto the event listeners. + * @return {Object} Event object (see {@link ng.$rootScope.Scope#$on}). + */ + $emit: function(name, args) { + var empty = [], + namedListeners, + scope = this, + stopPropagation = false, + event = { + name: name, + targetScope: scope, + stopPropagation: function() {stopPropagation = true;}, + preventDefault: function() { + event.defaultPrevented = true; + }, + defaultPrevented: false + }, + listenerArgs = concat([event], arguments, 1), + i, length; + + do { + namedListeners = scope.$$listeners[name] || empty; + event.currentScope = scope; + for (i=0, length=namedListeners.length; i= 8 ) { + normalizedVal = urlResolve(uri).href; + if (normalizedVal !== '' && !normalizedVal.match(regex)) { + return 'unsafe:'+normalizedVal; + } + } + return uri; + }; + }; +} + +var $sceMinErr = minErr('$sce'); + +var SCE_CONTEXTS = { + HTML: 'html', + CSS: 'css', + URL: 'url', + // RESOURCE_URL is a subtype of URL used in contexts where a privileged resource is sourced from a + // url. (e.g. ng-include, script src, templateUrl) + RESOURCE_URL: 'resourceUrl', + JS: 'js' +}; + +// Helper functions follow. + +// Copied from: +// http://docs.closure-library.googlecode.com/git/closure_goog_string_string.js.source.html#line962 +// Prereq: s is a string. +function escapeForRegexp(s) { + return s.replace(/([-()\[\]{}+?*.$\^|,:# -1) { + throw $sceMinErr('iwcard', + 'Illegal sequence *** in string matcher. String: {0}', matcher); + } + matcher = escapeForRegexp(matcher). + replace('\\*\\*', '.*'). + replace('\\*', '[^:/.?&;]*'); + return new RegExp('^' + matcher + '$'); + } else if (isRegExp(matcher)) { + // The only other type of matcher allowed is a Regexp. + // Match entire URL / disallow partial matches. + // Flags are reset (i.e. no global, ignoreCase or multiline) + return new RegExp('^' + matcher.source + '$'); + } else { + throw $sceMinErr('imatcher', + 'Matchers may only be "self", string patterns or RegExp objects'); + } +} + + +function adjustMatchers(matchers) { + var adjustedMatchers = []; + if (isDefined(matchers)) { + forEach(matchers, function(matcher) { + adjustedMatchers.push(adjustMatcher(matcher)); + }); + } + return adjustedMatchers; +} + + +/** + * @ngdoc service + * @name $sceDelegate + * @kind function + * + * @description + * + * `$sceDelegate` is a service that is used by the `$sce` service to provide {@link ng.$sce Strict + * Contextual Escaping (SCE)} services to AngularJS. + * + * Typically, you would configure or override the {@link ng.$sceDelegate $sceDelegate} instead of + * the `$sce` service to customize the way Strict Contextual Escaping works in AngularJS. This is + * because, while the `$sce` provides numerous shorthand methods, etc., you really only need to + * override 3 core functions (`trustAs`, `getTrusted` and `valueOf`) to replace the way things + * work because `$sce` delegates to `$sceDelegate` for these operations. + * + * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} to configure this service. + * + * The default instance of `$sceDelegate` should work out of the box with little pain. While you + * can override it completely to change the behavior of `$sce`, the common case would + * involve configuring the {@link ng.$sceDelegateProvider $sceDelegateProvider} instead by setting + * your own whitelists and blacklists for trusting URLs used for loading AngularJS resources such as + * templates. Refer {@link ng.$sceDelegateProvider#resourceUrlWhitelist + * $sceDelegateProvider.resourceUrlWhitelist} and {@link + * ng.$sceDelegateProvider#resourceUrlBlacklist $sceDelegateProvider.resourceUrlBlacklist} + */ + +/** + * @ngdoc provider + * @name $sceDelegateProvider + * @description + * + * The `$sceDelegateProvider` provider allows developers to configure the {@link ng.$sceDelegate + * $sceDelegate} service. This allows one to get/set the whitelists and blacklists used to ensure + * that the URLs used for sourcing Angular templates are safe. Refer {@link + * ng.$sceDelegateProvider#resourceUrlWhitelist $sceDelegateProvider.resourceUrlWhitelist} and + * {@link ng.$sceDelegateProvider#resourceUrlBlacklist $sceDelegateProvider.resourceUrlBlacklist} + * + * For the general details about this service in Angular, read the main page for {@link ng.$sce + * Strict Contextual Escaping (SCE)}. + * + * **Example**: Consider the following case. + * + * - your app is hosted at url `http://myapp.example.com/` + * - but some of your templates are hosted on other domains you control such as + * `http://srv01.assets.example.com/`,  `http://srv02.assets.example.com/`, etc. + * - and you have an open redirect at `http://myapp.example.com/clickThru?...`. + * + * Here is what a secure configuration for this scenario might look like: + * + * ``` + * angular.module('myApp', []).config(function($sceDelegateProvider) { + * $sceDelegateProvider.resourceUrlWhitelist([ + * // Allow same origin resource loads. + * 'self', + * // Allow loading from our assets domain. Notice the difference between * and **. + * 'http://srv*.assets.example.com/**' + * ]); + * + * // The blacklist overrides the whitelist so the open redirect here is blocked. + * $sceDelegateProvider.resourceUrlBlacklist([ + * 'http://myapp.example.com/clickThru**' + * ]); + * }); + * ``` + */ + +function $SceDelegateProvider() { + this.SCE_CONTEXTS = SCE_CONTEXTS; + + // Resource URLs can also be trusted by policy. + var resourceUrlWhitelist = ['self'], + resourceUrlBlacklist = []; + + /** + * @ngdoc method + * @name $sceDelegateProvider#resourceUrlWhitelist + * @kind function + * + * @param {Array=} whitelist When provided, replaces the resourceUrlWhitelist with the value + * provided. This must be an array or null. A snapshot of this array is used so further + * changes to the array are ignored. + * + * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items + * allowed in this array. + * + * Note: **an empty whitelist array will block all URLs**! + * + * @return {Array} the currently set whitelist array. + * + * The **default value** when no whitelist has been explicitly set is `['self']` allowing only + * same origin resource requests. + * + * @description + * Sets/Gets the whitelist of trusted resource URLs. + */ + this.resourceUrlWhitelist = function (value) { + if (arguments.length) { + resourceUrlWhitelist = adjustMatchers(value); + } + return resourceUrlWhitelist; + }; + + /** + * @ngdoc method + * @name $sceDelegateProvider#resourceUrlBlacklist + * @kind function + * + * @param {Array=} blacklist When provided, replaces the resourceUrlBlacklist with the value + * provided. This must be an array or null. A snapshot of this array is used so further + * changes to the array are ignored. + * + * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items + * allowed in this array. + * + * The typical usage for the blacklist is to **block + * [open redirects](http://cwe.mitre.org/data/definitions/601.html)** served by your domain as + * these would otherwise be trusted but actually return content from the redirected domain. + * + * Finally, **the blacklist overrides the whitelist** and has the final say. + * + * @return {Array} the currently set blacklist array. + * + * The **default value** when no whitelist has been explicitly set is the empty array (i.e. there + * is no blacklist.) + * + * @description + * Sets/Gets the blacklist of trusted resource URLs. + */ + + this.resourceUrlBlacklist = function (value) { + if (arguments.length) { + resourceUrlBlacklist = adjustMatchers(value); + } + return resourceUrlBlacklist; + }; + + this.$get = ['$injector', function($injector) { + + var htmlSanitizer = function htmlSanitizer(html) { + throw $sceMinErr('unsafe', 'Attempting to use an unsafe value in a safe context.'); + }; + + if ($injector.has('$sanitize')) { + htmlSanitizer = $injector.get('$sanitize'); + } + + + function matchUrl(matcher, parsedUrl) { + if (matcher === 'self') { + return urlIsSameOrigin(parsedUrl); + } else { + // definitely a regex. See adjustMatchers() + return !!matcher.exec(parsedUrl.href); + } + } + + function isResourceUrlAllowedByPolicy(url) { + var parsedUrl = urlResolve(url.toString()); + var i, n, allowed = false; + // Ensure that at least one item from the whitelist allows this url. + for (i = 0, n = resourceUrlWhitelist.length; i < n; i++) { + if (matchUrl(resourceUrlWhitelist[i], parsedUrl)) { + allowed = true; + break; + } + } + if (allowed) { + // Ensure that no item from the blacklist blocked this url. + for (i = 0, n = resourceUrlBlacklist.length; i < n; i++) { + if (matchUrl(resourceUrlBlacklist[i], parsedUrl)) { + allowed = false; + break; + } + } + } + return allowed; + } + + function generateHolderType(Base) { + var holderType = function TrustedValueHolderType(trustedValue) { + this.$$unwrapTrustedValue = function() { + return trustedValue; + }; + }; + if (Base) { + holderType.prototype = new Base(); + } + holderType.prototype.valueOf = function sceValueOf() { + return this.$$unwrapTrustedValue(); + }; + holderType.prototype.toString = function sceToString() { + return this.$$unwrapTrustedValue().toString(); + }; + return holderType; + } + + var trustedValueHolderBase = generateHolderType(), + byType = {}; + + byType[SCE_CONTEXTS.HTML] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.CSS] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.URL] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.JS] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.RESOURCE_URL] = generateHolderType(byType[SCE_CONTEXTS.URL]); + + /** + * @ngdoc method + * @name $sceDelegate#trustAs + * + * @description + * Returns an object that is trusted by angular for use in specified strict + * contextual escaping contexts (such as ng-bind-html, ng-include, any src + * attribute interpolation, any dom event binding attribute interpolation + * such as for onclick, etc.) that uses the provided value. + * See {@link ng.$sce $sce} for enabling strict contextual escaping. + * + * @param {string} type The kind of context in which this value is safe for use. e.g. url, + * resourceUrl, html, js and css. + * @param {*} value The value that that should be considered trusted/safe. + * @returns {*} A value that can be used to stand in for the provided `value` in places + * where Angular expects a $sce.trustAs() return value. + */ + function trustAs(type, trustedValue) { + var Constructor = (byType.hasOwnProperty(type) ? byType[type] : null); + if (!Constructor) { + throw $sceMinErr('icontext', + 'Attempted to trust a value in invalid context. Context: {0}; Value: {1}', + type, trustedValue); + } + if (trustedValue === null || trustedValue === undefined || trustedValue === '') { + return trustedValue; + } + // All the current contexts in SCE_CONTEXTS happen to be strings. In order to avoid trusting + // mutable objects, we ensure here that the value passed in is actually a string. + if (typeof trustedValue !== 'string') { + throw $sceMinErr('itype', + 'Attempted to trust a non-string value in a content requiring a string: Context: {0}', + type); + } + return new Constructor(trustedValue); + } + + /** + * @ngdoc method + * @name $sceDelegate#valueOf + * + * @description + * If the passed parameter had been returned by a prior call to {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`}, returns the value that had been passed to {@link + * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. + * + * If the passed parameter is not a value that had been returned by {@link + * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}, returns it as-is. + * + * @param {*} value The result of a prior {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} + * call or anything else. + * @returns {*} The `value` that was originally provided to {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} if `value` is the result of such a call. Otherwise, returns + * `value` unchanged. + */ + function valueOf(maybeTrusted) { + if (maybeTrusted instanceof trustedValueHolderBase) { + return maybeTrusted.$$unwrapTrustedValue(); + } else { + return maybeTrusted; + } + } + + /** + * @ngdoc method + * @name $sceDelegate#getTrusted + * + * @description + * Takes the result of a {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} call and + * returns the originally supplied value if the queried context type is a supertype of the + * created type. If this condition isn't satisfied, throws an exception. + * + * @param {string} type The kind of context in which this value is to be used. + * @param {*} maybeTrusted The result of a prior {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} call. + * @returns {*} The value the was originally provided to {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} if valid in this context. Otherwise, throws an exception. + */ + function getTrusted(type, maybeTrusted) { + if (maybeTrusted === null || maybeTrusted === undefined || maybeTrusted === '') { + return maybeTrusted; + } + var constructor = (byType.hasOwnProperty(type) ? byType[type] : null); + if (constructor && maybeTrusted instanceof constructor) { + return maybeTrusted.$$unwrapTrustedValue(); + } + // If we get here, then we may only take one of two actions. + // 1. sanitize the value for the requested type, or + // 2. throw an exception. + if (type === SCE_CONTEXTS.RESOURCE_URL) { + if (isResourceUrlAllowedByPolicy(maybeTrusted)) { + return maybeTrusted; + } else { + throw $sceMinErr('insecurl', + 'Blocked loading resource from url not allowed by $sceDelegate policy. URL: {0}', + maybeTrusted.toString()); + } + } else if (type === SCE_CONTEXTS.HTML) { + return htmlSanitizer(maybeTrusted); + } + throw $sceMinErr('unsafe', 'Attempting to use an unsafe value in a safe context.'); + } + + return { trustAs: trustAs, + getTrusted: getTrusted, + valueOf: valueOf }; + }]; +} + + +/** + * @ngdoc provider + * @name $sceProvider + * @description + * + * The $sceProvider provider allows developers to configure the {@link ng.$sce $sce} service. + * - enable/disable Strict Contextual Escaping (SCE) in a module + * - override the default implementation with a custom delegate + * + * Read more about {@link ng.$sce Strict Contextual Escaping (SCE)}. + */ + +/* jshint maxlen: false*/ + +/** + * @ngdoc service + * @name $sce + * @kind function + * + * @description + * + * `$sce` is a service that provides Strict Contextual Escaping services to AngularJS. + * + * # Strict Contextual Escaping + * + * Strict Contextual Escaping (SCE) is a mode in which AngularJS requires bindings in certain + * contexts to result in a value that is marked as safe to use for that context. One example of + * such a context is binding arbitrary html controlled by the user via `ng-bind-html`. We refer + * to these contexts as privileged or SCE contexts. + * + * As of version 1.2, Angular ships with SCE enabled by default. + * + * Note: When enabled (the default), IE8 in quirks mode is not supported. In this mode, IE8 allows + * one to execute arbitrary javascript by the use of the expression() syntax. Refer + * to learn more about them. + * You can ensure your document is in standards mode and not quirks mode by adding `` + * to the top of your HTML document. + * + * SCE assists in writing code in way that (a) is secure by default and (b) makes auditing for + * security vulnerabilities such as XSS, clickjacking, etc. a lot easier. + * + * Here's an example of a binding in a privileged context: + * + * ``` + * + *
+ * ``` + * + * Notice that `ng-bind-html` is bound to `userHtml` controlled by the user. With SCE + * disabled, this application allows the user to render arbitrary HTML into the DIV. + * In a more realistic example, one may be rendering user comments, blog articles, etc. via + * bindings. (HTML is just one example of a context where rendering user controlled input creates + * security vulnerabilities.) + * + * For the case of HTML, you might use a library, either on the client side, or on the server side, + * to sanitize unsafe HTML before binding to the value and rendering it in the document. + * + * How would you ensure that every place that used these types of bindings was bound to a value that + * was sanitized by your library (or returned as safe for rendering by your server?) How can you + * ensure that you didn't accidentally delete the line that sanitized the value, or renamed some + * properties/fields and forgot to update the binding to the sanitized value? + * + * To be secure by default, you want to ensure that any such bindings are disallowed unless you can + * determine that something explicitly says it's safe to use a value for binding in that + * context. You can then audit your code (a simple grep would do) to ensure that this is only done + * for those values that you can easily tell are safe - because they were received from your server, + * sanitized by your library, etc. You can organize your codebase to help with this - perhaps + * allowing only the files in a specific directory to do this. Ensuring that the internal API + * exposed by that code doesn't markup arbitrary values as safe then becomes a more manageable task. + * + * In the case of AngularJS' SCE service, one uses {@link ng.$sce#trustAs $sce.trustAs} + * (and shorthand methods such as {@link ng.$sce#trustAsHtml $sce.trustAsHtml}, etc.) to + * obtain values that will be accepted by SCE / privileged contexts. + * + * + * ## How does it work? + * + * In privileged contexts, directives and code will bind to the result of {@link ng.$sce#getTrusted + * $sce.getTrusted(context, value)} rather than to the value directly. Directives use {@link + * ng.$sce#parse $sce.parseAs} rather than `$parse` to watch attribute bindings, which performs the + * {@link ng.$sce#getTrusted $sce.getTrusted} behind the scenes on non-constant literals. + * + * As an example, {@link ng.directive:ngBindHtml ngBindHtml} uses {@link + * ng.$sce#parseAsHtml $sce.parseAsHtml(binding expression)}. Here's the actual code (slightly + * simplified): + * + * ``` + * var ngBindHtmlDirective = ['$sce', function($sce) { + * return function(scope, element, attr) { + * scope.$watch($sce.parseAsHtml(attr.ngBindHtml), function(value) { + * element.html(value || ''); + * }); + * }; + * }]; + * ``` + * + * ## Impact on loading templates + * + * This applies both to the {@link ng.directive:ngInclude `ng-include`} directive as well as + * `templateUrl`'s specified by {@link guide/directive directives}. + * + * By default, Angular only loads templates from the same domain and protocol as the application + * document. This is done by calling {@link ng.$sce#getTrustedResourceUrl + * $sce.getTrustedResourceUrl} on the template URL. To load templates from other domains and/or + * protocols, you may either either {@link ng.$sceDelegateProvider#resourceUrlWhitelist whitelist + * them} or {@link ng.$sce#trustAsResourceUrl wrap it} into a trusted value. + * + * *Please note*: + * The browser's + * [Same Origin Policy](https://code.google.com/p/browsersec/wiki/Part2#Same-origin_policy_for_XMLHttpRequest) + * and [Cross-Origin Resource Sharing (CORS)](http://www.w3.org/TR/cors/) + * policy apply in addition to this and may further restrict whether the template is successfully + * loaded. This means that without the right CORS policy, loading templates from a different domain + * won't work on all browsers. Also, loading templates from `file://` URL does not work on some + * browsers. + * + * ## This feels like too much overhead for the developer? + * + * It's important to remember that SCE only applies to interpolation expressions. + * + * If your expressions are constant literals, they're automatically trusted and you don't need to + * call `$sce.trustAs` on them (remember to include the `ngSanitize` module) (e.g. + * `
`) just works. + * + * Additionally, `a[href]` and `img[src]` automatically sanitize their URLs and do not pass them + * through {@link ng.$sce#getTrusted $sce.getTrusted}. SCE doesn't play a role here. + * + * The included {@link ng.$sceDelegate $sceDelegate} comes with sane defaults to allow you to load + * templates in `ng-include` from your application's domain without having to even know about SCE. + * It blocks loading templates from other domains or loading templates over http from an https + * served document. You can change these by setting your own custom {@link + * ng.$sceDelegateProvider#resourceUrlWhitelist whitelists} and {@link + * ng.$sceDelegateProvider#resourceUrlBlacklist blacklists} for matching such URLs. + * + * This significantly reduces the overhead. It is far easier to pay the small overhead and have an + * application that's secure and can be audited to verify that with much more ease than bolting + * security onto an application later. + * + * + * ## What trusted context types are supported? + * + * | Context | Notes | + * |---------------------|----------------| + * | `$sce.HTML` | For HTML that's safe to source into the application. The {@link ng.directive:ngBindHtml ngBindHtml} directive uses this context for bindings. If an unsafe value is encountered and the {@link ngSanitize $sanitize} module is present this will sanitize the value instead of throwing an error. | + * | `$sce.CSS` | For CSS that's safe to source into the application. Currently unused. Feel free to use it in your own directives. | + * | `$sce.URL` | For URLs that are safe to follow as links. Currently unused (`
Note that `$sce.RESOURCE_URL` makes a stronger statement about the URL than `$sce.URL` does and therefore contexts requiring values trusted for `$sce.RESOURCE_URL` can be used anywhere that values trusted for `$sce.URL` are required. | + * | `$sce.JS` | For JavaScript that is safe to execute in your application's context. Currently unused. Feel free to use it in your own directives. | + * + * ## Format of items in {@link ng.$sceDelegateProvider#resourceUrlWhitelist resourceUrlWhitelist}/{@link ng.$sceDelegateProvider#resourceUrlBlacklist Blacklist}
+ * + * Each element in these arrays must be one of the following: + * + * - **'self'** + * - The special **string**, `'self'`, can be used to match against all URLs of the **same + * domain** as the application document using the **same protocol**. + * - **String** (except the special value `'self'`) + * - The string is matched against the full *normalized / absolute URL* of the resource + * being tested (substring matches are not good enough.) + * - There are exactly **two wildcard sequences** - `*` and `**`. All other characters + * match themselves. + * - `*`: matches zero or more occurrences of any character other than one of the following 6 + * characters: '`:`', '`/`', '`.`', '`?`', '`&`' and ';'. It's a useful wildcard for use + * in a whitelist. + * - `**`: matches zero or more occurrences of *any* character. As such, it's not + * not appropriate to use in for a scheme, domain, etc. as it would match too much. (e.g. + * http://**.example.com/ would match http://evil.com/?ignore=.example.com/ and that might + * not have been the intention.) Its usage at the very end of the path is ok. (e.g. + * http://foo.example.com/templates/**). + * - **RegExp** (*see caveat below*) + * - *Caveat*: While regular expressions are powerful and offer great flexibility, their syntax + * (and all the inevitable escaping) makes them *harder to maintain*. It's easy to + * accidentally introduce a bug when one updates a complex expression (imho, all regexes should + * have good test coverage.). For instance, the use of `.` in the regex is correct only in a + * small number of cases. A `.` character in the regex used when matching the scheme or a + * subdomain could be matched against a `:` or literal `.` that was likely not intended. It + * is highly recommended to use the string patterns and only fall back to regular expressions + * if they as a last resort. + * - The regular expression must be an instance of RegExp (i.e. not a string.) It is + * matched against the **entire** *normalized / absolute URL* of the resource being tested + * (even when the RegExp did not have the `^` and `$` codes.) In addition, any flags + * present on the RegExp (such as multiline, global, ignoreCase) are ignored. + * - If you are generating your JavaScript from some other templating engine (not + * recommended, e.g. in issue [#4006](https://github.com/angular/angular.js/issues/4006)), + * remember to escape your regular expression (and be aware that you might need more than + * one level of escaping depending on your templating engine and the way you interpolated + * the value.) Do make use of your platform's escaping mechanism as it might be good + * enough before coding your own. e.g. Ruby has + * [Regexp.escape(str)](http://www.ruby-doc.org/core-2.0.0/Regexp.html#method-c-escape) + * and Python has [re.escape](http://docs.python.org/library/re.html#re.escape). + * Javascript lacks a similar built in function for escaping. Take a look at Google + * Closure library's [goog.string.regExpEscape(s)]( + * http://docs.closure-library.googlecode.com/git/closure_goog_string_string.js.source.html#line962). + * + * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} for an example. + * + * ## Show me an example using SCE. + * + * + * + *
+ *

+ * User comments
+ * By default, HTML that isn't explicitly trusted (e.g. Alice's comment) is sanitized when + * $sanitize is available. If $sanitize isn't available, this results in an error instead of an + * exploit. + *
+ *
+ * {{userComment.name}}: + * + *
+ *
+ *
+ *
+ *
+ * + * + * var mySceApp = angular.module('mySceApp', ['ngSanitize']); + * + * mySceApp.controller("myAppController", function myAppController($http, $templateCache, $sce) { + * var self = this; + * $http.get("test_data.json", {cache: $templateCache}).success(function(userComments) { + * self.userComments = userComments; + * }); + * self.explicitlyTrustedHtml = $sce.trustAsHtml( + * 'Hover over this text.'); + * }); + * + * + * + * [ + * { "name": "Alice", + * "htmlComment": + * "Is anyone reading this?" + * }, + * { "name": "Bob", + * "htmlComment": "Yes! Am I the only other one?" + * } + * ] + * + * + * + * describe('SCE doc demo', function() { + * it('should sanitize untrusted values', function() { + * expect(element.all(by.css('.htmlComment')).first().getInnerHtml()) + * .toBe('Is anyone reading this?'); + * }); + * + * it('should NOT sanitize explicitly trusted values', function() { + * expect(element(by.id('explicitlyTrustedHtml')).getInnerHtml()).toBe( + * 'Hover over this text.'); + * }); + * }); + * + *
+ * + * + * + * ## Can I disable SCE completely? + * + * Yes, you can. However, this is strongly discouraged. SCE gives you a lot of security benefits + * for little coding overhead. It will be much harder to take an SCE disabled application and + * either secure it on your own or enable SCE at a later stage. It might make sense to disable SCE + * for cases where you have a lot of existing code that was written before SCE was introduced and + * you're migrating them a module at a time. + * + * That said, here's how you can completely disable SCE: + * + * ``` + * angular.module('myAppWithSceDisabledmyApp', []).config(function($sceProvider) { + * // Completely disable SCE. For demonstration purposes only! + * // Do not use in new projects. + * $sceProvider.enabled(false); + * }); + * ``` + * + */ +/* jshint maxlen: 100 */ + +function $SceProvider() { + var enabled = true; + + /** + * @ngdoc method + * @name $sceProvider#enabled + * @kind function + * + * @param {boolean=} value If provided, then enables/disables SCE. + * @return {boolean} true if SCE is enabled, false otherwise. + * + * @description + * Enables/disables SCE and returns the current value. + */ + this.enabled = function (value) { + if (arguments.length) { + enabled = !!value; + } + return enabled; + }; + + + /* Design notes on the default implementation for SCE. + * + * The API contract for the SCE delegate + * ------------------------------------- + * The SCE delegate object must provide the following 3 methods: + * + * - trustAs(contextEnum, value) + * This method is used to tell the SCE service that the provided value is OK to use in the + * contexts specified by contextEnum. It must return an object that will be accepted by + * getTrusted() for a compatible contextEnum and return this value. + * + * - valueOf(value) + * For values that were not produced by trustAs(), return them as is. For values that were + * produced by trustAs(), return the corresponding input value to trustAs. Basically, if + * trustAs is wrapping the given values into some type, this operation unwraps it when given + * such a value. + * + * - getTrusted(contextEnum, value) + * This function should return the a value that is safe to use in the context specified by + * contextEnum or throw and exception otherwise. + * + * NOTE: This contract deliberately does NOT state that values returned by trustAs() must be + * opaque or wrapped in some holder object. That happens to be an implementation detail. For + * instance, an implementation could maintain a registry of all trusted objects by context. In + * such a case, trustAs() would return the same object that was passed in. getTrusted() would + * return the same object passed in if it was found in the registry under a compatible context or + * throw an exception otherwise. An implementation might only wrap values some of the time based + * on some criteria. getTrusted() might return a value and not throw an exception for special + * constants or objects even if not wrapped. All such implementations fulfill this contract. + * + * + * A note on the inheritance model for SCE contexts + * ------------------------------------------------ + * I've used inheritance and made RESOURCE_URL wrapped types a subtype of URL wrapped types. This + * is purely an implementation details. + * + * The contract is simply this: + * + * getTrusted($sce.RESOURCE_URL, value) succeeding implies that getTrusted($sce.URL, value) + * will also succeed. + * + * Inheritance happens to capture this in a natural way. In some future, we + * may not use inheritance anymore. That is OK because no code outside of + * sce.js and sceSpecs.js would need to be aware of this detail. + */ + + this.$get = ['$parse', '$sniffer', '$sceDelegate', function( + $parse, $sniffer, $sceDelegate) { + // Prereq: Ensure that we're not running in IE8 quirks mode. In that mode, IE allows + // the "expression(javascript expression)" syntax which is insecure. + if (enabled && $sniffer.msie && $sniffer.msieDocumentMode < 8) { + throw $sceMinErr('iequirks', + 'Strict Contextual Escaping does not support Internet Explorer version < 9 in quirks ' + + 'mode. You can fix this by adding the text to the top of your HTML ' + + 'document. See http://docs.angularjs.org/api/ng.$sce for more information.'); + } + + var sce = shallowCopy(SCE_CONTEXTS); + + /** + * @ngdoc method + * @name $sce#isEnabled + * @kind function + * + * @return {Boolean} true if SCE is enabled, false otherwise. If you want to set the value, you + * have to do it at module config time on {@link ng.$sceProvider $sceProvider}. + * + * @description + * Returns a boolean indicating if SCE is enabled. + */ + sce.isEnabled = function () { + return enabled; + }; + sce.trustAs = $sceDelegate.trustAs; + sce.getTrusted = $sceDelegate.getTrusted; + sce.valueOf = $sceDelegate.valueOf; + + if (!enabled) { + sce.trustAs = sce.getTrusted = function(type, value) { return value; }; + sce.valueOf = identity; + } + + /** + * @ngdoc method + * @name $sce#parseAs + * + * @description + * Converts Angular {@link guide/expression expression} into a function. This is like {@link + * ng.$parse $parse} and is identical when the expression is a literal constant. Otherwise, it + * wraps the expression in a call to {@link ng.$sce#getTrusted $sce.getTrusted(*type*, + * *result*)} + * + * @param {string} type The kind of SCE context in which this result will be used. + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. + */ + sce.parseAs = function sceParseAs(type, expr) { + var parsed = $parse(expr); + if (parsed.literal && parsed.constant) { + return parsed; + } else { + return function sceParseAsTrusted(self, locals) { + return sce.getTrusted(type, parsed(self, locals)); + }; + } + }; + + /** + * @ngdoc method + * @name $sce#trustAs + * + * @description + * Delegates to {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. As such, + * returns an object that is trusted by angular for use in specified strict contextual + * escaping contexts (such as ng-bind-html, ng-include, any src attribute + * interpolation, any dom event binding attribute interpolation such as for onclick, etc.) + * that uses the provided value. See * {@link ng.$sce $sce} for enabling strict contextual + * escaping. + * + * @param {string} type The kind of context in which this value is safe for use. e.g. url, + * resource_url, html, js and css. + * @param {*} value The value that that should be considered trusted/safe. + * @returns {*} A value that can be used to stand in for the provided `value` in places + * where Angular expects a $sce.trustAs() return value. + */ + + /** + * @ngdoc method + * @name $sce#trustAsHtml + * + * @description + * Shorthand method. `$sce.trustAsHtml(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.HTML, value)`} + * + * @param {*} value The value to trustAs. + * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedHtml + * $sce.getTrustedHtml(value)} to obtain the original value. (privileged directives + * only accept expressions that are either literal constants or are the + * return value of {@link ng.$sce#trustAs $sce.trustAs}.) + */ + + /** + * @ngdoc method + * @name $sce#trustAsUrl + * + * @description + * Shorthand method. `$sce.trustAsUrl(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.URL, value)`} + * + * @param {*} value The value to trustAs. + * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedUrl + * $sce.getTrustedUrl(value)} to obtain the original value. (privileged directives + * only accept expressions that are either literal constants or are the + * return value of {@link ng.$sce#trustAs $sce.trustAs}.) + */ + + /** + * @ngdoc method + * @name $sce#trustAsResourceUrl + * + * @description + * Shorthand method. `$sce.trustAsResourceUrl(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.RESOURCE_URL, value)`} + * + * @param {*} value The value to trustAs. + * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedResourceUrl + * $sce.getTrustedResourceUrl(value)} to obtain the original value. (privileged directives + * only accept expressions that are either literal constants or are the return + * value of {@link ng.$sce#trustAs $sce.trustAs}.) + */ + + /** + * @ngdoc method + * @name $sce#trustAsJs + * + * @description + * Shorthand method. `$sce.trustAsJs(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.JS, value)`} + * + * @param {*} value The value to trustAs. + * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedJs + * $sce.getTrustedJs(value)} to obtain the original value. (privileged directives + * only accept expressions that are either literal constants or are the + * return value of {@link ng.$sce#trustAs $sce.trustAs}.) + */ + + /** + * @ngdoc method + * @name $sce#getTrusted + * + * @description + * Delegates to {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted`}. As such, + * takes the result of a {@link ng.$sce#trustAs `$sce.trustAs`}() call and returns the + * originally supplied value if the queried context type is a supertype of the created type. + * If this condition isn't satisfied, throws an exception. + * + * @param {string} type The kind of context in which this value is to be used. + * @param {*} maybeTrusted The result of a prior {@link ng.$sce#trustAs `$sce.trustAs`} + * call. + * @returns {*} The value the was originally provided to + * {@link ng.$sce#trustAs `$sce.trustAs`} if valid in this context. + * Otherwise, throws an exception. + */ + + /** + * @ngdoc method + * @name $sce#getTrustedHtml + * + * @description + * Shorthand method. `$sce.getTrustedHtml(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.HTML, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @returns {*} The return value of `$sce.getTrusted($sce.HTML, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedCss + * + * @description + * Shorthand method. `$sce.getTrustedCss(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.CSS, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @returns {*} The return value of `$sce.getTrusted($sce.CSS, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedUrl + * + * @description + * Shorthand method. `$sce.getTrustedUrl(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.URL, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @returns {*} The return value of `$sce.getTrusted($sce.URL, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedResourceUrl + * + * @description + * Shorthand method. `$sce.getTrustedResourceUrl(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.RESOURCE_URL, value)`} + * + * @param {*} value The value to pass to `$sceDelegate.getTrusted`. + * @returns {*} The return value of `$sce.getTrusted($sce.RESOURCE_URL, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedJs + * + * @description + * Shorthand method. `$sce.getTrustedJs(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.JS, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @returns {*} The return value of `$sce.getTrusted($sce.JS, value)` + */ + + /** + * @ngdoc method + * @name $sce#parseAsHtml + * + * @description + * Shorthand method. `$sce.parseAsHtml(expression string)` → + * {@link ng.$sce#parse `$sce.parseAs($sce.HTML, value)`} + * + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsCss + * + * @description + * Shorthand method. `$sce.parseAsCss(value)` → + * {@link ng.$sce#parse `$sce.parseAs($sce.CSS, value)`} + * + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsUrl + * + * @description + * Shorthand method. `$sce.parseAsUrl(value)` → + * {@link ng.$sce#parse `$sce.parseAs($sce.URL, value)`} + * + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsResourceUrl + * + * @description + * Shorthand method. `$sce.parseAsResourceUrl(value)` → + * {@link ng.$sce#parse `$sce.parseAs($sce.RESOURCE_URL, value)`} + * + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsJs + * + * @description + * Shorthand method. `$sce.parseAsJs(value)` → + * {@link ng.$sce#parse `$sce.parseAs($sce.JS, value)`} + * + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. + */ + + // Shorthand delegations. + var parse = sce.parseAs, + getTrusted = sce.getTrusted, + trustAs = sce.trustAs; + + forEach(SCE_CONTEXTS, function (enumValue, name) { + var lName = lowercase(name); + sce[camelCase("parse_as_" + lName)] = function (expr) { + return parse(enumValue, expr); + }; + sce[camelCase("get_trusted_" + lName)] = function (value) { + return getTrusted(enumValue, value); + }; + sce[camelCase("trust_as_" + lName)] = function (value) { + return trustAs(enumValue, value); + }; + }); + + return sce; + }]; +} + +/** + * !!! This is an undocumented "private" service !!! + * + * @name $sniffer + * @requires $window + * @requires $document + * + * @property {boolean} history Does the browser support html5 history api ? + * @property {boolean} hashchange Does the browser support hashchange event ? + * @property {boolean} transitions Does the browser support CSS transition events ? + * @property {boolean} animations Does the browser support CSS animation events ? + * + * @description + * This is very simple implementation of testing browser's features. + */ +function $SnifferProvider() { + this.$get = ['$window', '$document', function($window, $document) { + var eventSupport = {}, + android = + int((/android (\d+)/.exec(lowercase(($window.navigator || {}).userAgent)) || [])[1]), + boxee = /Boxee/i.test(($window.navigator || {}).userAgent), + document = $document[0] || {}, + documentMode = document.documentMode, + vendorPrefix, + vendorRegex = /^(Moz|webkit|O|ms)(?=[A-Z])/, + bodyStyle = document.body && document.body.style, + transitions = false, + animations = false, + match; + + if (bodyStyle) { + for(var prop in bodyStyle) { + if(match = vendorRegex.exec(prop)) { + vendorPrefix = match[0]; + vendorPrefix = vendorPrefix.substr(0, 1).toUpperCase() + vendorPrefix.substr(1); + break; + } + } + + if(!vendorPrefix) { + vendorPrefix = ('WebkitOpacity' in bodyStyle) && 'webkit'; + } + + transitions = !!(('transition' in bodyStyle) || (vendorPrefix + 'Transition' in bodyStyle)); + animations = !!(('animation' in bodyStyle) || (vendorPrefix + 'Animation' in bodyStyle)); + + if (android && (!transitions||!animations)) { + transitions = isString(document.body.style.webkitTransition); + animations = isString(document.body.style.webkitAnimation); + } + } + + + return { + // Android has history.pushState, but it does not update location correctly + // so let's not use the history API at all. + // http://code.google.com/p/android/issues/detail?id=17471 + // https://github.com/angular/angular.js/issues/904 + + // older webkit browser (533.9) on Boxee box has exactly the same problem as Android has + // so let's not use the history API also + // We are purposefully using `!(android < 4)` to cover the case when `android` is undefined + // jshint -W018 + history: !!($window.history && $window.history.pushState && !(android < 4) && !boxee), + // jshint +W018 + hashchange: 'onhashchange' in $window && + // IE8 compatible mode lies + (!documentMode || documentMode > 7), + hasEvent: function(event) { + // IE9 implements 'input' event it's so fubared that we rather pretend that it doesn't have + // it. In particular the event is not fired when backspace or delete key are pressed or + // when cut operation is performed. + if (event == 'input' && msie == 9) return false; + + if (isUndefined(eventSupport[event])) { + var divElm = document.createElement('div'); + eventSupport[event] = 'on' + event in divElm; + } + + return eventSupport[event]; + }, + csp: csp(), + vendorPrefix: vendorPrefix, + transitions : transitions, + animations : animations, + android: android, + msie : msie, + msieDocumentMode: documentMode + }; + }]; +} + +function $TimeoutProvider() { + this.$get = ['$rootScope', '$browser', '$q', '$exceptionHandler', + function($rootScope, $browser, $q, $exceptionHandler) { + var deferreds = {}; + + + /** + * @ngdoc service + * @name $timeout + * + * @description + * Angular's wrapper for `window.setTimeout`. The `fn` function is wrapped into a try/catch + * block and delegates any exceptions to + * {@link ng.$exceptionHandler $exceptionHandler} service. + * + * The return value of registering a timeout function is a promise, which will be resolved when + * the timeout is reached and the timeout function is executed. + * + * To cancel a timeout request, call `$timeout.cancel(promise)`. + * + * In tests you can use {@link ngMock.$timeout `$timeout.flush()`} to + * synchronously flush the queue of deferred functions. + * + * @param {function()} fn A function, whose execution should be delayed. + * @param {number=} [delay=0] Delay in milliseconds. + * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise + * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. + * @returns {Promise} Promise that will be resolved when the timeout is reached. The value this + * promise will be resolved with is the return value of the `fn` function. + * + */ + function timeout(fn, delay, invokeApply) { + var deferred = $q.defer(), + promise = deferred.promise, + skipApply = (isDefined(invokeApply) && !invokeApply), + timeoutId; + + timeoutId = $browser.defer(function() { + try { + deferred.resolve(fn()); + } catch(e) { + deferred.reject(e); + $exceptionHandler(e); + } + finally { + delete deferreds[promise.$$timeoutId]; + } + + if (!skipApply) $rootScope.$apply(); + }, delay); + + promise.$$timeoutId = timeoutId; + deferreds[timeoutId] = deferred; + + return promise; + } + + + /** + * @ngdoc method + * @name $timeout#cancel + * + * @description + * Cancels a task associated with the `promise`. As a result of this, the promise will be + * resolved with a rejection. + * + * @param {Promise=} promise Promise returned by the `$timeout` function. + * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfully + * canceled. + */ + timeout.cancel = function(promise) { + if (promise && promise.$$timeoutId in deferreds) { + deferreds[promise.$$timeoutId].reject('canceled'); + delete deferreds[promise.$$timeoutId]; + return $browser.defer.cancel(promise.$$timeoutId); + } + return false; + }; + + return timeout; + }]; +} + +// NOTE: The usage of window and document instead of $window and $document here is +// deliberate. This service depends on the specific behavior of anchor nodes created by the +// browser (resolving and parsing URLs) that is unlikely to be provided by mock objects and +// cause us to break tests. In addition, when the browser resolves a URL for XHR, it +// doesn't know about mocked locations and resolves URLs to the real document - which is +// exactly the behavior needed here. There is little value is mocking these out for this +// service. +var urlParsingNode = document.createElement("a"); +var originUrl = urlResolve(window.location.href, true); + + +/** + * + * Implementation Notes for non-IE browsers + * ---------------------------------------- + * Assigning a URL to the href property of an anchor DOM node, even one attached to the DOM, + * results both in the normalizing and parsing of the URL. Normalizing means that a relative + * URL will be resolved into an absolute URL in the context of the application document. + * Parsing means that the anchor node's host, hostname, protocol, port, pathname and related + * properties are all populated to reflect the normalized URL. This approach has wide + * compatibility - Safari 1+, Mozilla 1+, Opera 7+,e etc. See + * http://www.aptana.com/reference/html/api/HTMLAnchorElement.html + * + * Implementation Notes for IE + * --------------------------- + * IE >= 8 and <= 10 normalizes the URL when assigned to the anchor node similar to the other + * browsers. However, the parsed components will not be set if the URL assigned did not specify + * them. (e.g. if you assign a.href = "foo", then a.protocol, a.host, etc. will be empty.) We + * work around that by performing the parsing in a 2nd step by taking a previously normalized + * URL (e.g. by assigning to a.href) and assigning it a.href again. This correctly populates the + * properties such as protocol, hostname, port, etc. + * + * IE7 does not normalize the URL when assigned to an anchor node. (Apparently, it does, if one + * uses the inner HTML approach to assign the URL as part of an HTML snippet - + * http://stackoverflow.com/a/472729) However, setting img[src] does normalize the URL. + * Unfortunately, setting img[src] to something like "javascript:foo" on IE throws an exception. + * Since the primary usage for normalizing URLs is to sanitize such URLs, we can't use that + * method and IE < 8 is unsupported. + * + * References: + * http://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement + * http://www.aptana.com/reference/html/api/HTMLAnchorElement.html + * http://url.spec.whatwg.org/#urlutils + * https://github.com/angular/angular.js/pull/2902 + * http://james.padolsey.com/javascript/parsing-urls-with-the-dom/ + * + * @kind function + * @param {string} url The URL to be parsed. + * @description Normalizes and parses a URL. + * @returns {object} Returns the normalized URL as a dictionary. + * + * | member name | Description | + * |---------------|----------------| + * | href | A normalized version of the provided URL if it was not an absolute URL | + * | protocol | The protocol including the trailing colon | + * | host | The host and port (if the port is non-default) of the normalizedUrl | + * | search | The search params, minus the question mark | + * | hash | The hash string, minus the hash symbol + * | hostname | The hostname + * | port | The port, without ":" + * | pathname | The pathname, beginning with "/" + * + */ +function urlResolve(url, base) { + var href = url; + + if (msie) { + // Normalize before parse. Refer Implementation Notes on why this is + // done in two steps on IE. + urlParsingNode.setAttribute("href", href); + href = urlParsingNode.href; + } + + urlParsingNode.setAttribute('href', href); + + // urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils + return { + href: urlParsingNode.href, + protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '', + host: urlParsingNode.host, + search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '', + hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '', + hostname: urlParsingNode.hostname, + port: urlParsingNode.port, + pathname: (urlParsingNode.pathname.charAt(0) === '/') + ? urlParsingNode.pathname + : '/' + urlParsingNode.pathname + }; +} + +/** + * Parse a request URL and determine whether this is a same-origin request as the application document. + * + * @param {string|object} requestUrl The url of the request as a string that will be resolved + * or a parsed URL object. + * @returns {boolean} Whether the request is for the same origin as the application document. + */ +function urlIsSameOrigin(requestUrl) { + var parsed = (isString(requestUrl)) ? urlResolve(requestUrl) : requestUrl; + return (parsed.protocol === originUrl.protocol && + parsed.host === originUrl.host); +} + +/** + * @ngdoc service + * @name $window + * + * @description + * A reference to the browser's `window` object. While `window` + * is globally available in JavaScript, it causes testability problems, because + * it is a global variable. In angular we always refer to it through the + * `$window` service, so it may be overridden, removed or mocked for testing. + * + * Expressions, like the one defined for the `ngClick` directive in the example + * below, are evaluated with respect to the current scope. Therefore, there is + * no risk of inadvertently coding in a dependency on a global value in such an + * expression. + * + * @example + + + +
+ + +
+
+ + it('should display the greeting in the input box', function() { + element(by.model('greeting')).sendKeys('Hello, E2E Tests'); + // If we click the button it will block the test runner + // element(':button').click(); + }); + +
+ */ +function $WindowProvider(){ + this.$get = valueFn(window); +} + +/** + * @ngdoc provider + * @name $filterProvider + * @description + * + * Filters are just functions which transform input to an output. However filters need to be + * Dependency Injected. To achieve this a filter definition consists of a factory function which is + * annotated with dependencies and is responsible for creating a filter function. + * + * ```js + * // Filter registration + * function MyModule($provide, $filterProvider) { + * // create a service to demonstrate injection (not always needed) + * $provide.value('greet', function(name){ + * return 'Hello ' + name + '!'; + * }); + * + * // register a filter factory which uses the + * // greet service to demonstrate DI. + * $filterProvider.register('greet', function(greet){ + * // return the filter function which uses the greet service + * // to generate salutation + * return function(text) { + * // filters need to be forgiving so check input validity + * return text && greet(text) || text; + * }; + * }); + * } + * ``` + * + * The filter function is registered with the `$injector` under the filter name suffix with + * `Filter`. + * + * ```js + * it('should be the same instance', inject( + * function($filterProvider) { + * $filterProvider.register('reverse', function(){ + * return ...; + * }); + * }, + * function($filter, reverseFilter) { + * expect($filter('reverse')).toBe(reverseFilter); + * }); + * ``` + * + * + * For more information about how angular filters work, and how to create your own filters, see + * {@link guide/filter Filters} in the Angular Developer Guide. + */ +/** + * @ngdoc method + * @name $filterProvider#register + * @description + * Register filter factory function. + * + * @param {String} name Name of the filter. + * @param {Function} fn The filter factory function which is injectable. + */ + + +/** + * @ngdoc service + * @name $filter + * @kind function + * @description + * Filters are used for formatting data displayed to the user. + * + * The general syntax in templates is as follows: + * + * {{ expression [| filter_name[:parameter_value] ... ] }} + * + * @param {String} name Name of the filter function to retrieve + * @return {Function} the filter function + * @example + + +
+

{{ originalText }}

+

{{ filteredText }}

+
+
+ + + angular.module('filterExample', []) + .controller('MainCtrl', function($scope, $filter) { + $scope.originalText = 'hello'; + $scope.filteredText = $filter('uppercase')($scope.originalText); + }); + +
+ */ +$FilterProvider.$inject = ['$provide']; +function $FilterProvider($provide) { + var suffix = 'Filter'; + + /** + * @ngdoc method + * @name $controllerProvider#register + * @param {string|Object} name Name of the filter function, or an object map of filters where + * the keys are the filter names and the values are the filter factories. + * @returns {Object} Registered filter instance, or if a map of filters was provided then a map + * of the registered filter instances. + */ + function register(name, factory) { + if(isObject(name)) { + var filters = {}; + forEach(name, function(filter, key) { + filters[key] = register(key, filter); + }); + return filters; + } else { + return $provide.factory(name + suffix, factory); + } + } + this.register = register; + + this.$get = ['$injector', function($injector) { + return function(name) { + return $injector.get(name + suffix); + }; + }]; + + //////////////////////////////////////// + + /* global + currencyFilter: false, + dateFilter: false, + filterFilter: false, + jsonFilter: false, + limitToFilter: false, + lowercaseFilter: false, + numberFilter: false, + orderByFilter: false, + uppercaseFilter: false, + */ + + register('currency', currencyFilter); + register('date', dateFilter); + register('filter', filterFilter); + register('json', jsonFilter); + register('limitTo', limitToFilter); + register('lowercase', lowercaseFilter); + register('number', numberFilter); + register('orderBy', orderByFilter); + register('uppercase', uppercaseFilter); +} + +/** + * @ngdoc filter + * @name filter + * @kind function + * + * @description + * Selects a subset of items from `array` and returns it as a new array. + * + * @param {Array} array The source array. + * @param {string|Object|function()} expression The predicate to be used for selecting items from + * `array`. + * + * Can be one of: + * + * - `string`: The string is evaluated as an expression and the resulting value is used for substring match against + * the contents of the `array`. All strings or objects with string properties in `array` that contain this string + * will be returned. The predicate can be negated by prefixing the string with `!`. + * + * - `Object`: A pattern object can be used to filter specific properties on objects contained + * by `array`. For example `{name:"M", phone:"1"}` predicate will return an array of items + * which have property `name` containing "M" and property `phone` containing "1". A special + * property name `$` can be used (as in `{$:"text"}`) to accept a match against any + * property of the object. That's equivalent to the simple substring match with a `string` + * as described above. + * + * - `function(value)`: A predicate function can be used to write arbitrary filters. The function is + * called for each element of `array`. The final result is an array of those elements that + * the predicate returned true for. + * + * @param {function(actual, expected)|true|undefined} comparator Comparator which is used in + * determining if the expected value (from the filter expression) and actual value (from + * the object in the array) should be considered a match. + * + * Can be one of: + * + * - `function(actual, expected)`: + * The function will be given the object value and the predicate value to compare and + * should return true if the item should be included in filtered result. + * + * - `true`: A shorthand for `function(actual, expected) { return angular.equals(expected, actual)}`. + * this is essentially strict comparison of expected and actual. + * + * - `false|undefined`: A short hand for a function which will look for a substring match in case + * insensitive way. + * + * @example + + +
+ + Search: + + + + + + +
NamePhone
{{friend.name}}{{friend.phone}}
+
+ Any:
+ Name only
+ Phone only
+ Equality
+ + + + + + +
NamePhone
{{friendObj.name}}{{friendObj.phone}}
+
+ + var expectFriendNames = function(expectedNames, key) { + element.all(by.repeater(key + ' in friends').column(key + '.name')).then(function(arr) { + arr.forEach(function(wd, i) { + expect(wd.getText()).toMatch(expectedNames[i]); + }); + }); + }; + + it('should search across all fields when filtering with a string', function() { + var searchText = element(by.model('searchText')); + searchText.clear(); + searchText.sendKeys('m'); + expectFriendNames(['Mary', 'Mike', 'Adam'], 'friend'); + + searchText.clear(); + searchText.sendKeys('76'); + expectFriendNames(['John', 'Julie'], 'friend'); + }); + + it('should search in specific fields when filtering with a predicate object', function() { + var searchAny = element(by.model('search.$')); + searchAny.clear(); + searchAny.sendKeys('i'); + expectFriendNames(['Mary', 'Mike', 'Julie', 'Juliette'], 'friendObj'); + }); + it('should use a equal comparison when comparator is true', function() { + var searchName = element(by.model('search.name')); + var strict = element(by.model('strict')); + searchName.clear(); + searchName.sendKeys('Julie'); + strict.click(); + expectFriendNames(['Julie'], 'friendObj'); + }); + +
+ */ +function filterFilter() { + return function(array, expression, comparator) { + if (!isArray(array)) return array; + + var comparatorType = typeof(comparator), + predicates = []; + + predicates.check = function(value) { + for (var j = 0; j < predicates.length; j++) { + if(!predicates[j](value)) { + return false; + } + } + return true; + }; + + if (comparatorType !== 'function') { + if (comparatorType === 'boolean' && comparator) { + comparator = function(obj, text) { + return angular.equals(obj, text); + }; + } else { + comparator = function(obj, text) { + if (obj && text && typeof obj === 'object' && typeof text === 'object') { + for (var objKey in obj) { + if (objKey.charAt(0) !== '$' && hasOwnProperty.call(obj, objKey) && + comparator(obj[objKey], text[objKey])) { + return true; + } + } + return false; + } + text = (''+text).toLowerCase(); + return (''+obj).toLowerCase().indexOf(text) > -1; + }; + } + } + + var search = function(obj, text){ + if (typeof text == 'string' && text.charAt(0) === '!') { + return !search(obj, text.substr(1)); + } + switch (typeof obj) { + case "boolean": + case "number": + case "string": + return comparator(obj, text); + case "object": + switch (typeof text) { + case "object": + return comparator(obj, text); + default: + for ( var objKey in obj) { + if (objKey.charAt(0) !== '$' && search(obj[objKey], text)) { + return true; + } + } + break; + } + return false; + case "array": + for ( var i = 0; i < obj.length; i++) { + if (search(obj[i], text)) { + return true; + } + } + return false; + default: + return false; + } + }; + switch (typeof expression) { + case "boolean": + case "number": + case "string": + // Set up expression object and fall through + expression = {$:expression}; + // jshint -W086 + case "object": + // jshint +W086 + for (var key in expression) { + (function(path) { + if (typeof expression[path] === 'undefined') return; + predicates.push(function(value) { + return search(path == '$' ? value : (value && value[path]), expression[path]); + }); + })(key); + } + break; + case 'function': + predicates.push(expression); + break; + default: + return array; + } + var filtered = []; + for ( var j = 0; j < array.length; j++) { + var value = array[j]; + if (predicates.check(value)) { + filtered.push(value); + } + } + return filtered; + }; +} + +/** + * @ngdoc filter + * @name currency + * @kind function + * + * @description + * Formats a number as a currency (ie $1,234.56). When no currency symbol is provided, default + * symbol for current locale is used. + * + * @param {number} amount Input to filter. + * @param {string=} symbol Currency symbol or identifier to be displayed. + * @returns {string} Formatted number. + * + * + * @example + + + +
+
+ default currency symbol ($): {{amount | currency}}
+ custom currency identifier (USD$): {{amount | currency:"USD$"}} +
+
+ + it('should init with 1234.56', function() { + expect(element(by.id('currency-default')).getText()).toBe('$1,234.56'); + expect(element(by.binding('amount | currency:"USD$"')).getText()).toBe('USD$1,234.56'); + }); + it('should update', function() { + if (browser.params.browser == 'safari') { + // Safari does not understand the minus key. See + // https://github.com/angular/protractor/issues/481 + return; + } + element(by.model('amount')).clear(); + element(by.model('amount')).sendKeys('-1234'); + expect(element(by.id('currency-default')).getText()).toBe('($1,234.00)'); + expect(element(by.binding('amount | currency:"USD$"')).getText()).toBe('(USD$1,234.00)'); + }); + +
+ */ +currencyFilter.$inject = ['$locale']; +function currencyFilter($locale) { + var formats = $locale.NUMBER_FORMATS; + return function(amount, currencySymbol){ + if (isUndefined(currencySymbol)) currencySymbol = formats.CURRENCY_SYM; + return formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, 2). + replace(/\u00A4/g, currencySymbol); + }; +} + +/** + * @ngdoc filter + * @name number + * @kind function + * + * @description + * Formats a number as text. + * + * If the input is not a number an empty string is returned. + * + * @param {number|string} number Number to format. + * @param {(number|string)=} fractionSize Number of decimal places to round the number to. + * If this is not provided then the fraction size is computed from the current locale's number + * formatting pattern. In the case of the default locale, it will be 3. + * @returns {string} Number rounded to decimalPlaces and places a “,” after each third digit. + * + * @example + + + +
+ Enter number:
+ Default formatting: {{val | number}}
+ No fractions: {{val | number:0}}
+ Negative number: {{-val | number:4}} +
+
+ + it('should format numbers', function() { + expect(element(by.id('number-default')).getText()).toBe('1,234.568'); + expect(element(by.binding('val | number:0')).getText()).toBe('1,235'); + expect(element(by.binding('-val | number:4')).getText()).toBe('-1,234.5679'); + }); + + it('should update', function() { + element(by.model('val')).clear(); + element(by.model('val')).sendKeys('3374.333'); + expect(element(by.id('number-default')).getText()).toBe('3,374.333'); + expect(element(by.binding('val | number:0')).getText()).toBe('3,374'); + expect(element(by.binding('-val | number:4')).getText()).toBe('-3,374.3330'); + }); + +
+ */ + + +numberFilter.$inject = ['$locale']; +function numberFilter($locale) { + var formats = $locale.NUMBER_FORMATS; + return function(number, fractionSize) { + return formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, + fractionSize); + }; +} + +var DECIMAL_SEP = '.'; +function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { + if (number == null || !isFinite(number) || isObject(number)) return ''; + + var isNegative = number < 0; + number = Math.abs(number); + var numStr = number + '', + formatedText = '', + parts = []; + + var hasExponent = false; + if (numStr.indexOf('e') !== -1) { + var match = numStr.match(/([\d\.]+)e(-?)(\d+)/); + if (match && match[2] == '-' && match[3] > fractionSize + 1) { + numStr = '0'; + number = 0; + } else { + formatedText = numStr; + hasExponent = true; + } + } + + if (!hasExponent) { + var fractionLen = (numStr.split(DECIMAL_SEP)[1] || '').length; + + // determine fractionSize if it is not specified + if (isUndefined(fractionSize)) { + fractionSize = Math.min(Math.max(pattern.minFrac, fractionLen), pattern.maxFrac); + } + + // safely round numbers in JS without hitting imprecisions of floating-point arithmetics + // inspired by: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round + number = +(Math.round(+(number.toString() + 'e' + fractionSize)).toString() + 'e' + -fractionSize); + + var fraction = ('' + number).split(DECIMAL_SEP); + var whole = fraction[0]; + fraction = fraction[1] || ''; + + var i, pos = 0, + lgroup = pattern.lgSize, + group = pattern.gSize; + + if (whole.length >= (lgroup + group)) { + pos = whole.length - lgroup; + for (i = 0; i < pos; i++) { + if ((pos - i)%group === 0 && i !== 0) { + formatedText += groupSep; + } + formatedText += whole.charAt(i); + } + } + + for (i = pos; i < whole.length; i++) { + if ((whole.length - i)%lgroup === 0 && i !== 0) { + formatedText += groupSep; + } + formatedText += whole.charAt(i); + } + + // format fraction part. + while(fraction.length < fractionSize) { + fraction += '0'; + } + + if (fractionSize && fractionSize !== "0") formatedText += decimalSep + fraction.substr(0, fractionSize); + } else { + + if (fractionSize > 0 && number > -1 && number < 1) { + formatedText = number.toFixed(fractionSize); + } + } + + parts.push(isNegative ? pattern.negPre : pattern.posPre); + parts.push(formatedText); + parts.push(isNegative ? pattern.negSuf : pattern.posSuf); + return parts.join(''); +} + +function padNumber(num, digits, trim) { + var neg = ''; + if (num < 0) { + neg = '-'; + num = -num; + } + num = '' + num; + while(num.length < digits) num = '0' + num; + if (trim) + num = num.substr(num.length - digits); + return neg + num; +} + + +function dateGetter(name, size, offset, trim) { + offset = offset || 0; + return function(date) { + var value = date['get' + name](); + if (offset > 0 || value > -offset) + value += offset; + if (value === 0 && offset == -12 ) value = 12; + return padNumber(value, size, trim); + }; +} + +function dateStrGetter(name, shortForm) { + return function(date, formats) { + var value = date['get' + name](); + var get = uppercase(shortForm ? ('SHORT' + name) : name); + + return formats[get][value]; + }; +} + +function timeZoneGetter(date) { + var zone = -1 * date.getTimezoneOffset(); + var paddedZone = (zone >= 0) ? "+" : ""; + + paddedZone += padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 2) + + padNumber(Math.abs(zone % 60), 2); + + return paddedZone; +} + +function ampmGetter(date, formats) { + return date.getHours() < 12 ? formats.AMPMS[0] : formats.AMPMS[1]; +} + +var DATE_FORMATS = { + yyyy: dateGetter('FullYear', 4), + yy: dateGetter('FullYear', 2, 0, true), + y: dateGetter('FullYear', 1), + MMMM: dateStrGetter('Month'), + MMM: dateStrGetter('Month', true), + MM: dateGetter('Month', 2, 1), + M: dateGetter('Month', 1, 1), + dd: dateGetter('Date', 2), + d: dateGetter('Date', 1), + HH: dateGetter('Hours', 2), + H: dateGetter('Hours', 1), + hh: dateGetter('Hours', 2, -12), + h: dateGetter('Hours', 1, -12), + mm: dateGetter('Minutes', 2), + m: dateGetter('Minutes', 1), + ss: dateGetter('Seconds', 2), + s: dateGetter('Seconds', 1), + // while ISO 8601 requires fractions to be prefixed with `.` or `,` + // we can be just safely rely on using `sss` since we currently don't support single or two digit fractions + sss: dateGetter('Milliseconds', 3), + EEEE: dateStrGetter('Day'), + EEE: dateStrGetter('Day', true), + a: ampmGetter, + Z: timeZoneGetter +}; + +var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/, + NUMBER_STRING = /^\-?\d+$/; + +/** + * @ngdoc filter + * @name date + * @kind function + * + * @description + * Formats `date` to a string based on the requested `format`. + * + * `format` string can be composed of the following elements: + * + * * `'yyyy'`: 4 digit representation of year (e.g. AD 1 => 0001, AD 2010 => 2010) + * * `'yy'`: 2 digit representation of year, padded (00-99). (e.g. AD 2001 => 01, AD 2010 => 10) + * * `'y'`: 1 digit representation of year, e.g. (AD 1 => 1, AD 199 => 199) + * * `'MMMM'`: Month in year (January-December) + * * `'MMM'`: Month in year (Jan-Dec) + * * `'MM'`: Month in year, padded (01-12) + * * `'M'`: Month in year (1-12) + * * `'dd'`: Day in month, padded (01-31) + * * `'d'`: Day in month (1-31) + * * `'EEEE'`: Day in Week,(Sunday-Saturday) + * * `'EEE'`: Day in Week, (Sun-Sat) + * * `'HH'`: Hour in day, padded (00-23) + * * `'H'`: Hour in day (0-23) + * * `'hh'`: Hour in am/pm, padded (01-12) + * * `'h'`: Hour in am/pm, (1-12) + * * `'mm'`: Minute in hour, padded (00-59) + * * `'m'`: Minute in hour (0-59) + * * `'ss'`: Second in minute, padded (00-59) + * * `'s'`: Second in minute (0-59) + * * `'.sss' or ',sss'`: Millisecond in second, padded (000-999) + * * `'a'`: am/pm marker + * * `'Z'`: 4 digit (+sign) representation of the timezone offset (-1200-+1200) + * + * `format` string can also be one of the following predefined + * {@link guide/i18n localizable formats}: + * + * * `'medium'`: equivalent to `'MMM d, y h:mm:ss a'` for en_US locale + * (e.g. Sep 3, 2010 12:05:08 pm) + * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 pm) + * * `'fullDate'`: equivalent to `'EEEE, MMMM d,y'` for en_US locale + * (e.g. Friday, September 3, 2010) + * * `'longDate'`: equivalent to `'MMMM d, y'` for en_US locale (e.g. September 3, 2010) + * * `'mediumDate'`: equivalent to `'MMM d, y'` for en_US locale (e.g. Sep 3, 2010) + * * `'shortDate'`: equivalent to `'M/d/yy'` for en_US locale (e.g. 9/3/10) + * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 pm) + * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 pm) + * + * `format` string can contain literal values. These need to be quoted with single quotes (e.g. + * `"h 'in the morning'"`). In order to output single quote, use two single quotes in a sequence + * (e.g. `"h 'o''clock'"`). + * + * @param {(Date|number|string)} date Date to format either as Date object, milliseconds (string or + * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.SSSZ and its + * shorter versions like yyyy-MM-ddTHH:mmZ, yyyy-MM-dd or yyyyMMddTHHmmssZ). If no timezone is + * specified in the string input, the time is considered to be in the local timezone. + * @param {string=} format Formatting rules (see Description). If not specified, + * `mediumDate` is used. + * @returns {string} Formatted string or the input if input is not recognized as date/millis. + * + * @example + + + {{1288323623006 | date:'medium'}}: + {{1288323623006 | date:'medium'}}
+ {{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}: + {{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}
+ {{1288323623006 | date:'MM/dd/yyyy @ h:mma'}}: + {{'1288323623006' | date:'MM/dd/yyyy @ h:mma'}}
+
+ + it('should format date', function() { + expect(element(by.binding("1288323623006 | date:'medium'")).getText()). + toMatch(/Oct 2\d, 2010 \d{1,2}:\d{2}:\d{2} (AM|PM)/); + expect(element(by.binding("1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'")).getText()). + toMatch(/2010\-10\-2\d \d{2}:\d{2}:\d{2} (\-|\+)?\d{4}/); + expect(element(by.binding("'1288323623006' | date:'MM/dd/yyyy @ h:mma'")).getText()). + toMatch(/10\/2\d\/2010 @ \d{1,2}:\d{2}(AM|PM)/); + }); + +
+ */ +dateFilter.$inject = ['$locale']; +function dateFilter($locale) { + + + var R_ISO8601_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; + // 1 2 3 4 5 6 7 8 9 10 11 + function jsonStringToDate(string) { + var match; + if (match = string.match(R_ISO8601_STR)) { + var date = new Date(0), + tzHour = 0, + tzMin = 0, + dateSetter = match[8] ? date.setUTCFullYear : date.setFullYear, + timeSetter = match[8] ? date.setUTCHours : date.setHours; + + if (match[9]) { + tzHour = int(match[9] + match[10]); + tzMin = int(match[9] + match[11]); + } + dateSetter.call(date, int(match[1]), int(match[2]) - 1, int(match[3])); + var h = int(match[4]||0) - tzHour; + var m = int(match[5]||0) - tzMin; + var s = int(match[6]||0); + var ms = Math.round(parseFloat('0.' + (match[7]||0)) * 1000); + timeSetter.call(date, h, m, s, ms); + return date; + } + return string; + } + + + return function(date, format) { + var text = '', + parts = [], + fn, match; + + format = format || 'mediumDate'; + format = $locale.DATETIME_FORMATS[format] || format; + if (isString(date)) { + if (NUMBER_STRING.test(date)) { + date = int(date); + } else { + date = jsonStringToDate(date); + } + } + + if (isNumber(date)) { + date = new Date(date); + } + + if (!isDate(date)) { + return date; + } + + while(format) { + match = DATE_FORMATS_SPLIT.exec(format); + if (match) { + parts = concat(parts, match, 1); + format = parts.pop(); + } else { + parts.push(format); + format = null; + } + } + + forEach(parts, function(value){ + fn = DATE_FORMATS[value]; + text += fn ? fn(date, $locale.DATETIME_FORMATS) + : value.replace(/(^'|'$)/g, '').replace(/''/g, "'"); + }); + + return text; + }; +} + + +/** + * @ngdoc filter + * @name json + * @kind function + * + * @description + * Allows you to convert a JavaScript object into JSON string. + * + * This filter is mostly useful for debugging. When using the double curly {{value}} notation + * the binding is automatically converted to JSON. + * + * @param {*} object Any JavaScript object (including arrays and primitive types) to filter. + * @returns {string} JSON string. + * + * + * @example + + +
{{ {'name':'value'} | json }}
+
+ + it('should jsonify filtered objects', function() { + expect(element(by.binding("{'name':'value'}")).getText()).toMatch(/\{\n "name": ?"value"\n}/); + }); + +
+ * + */ +function jsonFilter() { + return function(object) { + return toJson(object, true); + }; +} + + +/** + * @ngdoc filter + * @name lowercase + * @kind function + * @description + * Converts string to lowercase. + * @see angular.lowercase + */ +var lowercaseFilter = valueFn(lowercase); + + +/** + * @ngdoc filter + * @name uppercase + * @kind function + * @description + * Converts string to uppercase. + * @see angular.uppercase + */ +var uppercaseFilter = valueFn(uppercase); + +/** + * @ngdoc filter + * @name limitTo + * @kind function + * + * @description + * Creates a new array or string containing only a specified number of elements. The elements + * are taken from either the beginning or the end of the source array or string, as specified by + * the value and sign (positive or negative) of `limit`. + * + * @param {Array|string} input Source array or string to be limited. + * @param {string|number} limit The length of the returned array or string. If the `limit` number + * is positive, `limit` number of items from the beginning of the source array/string are copied. + * If the number is negative, `limit` number of items from the end of the source array/string + * are copied. The `limit` will be trimmed if it exceeds `array.length` + * @returns {Array|string} A new sub-array or substring of length `limit` or less if input array + * had less than `limit` elements. + * + * @example + + + +
+ Limit {{numbers}} to: +

Output numbers: {{ numbers | limitTo:numLimit }}

+ Limit {{letters}} to: +

Output letters: {{ letters | limitTo:letterLimit }}

+
+
+ + var numLimitInput = element(by.model('numLimit')); + var letterLimitInput = element(by.model('letterLimit')); + var limitedNumbers = element(by.binding('numbers | limitTo:numLimit')); + var limitedLetters = element(by.binding('letters | limitTo:letterLimit')); + + it('should limit the number array to first three items', function() { + expect(numLimitInput.getAttribute('value')).toBe('3'); + expect(letterLimitInput.getAttribute('value')).toBe('3'); + expect(limitedNumbers.getText()).toEqual('Output numbers: [1,2,3]'); + expect(limitedLetters.getText()).toEqual('Output letters: abc'); + }); + + it('should update the output when -3 is entered', function() { + numLimitInput.clear(); + numLimitInput.sendKeys('-3'); + letterLimitInput.clear(); + letterLimitInput.sendKeys('-3'); + expect(limitedNumbers.getText()).toEqual('Output numbers: [7,8,9]'); + expect(limitedLetters.getText()).toEqual('Output letters: ghi'); + }); + + it('should not exceed the maximum size of input array', function() { + numLimitInput.clear(); + numLimitInput.sendKeys('100'); + letterLimitInput.clear(); + letterLimitInput.sendKeys('100'); + expect(limitedNumbers.getText()).toEqual('Output numbers: [1,2,3,4,5,6,7,8,9]'); + expect(limitedLetters.getText()).toEqual('Output letters: abcdefghi'); + }); + +
+ */ +function limitToFilter(){ + return function(input, limit) { + if (!isArray(input) && !isString(input)) return input; + + if (Math.abs(Number(limit)) === Infinity) { + limit = Number(limit); + } else { + limit = int(limit); + } + + if (isString(input)) { + //NaN check on limit + if (limit) { + return limit >= 0 ? input.slice(0, limit) : input.slice(limit, input.length); + } else { + return ""; + } + } + + var out = [], + i, n; + + // if abs(limit) exceeds maximum length, trim it + if (limit > input.length) + limit = input.length; + else if (limit < -input.length) + limit = -input.length; + + if (limit > 0) { + i = 0; + n = limit; + } else { + i = input.length + limit; + n = input.length; + } + + for (; i} expression A predicate to be + * used by the comparator to determine the order of elements. + * + * Can be one of: + * + * - `function`: Getter function. The result of this function will be sorted using the + * `<`, `=`, `>` operator. + * - `string`: An Angular expression which evaluates to an object to order by, such as 'name' + * to sort by a property called 'name'. Optionally prefixed with `+` or `-` to control + * ascending or descending sort order (for example, +name or -name). + * - `Array`: An array of function or string predicates. The first predicate in the array + * is used for sorting, but when two items are equivalent, the next predicate is used. + * + * @param {boolean=} reverse Reverse the order of the array. + * @returns {Array} Sorted copy of the source array. + * + * @example + + + +
+
Sorting predicate = {{predicate}}; reverse = {{reverse}}
+
+ [ unsorted ] + + + + + + + + + + + +
Name + (^)Phone NumberAge
{{friend.name}}{{friend.phone}}{{friend.age}}
+
+
+
+ * + * It's also possible to call the orderBy filter manually, by injecting `$filter`, retrieving the + * filter routine with `$filter('orderBy')`, and calling the returned filter routine with the + * desired parameters. + * + * Example: + * + * @example + + +
+ + + + + + + + + + + +
Name + (^)Phone NumberAge
{{friend.name}}{{friend.phone}}{{friend.age}}
+
+
+ + + angular.module('orderByExample', []) + .controller('ExampleController', ['$scope', '$filter', function($scope, $filter) { + var orderBy = $filter('orderBy'); + $scope.friends = [ + { name: 'John', phone: '555-1212', age: 10 }, + { name: 'Mary', phone: '555-9876', age: 19 }, + { name: 'Mike', phone: '555-4321', age: 21 }, + { name: 'Adam', phone: '555-5678', age: 35 }, + { name: 'Julie', phone: '555-8765', age: 29 } + ]; + $scope.order = function(predicate, reverse) { + $scope.friends = orderBy($scope.friends, predicate, reverse); + }; + $scope.order('-age',false); + }]); + +
+ */ +orderByFilter.$inject = ['$parse']; +function orderByFilter($parse){ + return function(array, sortPredicate, reverseOrder) { + if (!isArray(array)) return array; + if (!sortPredicate) return array; + sortPredicate = isArray(sortPredicate) ? sortPredicate: [sortPredicate]; + sortPredicate = map(sortPredicate, function(predicate){ + var descending = false, get = predicate || identity; + if (isString(predicate)) { + if ((predicate.charAt(0) == '+' || predicate.charAt(0) == '-')) { + descending = predicate.charAt(0) == '-'; + predicate = predicate.substring(1); + } + get = $parse(predicate); + if (get.constant) { + var key = get(); + return reverseComparator(function(a,b) { + return compare(a[key], b[key]); + }, descending); + } + } + return reverseComparator(function(a,b){ + return compare(get(a),get(b)); + }, descending); + }); + var arrayCopy = []; + for ( var i = 0; i < array.length; i++) { arrayCopy.push(array[i]); } + return arrayCopy.sort(reverseComparator(comparator, reverseOrder)); + + function comparator(o1, o2){ + for ( var i = 0; i < sortPredicate.length; i++) { + var comp = sortPredicate[i](o1, o2); + if (comp !== 0) return comp; + } + return 0; + } + function reverseComparator(comp, descending) { + return toBoolean(descending) + ? function(a,b){return comp(b,a);} + : comp; + } + function compare(v1, v2){ + var t1 = typeof v1; + var t2 = typeof v2; + if (t1 == t2) { + if (t1 == "string") { + v1 = v1.toLowerCase(); + v2 = v2.toLowerCase(); + } + if (v1 === v2) return 0; + return v1 < v2 ? -1 : 1; + } else { + return t1 < t2 ? -1 : 1; + } + } + }; +} + +function ngDirective(directive) { + if (isFunction(directive)) { + directive = { + link: directive + }; + } + directive.restrict = directive.restrict || 'AC'; + return valueFn(directive); +} + +/** + * @ngdoc directive + * @name a + * @restrict E + * + * @description + * Modifies the default behavior of the html A tag so that the default action is prevented when + * the href attribute is empty. + * + * This change permits the easy creation of action links with the `ngClick` directive + * without changing the location or causing page reloads, e.g.: + * `Add Item` + */ +var htmlAnchorDirective = valueFn({ + restrict: 'E', + compile: function(element, attr) { + + if (msie <= 8) { + + // turn link into a stylable link in IE + // but only if it doesn't have name attribute, in which case it's an anchor + if (!attr.href && !attr.name) { + attr.$set('href', ''); + } + + // add a comment node to anchors to workaround IE bug that causes element content to be reset + // to new attribute content if attribute is updated with value containing @ and element also + // contains value with @ + // see issue #1949 + element.append(document.createComment('IE fix')); + } + + if (!attr.href && !attr.xlinkHref && !attr.name) { + return function(scope, element) { + // SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute. + var href = toString.call(element.prop('href')) === '[object SVGAnimatedString]' ? + 'xlink:href' : 'href'; + element.on('click', function(event){ + // if we have no href url, then don't navigate anywhere. + if (!element.attr(href)) { + event.preventDefault(); + } + }); + }; + } + } +}); + +/** + * @ngdoc directive + * @name ngHref + * @restrict A + * @priority 99 + * + * @description + * Using Angular markup like `{{hash}}` in an href attribute will + * make the link go to the wrong URL if the user clicks it before + * Angular has a chance to replace the `{{hash}}` markup with its + * value. Until Angular replaces the markup the link will be broken + * and will most likely return a 404 error. + * + * The `ngHref` directive solves this problem. + * + * The wrong way to write it: + * ```html + * + * ``` + * + * The correct way to write it: + * ```html + * + * ``` + * + * @element A + * @param {template} ngHref any string which can contain `{{}}` markup. + * + * @example + * This example shows various combinations of `href`, `ng-href` and `ng-click` attributes + * in links and their different behaviors: + + +
+
link 1 (link, don't reload)
+ link 2 (link, don't reload)
+ link 3 (link, reload!)
+ anchor (link, don't reload)
+ anchor (no link)
+ link (link, change location) +
+ + it('should execute ng-click but not reload when href without value', function() { + element(by.id('link-1')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('1'); + expect(element(by.id('link-1')).getAttribute('href')).toBe(''); + }); + + it('should execute ng-click but not reload when href empty string', function() { + element(by.id('link-2')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('2'); + expect(element(by.id('link-2')).getAttribute('href')).toBe(''); + }); + + it('should execute ng-click and change url when ng-href specified', function() { + expect(element(by.id('link-3')).getAttribute('href')).toMatch(/\/123$/); + + element(by.id('link-3')).click(); + + // At this point, we navigate away from an Angular page, so we need + // to use browser.driver to get the base webdriver. + + browser.wait(function() { + return browser.driver.getCurrentUrl().then(function(url) { + return url.match(/\/123$/); + }); + }, 5000, 'page should navigate to /123'); + }); + + xit('should execute ng-click but not reload when href empty string and name specified', function() { + element(by.id('link-4')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('4'); + expect(element(by.id('link-4')).getAttribute('href')).toBe(''); + }); + + it('should execute ng-click but not reload when no href but name specified', function() { + element(by.id('link-5')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('5'); + expect(element(by.id('link-5')).getAttribute('href')).toBe(null); + }); + + it('should only change url when only ng-href', function() { + element(by.model('value')).clear(); + element(by.model('value')).sendKeys('6'); + expect(element(by.id('link-6')).getAttribute('href')).toMatch(/\/6$/); + + element(by.id('link-6')).click(); + + // At this point, we navigate away from an Angular page, so we need + // to use browser.driver to get the base webdriver. + browser.wait(function() { + return browser.driver.getCurrentUrl().then(function(url) { + return url.match(/\/6$/); + }); + }, 5000, 'page should navigate to /6'); + }); + + + */ + +/** + * @ngdoc directive + * @name ngSrc + * @restrict A + * @priority 99 + * + * @description + * Using Angular markup like `{{hash}}` in a `src` attribute doesn't + * work right: The browser will fetch from the URL with the literal + * text `{{hash}}` until Angular replaces the expression inside + * `{{hash}}`. The `ngSrc` directive solves this problem. + * + * The buggy way to write it: + * ```html + * + * ``` + * + * The correct way to write it: + * ```html + * + * ``` + * + * @element IMG + * @param {template} ngSrc any string which can contain `{{}}` markup. + */ + +/** + * @ngdoc directive + * @name ngSrcset + * @restrict A + * @priority 99 + * + * @description + * Using Angular markup like `{{hash}}` in a `srcset` attribute doesn't + * work right: The browser will fetch from the URL with the literal + * text `{{hash}}` until Angular replaces the expression inside + * `{{hash}}`. The `ngSrcset` directive solves this problem. + * + * The buggy way to write it: + * ```html + * + * ``` + * + * The correct way to write it: + * ```html + * + * ``` + * + * @element IMG + * @param {template} ngSrcset any string which can contain `{{}}` markup. + */ + +/** + * @ngdoc directive + * @name ngDisabled + * @restrict A + * @priority 100 + * + * @description + * + * The following markup will make the button enabled on Chrome/Firefox but not on IE8 and older IEs: + * ```html + *
+ * + *
+ * ``` + * + * The HTML specification does not require browsers to preserve the values of boolean attributes + * such as disabled. (Their presence means true and their absence means false.) + * If we put an Angular interpolation expression into such an attribute then the + * binding information would be lost when the browser removes the attribute. + * The `ngDisabled` directive solves this problem for the `disabled` attribute. + * This complementary directive is not removed by the browser and so provides + * a permanent reliable place to store the binding information. + * + * @example + + + Click me to toggle:
+ +
+ + it('should toggle button', function() { + expect(element(by.css('button')).getAttribute('disabled')).toBeFalsy(); + element(by.model('checked')).click(); + expect(element(by.css('button')).getAttribute('disabled')).toBeTruthy(); + }); + +
+ * + * @element INPUT + * @param {expression} ngDisabled If the {@link guide/expression expression} is truthy, + * then special attribute "disabled" will be set on the element + */ + + +/** + * @ngdoc directive + * @name ngChecked + * @restrict A + * @priority 100 + * + * @description + * The HTML specification does not require browsers to preserve the values of boolean attributes + * such as checked. (Their presence means true and their absence means false.) + * If we put an Angular interpolation expression into such an attribute then the + * binding information would be lost when the browser removes the attribute. + * The `ngChecked` directive solves this problem for the `checked` attribute. + * This complementary directive is not removed by the browser and so provides + * a permanent reliable place to store the binding information. + * @example + + + Check me to check both:
+ +
+ + it('should check both checkBoxes', function() { + expect(element(by.id('checkSlave')).getAttribute('checked')).toBeFalsy(); + element(by.model('master')).click(); + expect(element(by.id('checkSlave')).getAttribute('checked')).toBeTruthy(); + }); + +
+ * + * @element INPUT + * @param {expression} ngChecked If the {@link guide/expression expression} is truthy, + * then special attribute "checked" will be set on the element + */ + + +/** + * @ngdoc directive + * @name ngReadonly + * @restrict A + * @priority 100 + * + * @description + * The HTML specification does not require browsers to preserve the values of boolean attributes + * such as readonly. (Their presence means true and their absence means false.) + * If we put an Angular interpolation expression into such an attribute then the + * binding information would be lost when the browser removes the attribute. + * The `ngReadonly` directive solves this problem for the `readonly` attribute. + * This complementary directive is not removed by the browser and so provides + * a permanent reliable place to store the binding information. + * @example + + + Check me to make text readonly:
+ +
+ + it('should toggle readonly attr', function() { + expect(element(by.css('[type="text"]')).getAttribute('readonly')).toBeFalsy(); + element(by.model('checked')).click(); + expect(element(by.css('[type="text"]')).getAttribute('readonly')).toBeTruthy(); + }); + +
+ * + * @element INPUT + * @param {expression} ngReadonly If the {@link guide/expression expression} is truthy, + * then special attribute "readonly" will be set on the element + */ + + +/** + * @ngdoc directive + * @name ngSelected + * @restrict A + * @priority 100 + * + * @description + * The HTML specification does not require browsers to preserve the values of boolean attributes + * such as selected. (Their presence means true and their absence means false.) + * If we put an Angular interpolation expression into such an attribute then the + * binding information would be lost when the browser removes the attribute. + * The `ngSelected` directive solves this problem for the `selected` attribute. + * This complementary directive is not removed by the browser and so provides + * a permanent reliable place to store the binding information. + * + * @example + + + Check me to select:
+ +
+ + it('should select Greetings!', function() { + expect(element(by.id('greet')).getAttribute('selected')).toBeFalsy(); + element(by.model('selected')).click(); + expect(element(by.id('greet')).getAttribute('selected')).toBeTruthy(); + }); + +
+ * + * @element OPTION + * @param {expression} ngSelected If the {@link guide/expression expression} is truthy, + * then special attribute "selected" will be set on the element + */ + +/** + * @ngdoc directive + * @name ngOpen + * @restrict A + * @priority 100 + * + * @description + * The HTML specification does not require browsers to preserve the values of boolean attributes + * such as open. (Their presence means true and their absence means false.) + * If we put an Angular interpolation expression into such an attribute then the + * binding information would be lost when the browser removes the attribute. + * The `ngOpen` directive solves this problem for the `open` attribute. + * This complementary directive is not removed by the browser and so provides + * a permanent reliable place to store the binding information. + * @example + + + Check me check multiple:
+
+ Show/Hide me +
+
+ + it('should toggle open', function() { + expect(element(by.id('details')).getAttribute('open')).toBeFalsy(); + element(by.model('open')).click(); + expect(element(by.id('details')).getAttribute('open')).toBeTruthy(); + }); + +
+ * + * @element DETAILS + * @param {expression} ngOpen If the {@link guide/expression expression} is truthy, + * then special attribute "open" will be set on the element + */ + +var ngAttributeAliasDirectives = {}; + + +// boolean attrs are evaluated +forEach(BOOLEAN_ATTR, function(propName, attrName) { + // binding to multiple is not supported + if (propName == "multiple") return; + + var normalized = directiveNormalize('ng-' + attrName); + ngAttributeAliasDirectives[normalized] = function() { + return { + priority: 100, + link: function(scope, element, attr) { + scope.$watch(attr[normalized], function ngBooleanAttrWatchAction(value) { + attr.$set(attrName, !!value); + }); + } + }; + }; +}); + + +// ng-src, ng-srcset, ng-href are interpolated +forEach(['src', 'srcset', 'href'], function(attrName) { + var normalized = directiveNormalize('ng-' + attrName); + ngAttributeAliasDirectives[normalized] = function() { + return { + priority: 99, // it needs to run after the attributes are interpolated + link: function(scope, element, attr) { + var propName = attrName, + name = attrName; + + if (attrName === 'href' && + toString.call(element.prop('href')) === '[object SVGAnimatedString]') { + name = 'xlinkHref'; + attr.$attr[name] = 'xlink:href'; + propName = null; + } + + attr.$observe(normalized, function(value) { + if (!value) + return; + + attr.$set(name, value); + + // on IE, if "ng:src" directive declaration is used and "src" attribute doesn't exist + // then calling element.setAttribute('src', 'foo') doesn't do anything, so we need + // to set the property as well to achieve the desired effect. + // we use attr[attrName] value since $set can sanitize the url. + if (msie && propName) element.prop(propName, attr[name]); + }); + } + }; + }; +}); + +/* global -nullFormCtrl */ +var nullFormCtrl = { + $addControl: noop, + $removeControl: noop, + $setValidity: noop, + $setDirty: noop, + $setPristine: noop +}; + +/** + * @ngdoc type + * @name form.FormController + * + * @property {boolean} $pristine True if user has not interacted with the form yet. + * @property {boolean} $dirty True if user has already interacted with the form. + * @property {boolean} $valid True if all of the containing forms and controls are valid. + * @property {boolean} $invalid True if at least one containing control or form is invalid. + * + * @property {Object} $error Is an object hash, containing references to all invalid controls or + * forms, where: + * + * - keys are validation tokens (error names), + * - values are arrays of controls or forms that are invalid for given error name. + * + * + * Built-in validation tokens: + * + * - `email` + * - `max` + * - `maxlength` + * - `min` + * - `minlength` + * - `number` + * - `pattern` + * - `required` + * - `url` + * + * @description + * `FormController` keeps track of all its controls and nested forms as well as the state of them, + * such as being valid/invalid or dirty/pristine. + * + * Each {@link ng.directive:form form} directive creates an instance + * of `FormController`. + * + */ +//asks for $scope to fool the BC controller module +FormController.$inject = ['$element', '$attrs', '$scope', '$animate']; +function FormController(element, attrs, $scope, $animate) { + var form = this, + parentForm = element.parent().controller('form') || nullFormCtrl, + invalidCount = 0, // used to easily determine if we are valid + errors = form.$error = {}, + controls = []; + + // init state + form.$name = attrs.name || attrs.ngForm; + form.$dirty = false; + form.$pristine = true; + form.$valid = true; + form.$invalid = false; + + parentForm.$addControl(form); + + // Setup initial state of the control + element.addClass(PRISTINE_CLASS); + toggleValidCss(true); + + // convenience method for easy toggling of classes + function toggleValidCss(isValid, validationErrorKey) { + validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; + $animate.removeClass(element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); + $animate.addClass(element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); + } + + /** + * @ngdoc method + * @name form.FormController#$addControl + * + * @description + * Register a control with the form. + * + * Input elements using ngModelController do this automatically when they are linked. + */ + form.$addControl = function(control) { + // Breaking change - before, inputs whose name was "hasOwnProperty" were quietly ignored + // and not added to the scope. Now we throw an error. + assertNotHasOwnProperty(control.$name, 'input'); + controls.push(control); + + if (control.$name) { + form[control.$name] = control; + } + }; + + /** + * @ngdoc method + * @name form.FormController#$removeControl + * + * @description + * Deregister a control from the form. + * + * Input elements using ngModelController do this automatically when they are destroyed. + */ + form.$removeControl = function(control) { + if (control.$name && form[control.$name] === control) { + delete form[control.$name]; + } + forEach(errors, function(queue, validationToken) { + form.$setValidity(validationToken, true, control); + }); + + arrayRemove(controls, control); + }; + + /** + * @ngdoc method + * @name form.FormController#$setValidity + * + * @description + * Sets the validity of a form control. + * + * This method will also propagate to parent forms. + */ + form.$setValidity = function(validationToken, isValid, control) { + var queue = errors[validationToken]; + + if (isValid) { + if (queue) { + arrayRemove(queue, control); + if (!queue.length) { + invalidCount--; + if (!invalidCount) { + toggleValidCss(isValid); + form.$valid = true; + form.$invalid = false; + } + errors[validationToken] = false; + toggleValidCss(true, validationToken); + parentForm.$setValidity(validationToken, true, form); + } + } + + } else { + if (!invalidCount) { + toggleValidCss(isValid); + } + if (queue) { + if (includes(queue, control)) return; + } else { + errors[validationToken] = queue = []; + invalidCount++; + toggleValidCss(false, validationToken); + parentForm.$setValidity(validationToken, false, form); + } + queue.push(control); + + form.$valid = false; + form.$invalid = true; + } + }; + + /** + * @ngdoc method + * @name form.FormController#$setDirty + * + * @description + * Sets the form to a dirty state. + * + * This method can be called to add the 'ng-dirty' class and set the form to a dirty + * state (ng-dirty class). This method will also propagate to parent forms. + */ + form.$setDirty = function() { + $animate.removeClass(element, PRISTINE_CLASS); + $animate.addClass(element, DIRTY_CLASS); + form.$dirty = true; + form.$pristine = false; + parentForm.$setDirty(); + }; + + /** + * @ngdoc method + * @name form.FormController#$setPristine + * + * @description + * Sets the form to its pristine state. + * + * This method can be called to remove the 'ng-dirty' class and set the form to its pristine + * state (ng-pristine class). This method will also propagate to all the controls contained + * in this form. + * + * Setting a form back to a pristine state is often useful when we want to 'reuse' a form after + * saving or resetting it. + */ + form.$setPristine = function () { + $animate.removeClass(element, DIRTY_CLASS); + $animate.addClass(element, PRISTINE_CLASS); + form.$dirty = false; + form.$pristine = true; + forEach(controls, function(control) { + control.$setPristine(); + }); + }; +} + + +/** + * @ngdoc directive + * @name ngForm + * @restrict EAC + * + * @description + * Nestable alias of {@link ng.directive:form `form`} directive. HTML + * does not allow nesting of form elements. It is useful to nest forms, for example if the validity of a + * sub-group of controls needs to be determined. + * + * Note: the purpose of `ngForm` is to group controls, + * but not to be a replacement for the `
` tag with all of its capabilities + * (e.g. posting to the server, ...). + * + * @param {string=} ngForm|name Name of the form. If specified, the form controller will be published into + * related scope, under this name. + * + */ + + /** + * @ngdoc directive + * @name form + * @restrict E + * + * @description + * Directive that instantiates + * {@link form.FormController FormController}. + * + * If the `name` attribute is specified, the form controller is published onto the current scope under + * this name. + * + * # Alias: {@link ng.directive:ngForm `ngForm`} + * + * In Angular forms can be nested. This means that the outer form is valid when all of the child + * forms are valid as well. However, browsers do not allow nesting of `` elements, so + * Angular provides the {@link ng.directive:ngForm `ngForm`} directive which behaves identically to + * `` but can be nested. This allows you to have nested forms, which is very useful when + * using Angular validation directives in forms that are dynamically generated using the + * {@link ng.directive:ngRepeat `ngRepeat`} directive. Since you cannot dynamically generate the `name` + * attribute of input elements using interpolation, you have to wrap each set of repeated inputs in an + * `ngForm` directive and nest these in an outer `form` element. + * + * + * # CSS classes + * - `ng-valid` is set if the form is valid. + * - `ng-invalid` is set if the form is invalid. + * - `ng-pristine` is set if the form is pristine. + * - `ng-dirty` is set if the form is dirty. + * + * Keep in mind that ngAnimate can detect each of these classes when added and removed. + * + * + * # Submitting a form and preventing the default action + * + * Since the role of forms in client-side Angular applications is different than in classical + * roundtrip apps, it is desirable for the browser not to translate the form submission into a full + * page reload that sends the data to the server. Instead some javascript logic should be triggered + * to handle the form submission in an application-specific way. + * + * For this reason, Angular prevents the default action (form submission to the server) unless the + * `` element has an `action` attribute specified. + * + * You can use one of the following two ways to specify what javascript method should be called when + * a form is submitted: + * + * - {@link ng.directive:ngSubmit ngSubmit} directive on the form element + * - {@link ng.directive:ngClick ngClick} directive on the first + * button or input field of type submit (input[type=submit]) + * + * To prevent double execution of the handler, use only one of the {@link ng.directive:ngSubmit ngSubmit} + * or {@link ng.directive:ngClick ngClick} directives. + * This is because of the following form submission rules in the HTML specification: + * + * - If a form has only one input field then hitting enter in this field triggers form submit + * (`ngSubmit`) + * - if a form has 2+ input fields and no buttons or input[type=submit] then hitting enter + * doesn't trigger submit + * - if a form has one or more input fields and one or more buttons or input[type=submit] then + * hitting enter in any of the input fields will trigger the click handler on the *first* button or + * input[type=submit] (`ngClick`) *and* a submit handler on the enclosing form (`ngSubmit`) + * + * @param {string=} name Name of the form. If specified, the form controller will be published into + * related scope, under this name. + * + * ## Animation Hooks + * + * Animations in ngForm are triggered when any of the associated CSS classes are added and removed. + * These classes are: `.ng-pristine`, `.ng-dirty`, `.ng-invalid` and `.ng-valid` as well as any + * other validations that are performed within the form. Animations in ngForm are similar to how + * they work in ngClass and animations can be hooked into using CSS transitions, keyframes as well + * as JS animations. + * + * The following example shows a simple way to utilize CSS transitions to style a form element + * that has been rendered as invalid after it has been validated: + * + *
+ * //be sure to include ngAnimate as a module to hook into more
+ * //advanced animations
+ * .my-form {
+ *   transition:0.5s linear all;
+ *   background: white;
+ * }
+ * .my-form.ng-invalid {
+ *   background: red;
+ *   color:white;
+ * }
+ * 
+ * + * @example + + + + + + userType: + Required!
+ userType = {{userType}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+ +
+ + it('should initialize to model', function() { + var userType = element(by.binding('userType')); + var valid = element(by.binding('myForm.input.$valid')); + + expect(userType.getText()).toContain('guest'); + expect(valid.getText()).toContain('true'); + }); + + it('should be invalid if empty', function() { + var userType = element(by.binding('userType')); + var valid = element(by.binding('myForm.input.$valid')); + var userInput = element(by.model('userType')); + + userInput.clear(); + userInput.sendKeys(''); + + expect(userType.getText()).toEqual('userType ='); + expect(valid.getText()).toContain('false'); + }); + +
+ * + */ +var formDirectiveFactory = function(isNgForm) { + return ['$timeout', function($timeout) { + var formDirective = { + name: 'form', + restrict: isNgForm ? 'EAC' : 'E', + controller: FormController, + compile: function() { + return { + pre: function(scope, formElement, attr, controller) { + if (!attr.action) { + // we can't use jq events because if a form is destroyed during submission the default + // action is not prevented. see #1238 + // + // IE 9 is not affected because it doesn't fire a submit event and try to do a full + // page reload if the form was destroyed by submission of the form via a click handler + // on a button in the form. Looks like an IE9 specific bug. + var preventDefaultListener = function(event) { + event.preventDefault + ? event.preventDefault() + : event.returnValue = false; // IE + }; + + addEventListenerFn(formElement[0], 'submit', preventDefaultListener); + + // unregister the preventDefault listener so that we don't not leak memory but in a + // way that will achieve the prevention of the default action. + formElement.on('$destroy', function() { + $timeout(function() { + removeEventListenerFn(formElement[0], 'submit', preventDefaultListener); + }, 0, false); + }); + } + + var parentFormCtrl = formElement.parent().controller('form'), + alias = attr.name || attr.ngForm; + + if (alias) { + setter(scope, alias, controller, alias); + } + if (parentFormCtrl) { + formElement.on('$destroy', function() { + parentFormCtrl.$removeControl(controller); + if (alias) { + setter(scope, alias, undefined, alias); + } + extend(controller, nullFormCtrl); //stop propagating child destruction handlers upwards + }); + } + } + }; + } + }; + + return formDirective; + }]; +}; + +var formDirective = formDirectiveFactory(); +var ngFormDirective = formDirectiveFactory(true); + +/* global + + -VALID_CLASS, + -INVALID_CLASS, + -PRISTINE_CLASS, + -DIRTY_CLASS +*/ + +var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; +var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; +var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; + +var inputType = { + + /** + * @ngdoc input + * @name input[text] + * + * @description + * Standard HTML text input with angular data binding. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Adds `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. + * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * + * @example + + + +
+ Single word: + + Required! + + Single word only! + + text = {{text}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var text = element(by.binding('text')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('text')); + + it('should initialize to model', function() { + expect(text.getText()).toContain('guest'); + expect(valid.getText()).toContain('true'); + }); + + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + + expect(text.getText()).toEqual('text ='); + expect(valid.getText()).toContain('false'); + }); + + it('should be invalid if multi word', function() { + input.clear(); + input.sendKeys('hello world'); + + expect(valid.getText()).toContain('false'); + }); + +
+ */ + 'text': textInputType, + + + /** + * @ngdoc input + * @name input[number] + * + * @description + * Text input with number validation and transformation. Sets the `number` validation + * error if not a valid number. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. + * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Number: + + Required! + + Not valid number! + value = {{value}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var value = element(by.binding('value')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + it('should initialize to model', function() { + expect(value.getText()).toContain('12'); + expect(valid.getText()).toContain('true'); + }); + + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('false'); + }); + + it('should be invalid if over max', function() { + input.clear(); + input.sendKeys('123'); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('false'); + }); + +
+ */ + 'number': numberInputType, + + + /** + * @ngdoc input + * @name input[url] + * + * @description + * Text input with URL validation. Sets the `url` validation error key if the content is not a + * valid URL. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. + * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ URL: + + Required! + + Not valid url! + text = {{text}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+ myForm.$error.url = {{!!myForm.$error.url}}
+
+
+ + var text = element(by.binding('text')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('text')); + + it('should initialize to model', function() { + expect(text.getText()).toContain('http://google.com'); + expect(valid.getText()).toContain('true'); + }); + + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + + expect(text.getText()).toEqual('text ='); + expect(valid.getText()).toContain('false'); + }); + + it('should be invalid if not url', function() { + input.clear(); + input.sendKeys('box'); + + expect(valid.getText()).toContain('false'); + }); + +
+ */ + 'url': urlInputType, + + + /** + * @ngdoc input + * @name input[email] + * + * @description + * Text input with email validation. Sets the `email` validation error key if not a valid email + * address. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. + * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Email: + + Required! + + Not valid email! + text = {{text}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+ myForm.$error.email = {{!!myForm.$error.email}}
+
+
+ + var text = element(by.binding('text')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('text')); + + it('should initialize to model', function() { + expect(text.getText()).toContain('me@example.com'); + expect(valid.getText()).toContain('true'); + }); + + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + expect(text.getText()).toEqual('text ='); + expect(valid.getText()).toContain('false'); + }); + + it('should be invalid if not email', function() { + input.clear(); + input.sendKeys('xxx'); + + expect(valid.getText()).toContain('false'); + }); + +
+ */ + 'email': emailInputType, + + + /** + * @ngdoc input + * @name input[radio] + * + * @description + * HTML radio button. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string} value The value to which the expression should be set when selected. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * @param {string} ngValue Angular expression which sets the value to which the expression should + * be set when selected. + * + * @example + + + +
+ Red
+ Green
+ Blue
+ color = {{color | json}}
+
+ Note that `ng-value="specialValue"` sets radio item's value to be the value of `$scope.specialValue`. +
+ + it('should change state', function() { + var color = element(by.binding('color')); + + expect(color.getText()).toContain('blue'); + + element.all(by.model('color')).get(0).click(); + + expect(color.getText()).toContain('red'); + }); + +
+ */ + 'radio': radioInputType, + + + /** + * @ngdoc input + * @name input[checkbox] + * + * @description + * HTML checkbox. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} ngTrueValue The value to which the expression should be set when selected. + * @param {string=} ngFalseValue The value to which the expression should be set when not selected. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Value1:
+ Value2:
+ value1 = {{value1}}
+ value2 = {{value2}}
+
+
+ + it('should change state', function() { + var value1 = element(by.binding('value1')); + var value2 = element(by.binding('value2')); + + expect(value1.getText()).toContain('true'); + expect(value2.getText()).toContain('YES'); + + element(by.model('value1')).click(); + element(by.model('value2')).click(); + + expect(value1.getText()).toContain('false'); + expect(value2.getText()).toContain('NO'); + }); + +
+ */ + 'checkbox': checkboxInputType, + + 'hidden': noop, + 'button': noop, + 'submit': noop, + 'reset': noop, + 'file': noop +}; + +// A helper function to call $setValidity and return the value / undefined, +// a pattern that is repeated a lot in the input validation logic. +function validate(ctrl, validatorName, validity, value){ + ctrl.$setValidity(validatorName, validity); + return validity ? value : undefined; +} + +function testFlags(validity, flags) { + var i, flag; + if (flags) { + for (i=0; i + if (toBoolean(attr.ngTrim || 'T')) { + value = trim(value); + } + + // If a control is suffering from bad input, browsers discard its value, so it may be + // necessary to revalidate even if the control's value is the same empty value twice in + // a row. + var revalidate = validity && ctrl.$$hasNativeValidators; + if (ctrl.$viewValue !== value || (value === '' && revalidate)) { + if (scope.$$phase) { + ctrl.$setViewValue(value); + } else { + scope.$apply(function() { + ctrl.$setViewValue(value); + }); + } + } + }; + + // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the + // input event on backspace, delete or cut + if ($sniffer.hasEvent('input')) { + element.on('input', listener); + } else { + var timeout; + + var deferListener = function() { + if (!timeout) { + timeout = $browser.defer(function() { + listener(); + timeout = null; + }); + } + }; + + element.on('keydown', function(event) { + var key = event.keyCode; + + // ignore + // command modifiers arrows + if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + + deferListener(); + }); + + // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it + if ($sniffer.hasEvent('paste')) { + element.on('paste cut', deferListener); + } + } + + // if user paste into input using mouse on older browser + // or form autocomplete on newer browser, we need "change" event to catch it + element.on('change', listener); + + ctrl.$render = function() { + element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); + }; + + // pattern validator + var pattern = attr.ngPattern, + patternValidator, + match; + + if (pattern) { + var validateRegex = function(regexp, value) { + return validate(ctrl, 'pattern', ctrl.$isEmpty(value) || regexp.test(value), value); + }; + match = pattern.match(/^\/(.*)\/([gim]*)$/); + if (match) { + pattern = new RegExp(match[1], match[2]); + patternValidator = function(value) { + return validateRegex(pattern, value); + }; + } else { + patternValidator = function(value) { + var patternObj = scope.$eval(pattern); + + if (!patternObj || !patternObj.test) { + throw minErr('ngPattern')('noregexp', + 'Expected {0} to be a RegExp but was {1}. Element: {2}', pattern, + patternObj, startingTag(element)); + } + return validateRegex(patternObj, value); + }; + } + + ctrl.$formatters.push(patternValidator); + ctrl.$parsers.push(patternValidator); + } + + // min length validator + if (attr.ngMinlength) { + var minlength = int(attr.ngMinlength); + var minLengthValidator = function(value) { + return validate(ctrl, 'minlength', ctrl.$isEmpty(value) || value.length >= minlength, value); + }; + + ctrl.$parsers.push(minLengthValidator); + ctrl.$formatters.push(minLengthValidator); + } + + // max length validator + if (attr.ngMaxlength) { + var maxlength = int(attr.ngMaxlength); + var maxLengthValidator = function(value) { + return validate(ctrl, 'maxlength', ctrl.$isEmpty(value) || value.length <= maxlength, value); + }; + + ctrl.$parsers.push(maxLengthValidator); + ctrl.$formatters.push(maxLengthValidator); + } +} + +var numberBadFlags = ['badInput']; + +function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); + + ctrl.$parsers.push(function(value) { + var empty = ctrl.$isEmpty(value); + if (empty || NUMBER_REGEXP.test(value)) { + ctrl.$setValidity('number', true); + return value === '' ? null : (empty ? value : parseFloat(value)); + } else { + ctrl.$setValidity('number', false); + return undefined; + } + }); + + addNativeHtml5Validators(ctrl, 'number', numberBadFlags, null, ctrl.$$validityState); + + ctrl.$formatters.push(function(value) { + return ctrl.$isEmpty(value) ? '' : '' + value; + }); + + if (attr.min) { + var minValidator = function(value) { + var min = parseFloat(attr.min); + return validate(ctrl, 'min', ctrl.$isEmpty(value) || value >= min, value); + }; + + ctrl.$parsers.push(minValidator); + ctrl.$formatters.push(minValidator); + } + + if (attr.max) { + var maxValidator = function(value) { + var max = parseFloat(attr.max); + return validate(ctrl, 'max', ctrl.$isEmpty(value) || value <= max, value); + }; + + ctrl.$parsers.push(maxValidator); + ctrl.$formatters.push(maxValidator); + } + + ctrl.$formatters.push(function(value) { + return validate(ctrl, 'number', ctrl.$isEmpty(value) || isNumber(value), value); + }); +} + +function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); + + var urlValidator = function(value) { + return validate(ctrl, 'url', ctrl.$isEmpty(value) || URL_REGEXP.test(value), value); + }; + + ctrl.$formatters.push(urlValidator); + ctrl.$parsers.push(urlValidator); +} + +function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); + + var emailValidator = function(value) { + return validate(ctrl, 'email', ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value), value); + }; + + ctrl.$formatters.push(emailValidator); + ctrl.$parsers.push(emailValidator); +} + +function radioInputType(scope, element, attr, ctrl) { + // make the name unique, if not defined + if (isUndefined(attr.name)) { + element.attr('name', nextUid()); + } + + element.on('click', function() { + if (element[0].checked) { + scope.$apply(function() { + ctrl.$setViewValue(attr.value); + }); + } + }); + + ctrl.$render = function() { + var value = attr.value; + element[0].checked = (value == ctrl.$viewValue); + }; + + attr.$observe('value', ctrl.$render); +} + +function checkboxInputType(scope, element, attr, ctrl) { + var trueValue = attr.ngTrueValue, + falseValue = attr.ngFalseValue; + + if (!isString(trueValue)) trueValue = true; + if (!isString(falseValue)) falseValue = false; + + element.on('click', function() { + scope.$apply(function() { + ctrl.$setViewValue(element[0].checked); + }); + }); + + ctrl.$render = function() { + element[0].checked = ctrl.$viewValue; + }; + + // Override the standard `$isEmpty` because a value of `false` means empty in a checkbox. + ctrl.$isEmpty = function(value) { + return value !== trueValue; + }; + + ctrl.$formatters.push(function(value) { + return value === trueValue; + }); + + ctrl.$parsers.push(function(value) { + return value ? trueValue : falseValue; + }); +} + + +/** + * @ngdoc directive + * @name textarea + * @restrict E + * + * @description + * HTML textarea element control with angular data-binding. The data-binding and validation + * properties of this element are exactly the same as those of the + * {@link ng.directive:input input element}. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. + * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + */ + + +/** + * @ngdoc directive + * @name input + * @restrict E + * + * @description + * HTML input element control with angular data-binding. Input control follows HTML5 input types + * and polyfills the HTML5 validation behavior for older browsers. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {boolean=} ngRequired Sets `required` attribute if set to true + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. + * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+
+ User name: + + Required!
+ Last name: + + Too short! + + Too long!
+
+
+ user = {{user}}
+ myForm.userName.$valid = {{myForm.userName.$valid}}
+ myForm.userName.$error = {{myForm.userName.$error}}
+ myForm.lastName.$valid = {{myForm.lastName.$valid}}
+ myForm.lastName.$error = {{myForm.lastName.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+ myForm.$error.minlength = {{!!myForm.$error.minlength}}
+ myForm.$error.maxlength = {{!!myForm.$error.maxlength}}
+
+
+ + var user = element(by.binding('{{user}}')); + var userNameValid = element(by.binding('myForm.userName.$valid')); + var lastNameValid = element(by.binding('myForm.lastName.$valid')); + var lastNameError = element(by.binding('myForm.lastName.$error')); + var formValid = element(by.binding('myForm.$valid')); + var userNameInput = element(by.model('user.name')); + var userLastInput = element(by.model('user.last')); + + it('should initialize to model', function() { + expect(user.getText()).toContain('{"name":"guest","last":"visitor"}'); + expect(userNameValid.getText()).toContain('true'); + expect(formValid.getText()).toContain('true'); + }); + + it('should be invalid if empty when required', function() { + userNameInput.clear(); + userNameInput.sendKeys(''); + + expect(user.getText()).toContain('{"last":"visitor"}'); + expect(userNameValid.getText()).toContain('false'); + expect(formValid.getText()).toContain('false'); + }); + + it('should be valid if empty when min length is set', function() { + userLastInput.clear(); + userLastInput.sendKeys(''); + + expect(user.getText()).toContain('{"name":"guest","last":""}'); + expect(lastNameValid.getText()).toContain('true'); + expect(formValid.getText()).toContain('true'); + }); + + it('should be invalid if less than required min length', function() { + userLastInput.clear(); + userLastInput.sendKeys('xx'); + + expect(user.getText()).toContain('{"name":"guest"}'); + expect(lastNameValid.getText()).toContain('false'); + expect(lastNameError.getText()).toContain('minlength'); + expect(formValid.getText()).toContain('false'); + }); + + it('should be invalid if longer than max length', function() { + userLastInput.clear(); + userLastInput.sendKeys('some ridiculously long name'); + + expect(user.getText()).toContain('{"name":"guest"}'); + expect(lastNameValid.getText()).toContain('false'); + expect(lastNameError.getText()).toContain('maxlength'); + expect(formValid.getText()).toContain('false'); + }); + +
+ */ +var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { + return { + restrict: 'E', + require: '?ngModel', + link: function(scope, element, attr, ctrl) { + if (ctrl) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, + $browser); + } + } + }; +}]; + +var VALID_CLASS = 'ng-valid', + INVALID_CLASS = 'ng-invalid', + PRISTINE_CLASS = 'ng-pristine', + DIRTY_CLASS = 'ng-dirty'; + +/** + * @ngdoc type + * @name ngModel.NgModelController + * + * @property {string} $viewValue Actual string value in the view. + * @property {*} $modelValue The value in the model, that the control is bound to. + * @property {Array.} $parsers Array of functions to execute, as a pipeline, whenever + the control reads value from the DOM. Each function is called, in turn, passing the value + through to the next. The last return value is used to populate the model. + Used to sanitize / convert the value as well as validation. For validation, + the parsers should update the validity state using + {@link ngModel.NgModelController#$setValidity $setValidity()}, + and return `undefined` for invalid values. + + * + * @property {Array.} $formatters Array of functions to execute, as a pipeline, whenever + the model value changes. Each function is called, in turn, passing the value through to the + next. Used to format / convert values for display in the control and validation. + * ```js + * function formatter(value) { + * if (value) { + * return value.toUpperCase(); + * } + * } + * ngModel.$formatters.push(formatter); + * ``` + * + * @property {Array.} $viewChangeListeners Array of functions to execute whenever the + * view value has changed. It is called with no arguments, and its return value is ignored. + * This can be used in place of additional $watches against the model value. + * + * @property {Object} $error An object hash with all errors as keys. + * + * @property {boolean} $pristine True if user has not interacted with the control yet. + * @property {boolean} $dirty True if user has already interacted with the control. + * @property {boolean} $valid True if there is no error. + * @property {boolean} $invalid True if at least one error on the control. + * + * @description + * + * `NgModelController` provides API for the `ng-model` directive. The controller contains + * services for data-binding, validation, CSS updates, and value formatting and parsing. It + * purposefully does not contain any logic which deals with DOM rendering or listening to + * DOM events. Such DOM related logic should be provided by other directives which make use of + * `NgModelController` for data-binding. + * + * ## Custom Control Example + * This example shows how to use `NgModelController` with a custom control to achieve + * data-binding. Notice how different directives (`contenteditable`, `ng-model`, and `required`) + * collaborate together to achieve the desired result. + * + * Note that `contenteditable` is an HTML5 attribute, which tells the browser to let the element + * contents be edited in place by the user. This will not work on older browsers. + * + * We are using the {@link ng.service:$sce $sce} service here and include the {@link ngSanitize $sanitize} + * module to automatically remove "bad" content like inline event listener (e.g. ``). + * However, as we are using `$sce` the model can still decide to to provide unsafe content if it marks + * that content using the `$sce` service. + * + * + + [contenteditable] { + border: 1px solid black; + background-color: white; + min-height: 20px; + } + + .ng-invalid { + border: 1px solid red; + } + + + + angular.module('customControl', ['ngSanitize']). + directive('contenteditable', ['$sce', function($sce) { + return { + restrict: 'A', // only activate on element attribute + require: '?ngModel', // get a hold of NgModelController + link: function(scope, element, attrs, ngModel) { + if(!ngModel) return; // do nothing if no ng-model + + // Specify how UI should be updated + ngModel.$render = function() { + element.html($sce.getTrustedHtml(ngModel.$viewValue || '')); + }; + + // Listen for change events to enable binding + element.on('blur keyup change', function() { + scope.$apply(read); + }); + read(); // initialize + + // Write data to the model + function read() { + var html = element.html(); + // When we clear the content editable the browser leaves a
behind + // If strip-br attribute is provided then we strip this out + if( attrs.stripBr && html == '
' ) { + html = ''; + } + ngModel.$setViewValue(html); + } + } + }; + }]); +
+ +
+
Change me!
+ Required! +
+ +
+
+ + it('should data-bind and become invalid', function() { + if (browser.params.browser == 'safari' || browser.params.browser == 'firefox') { + // SafariDriver can't handle contenteditable + // and Firefox driver can't clear contenteditables very well + return; + } + var contentEditable = element(by.css('[contenteditable]')); + var content = 'Change me!'; + + expect(contentEditable.getText()).toEqual(content); + + contentEditable.clear(); + contentEditable.sendKeys(protractor.Key.BACK_SPACE); + expect(contentEditable.getText()).toEqual(''); + expect(contentEditable.getAttribute('class')).toMatch(/ng-invalid-required/); + }); + + *
+ * + * + */ +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', + function($scope, $exceptionHandler, $attr, $element, $parse, $animate) { + this.$viewValue = Number.NaN; + this.$modelValue = Number.NaN; + this.$parsers = []; + this.$formatters = []; + this.$viewChangeListeners = []; + this.$pristine = true; + this.$dirty = false; + this.$valid = true; + this.$invalid = false; + this.$name = $attr.name; + + var ngModelGet = $parse($attr.ngModel), + ngModelSet = ngModelGet.assign; + + if (!ngModelSet) { + throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}", + $attr.ngModel, startingTag($element)); + } + + /** + * @ngdoc method + * @name ngModel.NgModelController#$render + * + * @description + * Called when the view needs to be updated. It is expected that the user of the ng-model + * directive will implement this method. + */ + this.$render = noop; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$isEmpty + * + * @description + * This is called when we need to determine if the value of the input is empty. + * + * For instance, the required directive does this to work out if the input has data or not. + * The default `$isEmpty` function checks whether the value is `undefined`, `''`, `null` or `NaN`. + * + * You can override this for input directives whose concept of being empty is different to the + * default. The `checkboxInputType` directive does this because in its case a value of `false` + * implies empty. + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is empty. + */ + this.$isEmpty = function(value) { + return isUndefined(value) || value === '' || value === null || value !== value; + }; + + var parentForm = $element.inheritedData('$formController') || nullFormCtrl, + invalidCount = 0, // used to easily determine if we are valid + $error = this.$error = {}; // keep invalid keys here + + + // Setup initial state of the control + $element.addClass(PRISTINE_CLASS); + toggleValidCss(true); + + // convenience method for easy toggling of classes + function toggleValidCss(isValid, validationErrorKey) { + validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; + $animate.removeClass($element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); + $animate.addClass($element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); + } + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setValidity + * + * @description + * Change the validity state, and notifies the form when the control changes validity. (i.e. it + * does not notify form if given validator is already marked as invalid). + * + * This method should be called by validators - i.e. the parser or formatter functions. + * + * @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign + * to `$error[validationErrorKey]=!isValid` so that it is available for data-binding. + * The `validationErrorKey` should be in camelCase and will get converted into dash-case + * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` + * class and can be bound to as `{{someForm.someControl.$error.myError}}` . + * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). + */ + this.$setValidity = function(validationErrorKey, isValid) { + // Purposeful use of ! here to cast isValid to boolean in case it is undefined + // jshint -W018 + if ($error[validationErrorKey] === !isValid) return; + // jshint +W018 + + if (isValid) { + if ($error[validationErrorKey]) invalidCount--; + if (!invalidCount) { + toggleValidCss(true); + this.$valid = true; + this.$invalid = false; + } + } else { + toggleValidCss(false); + this.$invalid = true; + this.$valid = false; + invalidCount++; + } + + $error[validationErrorKey] = !isValid; + toggleValidCss(isValid, validationErrorKey); + + parentForm.$setValidity(validationErrorKey, isValid, this); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setPristine + * + * @description + * Sets the control to its pristine state. + * + * This method can be called to remove the 'ng-dirty' class and set the control to its pristine + * state (ng-pristine class). + */ + this.$setPristine = function () { + this.$dirty = false; + this.$pristine = true; + $animate.removeClass($element, DIRTY_CLASS); + $animate.addClass($element, PRISTINE_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setViewValue + * + * @description + * Update the view value. + * + * This method should be called when the view value changes, typically from within a DOM event handler. + * For example {@link ng.directive:input input} and + * {@link ng.directive:select select} directives call it. + * + * It will update the $viewValue, then pass this value through each of the functions in `$parsers`, + * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to + * `$modelValue` and the **expression** specified in the `ng-model` attribute. + * + * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. + * + * Note that calling this function does not trigger a `$digest`. + * + * @param {string} value Value from the view. + */ + this.$setViewValue = function(value) { + this.$viewValue = value; + + // change to dirty + if (this.$pristine) { + this.$dirty = true; + this.$pristine = false; + $animate.removeClass($element, PRISTINE_CLASS); + $animate.addClass($element, DIRTY_CLASS); + parentForm.$setDirty(); + } + + forEach(this.$parsers, function(fn) { + value = fn(value); + }); + + if (this.$modelValue !== value) { + this.$modelValue = value; + ngModelSet($scope, value); + forEach(this.$viewChangeListeners, function(listener) { + try { + listener(); + } catch(e) { + $exceptionHandler(e); + } + }); + } + }; + + // model -> value + var ctrl = this; + + $scope.$watch(function ngModelWatch() { + var value = ngModelGet($scope); + + // if scope model value and ngModel value are out of sync + if (ctrl.$modelValue !== value) { + + var formatters = ctrl.$formatters, + idx = formatters.length; + + ctrl.$modelValue = value; + while(idx--) { + value = formatters[idx](value); + } + + if (ctrl.$viewValue !== value) { + ctrl.$viewValue = value; + ctrl.$render(); + } + } + + return value; + }); +}]; + + +/** + * @ngdoc directive + * @name ngModel + * + * @element input + * + * @description + * The `ngModel` directive binds an `input`,`select`, `textarea` (or custom form control) to a + * property on the scope using {@link ngModel.NgModelController NgModelController}, + * which is created and exposed by this directive. + * + * `ngModel` is responsible for: + * + * - Binding the view into the model, which other directives such as `input`, `textarea` or `select` + * require. + * - Providing validation behavior (i.e. required, number, email, url). + * - Keeping the state of the control (valid/invalid, dirty/pristine, validation errors). + * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`) including animations. + * - Registering the control with its parent {@link ng.directive:form form}. + * + * Note: `ngModel` will try to bind to the property given by evaluating the expression on the + * current scope. If the property doesn't already exist on this scope, it will be created + * implicitly and added to the scope. + * + * For best practices on using `ngModel`, see: + * + * - [https://github.com/angular/angular.js/wiki/Understanding-Scopes] + * + * For basic examples, how to use `ngModel`, see: + * + * - {@link ng.directive:input input} + * - {@link input[text] text} + * - {@link input[checkbox] checkbox} + * - {@link input[radio] radio} + * - {@link input[number] number} + * - {@link input[email] email} + * - {@link input[url] url} + * - {@link ng.directive:select select} + * - {@link ng.directive:textarea textarea} + * + * # CSS classes + * The following CSS classes are added and removed on the associated input/select/textarea element + * depending on the validity of the model. + * + * - `ng-valid` is set if the model is valid. + * - `ng-invalid` is set if the model is invalid. + * - `ng-pristine` is set if the model is pristine. + * - `ng-dirty` is set if the model is dirty. + * + * Keep in mind that ngAnimate can detect each of these classes when added and removed. + * + * ## Animation Hooks + * + * Animations within models are triggered when any of the associated CSS classes are added and removed + * on the input element which is attached to the model. These classes are: `.ng-pristine`, `.ng-dirty`, + * `.ng-invalid` and `.ng-valid` as well as any other validations that are performed on the model itself. + * The animations that are triggered within ngModel are similar to how they work in ngClass and + * animations can be hooked into using CSS transitions, keyframes as well as JS animations. + * + * The following example shows a simple way to utilize CSS transitions to style an input element + * that has been rendered as invalid after it has been validated: + * + *
+ * //be sure to include ngAnimate as a module to hook into more
+ * //advanced animations
+ * .my-input {
+ *   transition:0.5s linear all;
+ *   background: white;
+ * }
+ * .my-input.ng-invalid {
+ *   background: red;
+ *   color:white;
+ * }
+ * 
+ * + * @example + * + + + + Update input to see transitions when valid/invalid. + Integer is a valid value. +
+ +
+
+ *
+ */ +var ngModelDirective = function() { + return { + require: ['ngModel', '^?form'], + controller: NgModelController, + link: function(scope, element, attr, ctrls) { + // notify others, especially parent forms + + var modelCtrl = ctrls[0], + formCtrl = ctrls[1] || nullFormCtrl; + + formCtrl.$addControl(modelCtrl); + + scope.$on('$destroy', function() { + formCtrl.$removeControl(modelCtrl); + }); + } + }; +}; + + +/** + * @ngdoc directive + * @name ngChange + * + * @description + * Evaluate the given expression when the user changes the input. + * The expression is evaluated immediately, unlike the JavaScript onchange event + * which only triggers at the end of a change (usually, when the user leaves the + * form element or presses the return key). + * The expression is not evaluated when the value change is coming from the model. + * + * Note, this directive requires `ngModel` to be present. + * + * @element input + * @param {expression} ngChange {@link guide/expression Expression} to evaluate upon change + * in input value. + * + * @example + * + * + * + *
+ * + * + *
+ * debug = {{confirmed}}
+ * counter = {{counter}}
+ *
+ *
+ * + * var counter = element(by.binding('counter')); + * var debug = element(by.binding('confirmed')); + * + * it('should evaluate the expression if changing from view', function() { + * expect(counter.getText()).toContain('0'); + * + * element(by.id('ng-change-example1')).click(); + * + * expect(counter.getText()).toContain('1'); + * expect(debug.getText()).toContain('true'); + * }); + * + * it('should not evaluate the expression if changing from model', function() { + * element(by.id('ng-change-example2')).click(); + + * expect(counter.getText()).toContain('0'); + * expect(debug.getText()).toContain('true'); + * }); + * + *
+ */ +var ngChangeDirective = valueFn({ + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + ctrl.$viewChangeListeners.push(function() { + scope.$eval(attr.ngChange); + }); + } +}); + + +var requiredDirective = function() { + return { + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + attr.required = true; // force truthy in case we are on non input element + + var validator = function(value) { + if (attr.required && ctrl.$isEmpty(value)) { + ctrl.$setValidity('required', false); + return; + } else { + ctrl.$setValidity('required', true); + return value; + } + }; + + ctrl.$formatters.push(validator); + ctrl.$parsers.unshift(validator); + + attr.$observe('required', function() { + validator(ctrl.$viewValue); + }); + } + }; +}; + + +/** + * @ngdoc directive + * @name ngList + * + * @description + * Text input that converts between a delimited string and an array of strings. The delimiter + * can be a fixed string (by default a comma) or a regular expression. + * + * @element input + * @param {string=} ngList optional delimiter that should be used to split the value. If + * specified in form `/something/` then the value will be converted into a regular expression. + * + * @example + + + +
+ List: + + Required! +
+ names = {{names}}
+ myForm.namesInput.$valid = {{myForm.namesInput.$valid}}
+ myForm.namesInput.$error = {{myForm.namesInput.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var listInput = element(by.model('names')); + var names = element(by.binding('{{names}}')); + var valid = element(by.binding('myForm.namesInput.$valid')); + var error = element(by.css('span.error')); + + it('should initialize to model', function() { + expect(names.getText()).toContain('["igor","misko","vojta"]'); + expect(valid.getText()).toContain('true'); + expect(error.getCssValue('display')).toBe('none'); + }); + + it('should be invalid if empty', function() { + listInput.clear(); + listInput.sendKeys(''); + + expect(names.getText()).toContain(''); + expect(valid.getText()).toContain('false'); + expect(error.getCssValue('display')).not.toBe('none'); }); + +
+ */ +var ngListDirective = function() { + return { + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + var match = /\/(.*)\//.exec(attr.ngList), + separator = match && new RegExp(match[1]) || attr.ngList || ','; + + var parse = function(viewValue) { + // If the viewValue is invalid (say required but empty) it will be `undefined` + if (isUndefined(viewValue)) return; + + var list = []; + + if (viewValue) { + forEach(viewValue.split(separator), function(value) { + if (value) list.push(trim(value)); + }); + } + + return list; + }; + + ctrl.$parsers.push(parse); + ctrl.$formatters.push(function(value) { + if (isArray(value)) { + return value.join(', '); + } + + return undefined; + }); + + // Override the standard $isEmpty because an empty array means the input is empty. + ctrl.$isEmpty = function(value) { + return !value || !value.length; + }; + } + }; +}; + + +var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/; +/** + * @ngdoc directive + * @name ngValue + * + * @description + * Binds the given expression to the value of `input[select]` or `input[radio]`, so + * that when the element is selected, the `ngModel` of that element is set to the + * bound value. + * + * `ngValue` is useful when dynamically generating lists of radio buttons using `ng-repeat`, as + * shown below. + * + * @element input + * @param {string=} ngValue angular expression, whose value will be bound to the `value` attribute + * of the `input` element + * + * @example + + + +
+

Which is your favorite?

+ +
You chose {{my.favorite}}
+
+
+ + var favorite = element(by.binding('my.favorite')); + + it('should initialize to model', function() { + expect(favorite.getText()).toContain('unicorns'); + }); + it('should bind the values to the inputs', function() { + element.all(by.model('my.favorite')).get(0).click(); + expect(favorite.getText()).toContain('pizza'); + }); + +
+ */ +var ngValueDirective = function() { + return { + priority: 100, + compile: function(tpl, tplAttr) { + if (CONSTANT_VALUE_REGEXP.test(tplAttr.ngValue)) { + return function ngValueConstantLink(scope, elm, attr) { + attr.$set('value', scope.$eval(attr.ngValue)); + }; + } else { + return function ngValueLink(scope, elm, attr) { + scope.$watch(attr.ngValue, function valueWatchAction(value) { + attr.$set('value', value); + }); + }; + } + } + }; +}; + +/** + * @ngdoc directive + * @name ngBind + * @restrict AC + * + * @description + * The `ngBind` attribute tells Angular to replace the text content of the specified HTML element + * with the value of a given expression, and to update the text content when the value of that + * expression changes. + * + * Typically, you don't use `ngBind` directly, but instead you use the double curly markup like + * `{{ expression }}` which is similar but less verbose. + * + * It is preferable to use `ngBind` instead of `{{ expression }}` when a template is momentarily + * displayed by the browser in its raw state before Angular compiles it. Since `ngBind` is an + * element attribute, it makes the bindings invisible to the user while the page is loading. + * + * An alternative solution to this problem would be using the + * {@link ng.directive:ngCloak ngCloak} directive. + * + * + * @element ANY + * @param {expression} ngBind {@link guide/expression Expression} to evaluate. + * + * @example + * Enter a name in the Live Preview text box; the greeting below the text box changes instantly. + + + +
+ Enter name:
+ Hello ! +
+
+ + it('should check ng-bind', function() { + var nameInput = element(by.model('name')); + + expect(element(by.binding('name')).getText()).toBe('Whirled'); + nameInput.clear(); + nameInput.sendKeys('world'); + expect(element(by.binding('name')).getText()).toBe('world'); + }); + +
+ */ +var ngBindDirective = ngDirective({ + compile: function(templateElement) { + templateElement.addClass('ng-binding'); + return function (scope, element, attr) { + element.data('$binding', attr.ngBind); + scope.$watch(attr.ngBind, function ngBindWatchAction(value) { + // We are purposefully using == here rather than === because we want to + // catch when value is "null or undefined" + // jshint -W041 + element.text(value == undefined ? '' : value); + }); + }; + } +}); + + +/** + * @ngdoc directive + * @name ngBindTemplate + * + * @description + * The `ngBindTemplate` directive specifies that the element + * text content should be replaced with the interpolation of the template + * in the `ngBindTemplate` attribute. + * Unlike `ngBind`, the `ngBindTemplate` can contain multiple `{{` `}}` + * expressions. This directive is needed since some HTML elements + * (such as TITLE and OPTION) cannot contain SPAN elements. + * + * @element ANY + * @param {string} ngBindTemplate template of form + * {{ expression }} to eval. + * + * @example + * Try it here: enter text in text box and watch the greeting change. + + + +
+ Salutation:
+ Name:
+

+       
+
+ + it('should check ng-bind', function() { + var salutationElem = element(by.binding('salutation')); + var salutationInput = element(by.model('salutation')); + var nameInput = element(by.model('name')); + + expect(salutationElem.getText()).toBe('Hello World!'); + + salutationInput.clear(); + salutationInput.sendKeys('Greetings'); + nameInput.clear(); + nameInput.sendKeys('user'); + + expect(salutationElem.getText()).toBe('Greetings user!'); + }); + +
+ */ +var ngBindTemplateDirective = ['$interpolate', function($interpolate) { + return function(scope, element, attr) { + // TODO: move this to scenario runner + var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); + element.addClass('ng-binding').data('$binding', interpolateFn); + attr.$observe('ngBindTemplate', function(value) { + element.text(value); + }); + }; +}]; + + +/** + * @ngdoc directive + * @name ngBindHtml + * + * @description + * Creates a binding that will innerHTML the result of evaluating the `expression` into the current + * element in a secure way. By default, the innerHTML-ed content will be sanitized using the {@link + * ngSanitize.$sanitize $sanitize} service. To utilize this functionality, ensure that `$sanitize` + * is available, for example, by including {@link ngSanitize} in your module's dependencies (not in + * core Angular.) You may also bypass sanitization for values you know are safe. To do so, bind to + * an explicitly trusted value via {@link ng.$sce#trustAsHtml $sce.trustAsHtml}. See the example + * under {@link ng.$sce#Example Strict Contextual Escaping (SCE)}. + * + * Note: If a `$sanitize` service is unavailable and the bound value isn't explicitly trusted, you + * will have an exception (instead of an exploit.) + * + * @element ANY + * @param {expression} ngBindHtml {@link guide/expression Expression} to evaluate. + * + * @example + Try it here: enter text in text box and watch the greeting change. + + + +
+

+
+
+ + + angular.module('bindHtmlExample', ['ngSanitize']) + .controller('ExampleController', ['$scope', function($scope) { + $scope.myHTML = + 'I am an HTMLstring with ' + + 'links! and other stuff'; + }]); + + + + it('should check ng-bind-html', function() { + expect(element(by.binding('myHTML')).getText()).toBe( + 'I am an HTMLstring with links! and other stuff'); + }); + +
+ */ +var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) { + return function(scope, element, attr) { + element.addClass('ng-binding').data('$binding', attr.ngBindHtml); + + var parsed = $parse(attr.ngBindHtml); + function getStringValue() { return (parsed(scope) || '').toString(); } + + scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) { + element.html($sce.getTrustedHtml(parsed(scope)) || ''); + }); + }; +}]; + +function classDirective(name, selector) { + name = 'ngClass' + name; + return ['$animate', function($animate) { + return { + restrict: 'AC', + link: function(scope, element, attr) { + var oldVal; + + scope.$watch(attr[name], ngClassWatchAction, true); + + attr.$observe('class', function(value) { + ngClassWatchAction(scope.$eval(attr[name])); + }); + + + if (name !== 'ngClass') { + scope.$watch('$index', function($index, old$index) { + // jshint bitwise: false + var mod = $index & 1; + if (mod !== (old$index & 1)) { + var classes = arrayClasses(scope.$eval(attr[name])); + mod === selector ? + addClasses(classes) : + removeClasses(classes); + } + }); + } + + function addClasses(classes) { + var newClasses = digestClassCounts(classes, 1); + attr.$addClass(newClasses); + } + + function removeClasses(classes) { + var newClasses = digestClassCounts(classes, -1); + attr.$removeClass(newClasses); + } + + function digestClassCounts (classes, count) { + var classCounts = element.data('$classCounts') || {}; + var classesToUpdate = []; + forEach(classes, function (className) { + if (count > 0 || classCounts[className]) { + classCounts[className] = (classCounts[className] || 0) + count; + if (classCounts[className] === +(count > 0)) { + classesToUpdate.push(className); + } + } + }); + element.data('$classCounts', classCounts); + return classesToUpdate.join(' '); + } + + function updateClasses (oldClasses, newClasses) { + var toAdd = arrayDifference(newClasses, oldClasses); + var toRemove = arrayDifference(oldClasses, newClasses); + toRemove = digestClassCounts(toRemove, -1); + toAdd = digestClassCounts(toAdd, 1); + + if (toAdd.length === 0) { + $animate.removeClass(element, toRemove); + } else if (toRemove.length === 0) { + $animate.addClass(element, toAdd); + } else { + $animate.setClass(element, toAdd, toRemove); + } + } + + function ngClassWatchAction(newVal) { + if (selector === true || scope.$index % 2 === selector) { + var newClasses = arrayClasses(newVal || []); + if (!oldVal) { + addClasses(newClasses); + } else if (!equals(newVal,oldVal)) { + var oldClasses = arrayClasses(oldVal); + updateClasses(oldClasses, newClasses); + } + } + oldVal = shallowCopy(newVal); + } + } + }; + + function arrayDifference(tokens1, tokens2) { + var values = []; + + outer: + for(var i = 0; i < tokens1.length; i++) { + var token = tokens1[i]; + for(var j = 0; j < tokens2.length; j++) { + if(token == tokens2[j]) continue outer; + } + values.push(token); + } + return values; + } + + function arrayClasses (classVal) { + if (isArray(classVal)) { + return classVal; + } else if (isString(classVal)) { + return classVal.split(' '); + } else if (isObject(classVal)) { + var classes = [], i = 0; + forEach(classVal, function(v, k) { + if (v) { + classes = classes.concat(k.split(' ')); + } + }); + return classes; + } + return classVal; + } + }]; +} + +/** + * @ngdoc directive + * @name ngClass + * @restrict AC + * + * @description + * The `ngClass` directive allows you to dynamically set CSS classes on an HTML element by databinding + * an expression that represents all classes to be added. + * + * The directive operates in three different ways, depending on which of three types the expression + * evaluates to: + * + * 1. If the expression evaluates to a string, the string should be one or more space-delimited class + * names. + * + * 2. If the expression evaluates to an array, each element of the array should be a string that is + * one or more space-delimited class names. + * + * 3. If the expression evaluates to an object, then for each key-value pair of the + * object with a truthy value the corresponding key is used as a class name. + * + * The directive won't add duplicate classes if a particular class was already set. + * + * When the expression changes, the previously added classes are removed and only then the + * new classes are added. + * + * @animations + * add - happens just before the class is applied to the element + * remove - happens just before the class is removed from the element + * + * @element ANY + * @param {expression} ngClass {@link guide/expression Expression} to eval. The result + * of the evaluation can be a string representing space delimited class + * names, an array, or a map of class names to boolean values. In the case of a map, the + * names of the properties whose values are truthy will be added as css classes to the + * element. + * + * @example Example that demonstrates basic bindings via ngClass directive. + + +

Map Syntax Example

+ deleted (apply "strike" class)
+ important (apply "bold" class)
+ error (apply "red" class) +
+

Using String Syntax

+ +
+

Using Array Syntax

+
+
+
+
+ + .strike { + text-decoration: line-through; + } + .bold { + font-weight: bold; + } + .red { + color: red; + } + + + var ps = element.all(by.css('p')); + + it('should let you toggle the class', function() { + + expect(ps.first().getAttribute('class')).not.toMatch(/bold/); + expect(ps.first().getAttribute('class')).not.toMatch(/red/); + + element(by.model('important')).click(); + expect(ps.first().getAttribute('class')).toMatch(/bold/); + + element(by.model('error')).click(); + expect(ps.first().getAttribute('class')).toMatch(/red/); + }); + + it('should let you toggle string example', function() { + expect(ps.get(1).getAttribute('class')).toBe(''); + element(by.model('style')).clear(); + element(by.model('style')).sendKeys('red'); + expect(ps.get(1).getAttribute('class')).toBe('red'); + }); + + it('array example should have 3 classes', function() { + expect(ps.last().getAttribute('class')).toBe(''); + element(by.model('style1')).sendKeys('bold'); + element(by.model('style2')).sendKeys('strike'); + element(by.model('style3')).sendKeys('red'); + expect(ps.last().getAttribute('class')).toBe('bold strike red'); + }); + +
+ + ## Animations + + The example below demonstrates how to perform animations using ngClass. + + + + + +
+ Sample Text +
+ + .base-class { + -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + } + + .base-class.my-class { + color: red; + font-size:3em; + } + + + it('should check ng-class', function() { + expect(element(by.css('.base-class')).getAttribute('class')).not. + toMatch(/my-class/); + + element(by.id('setbtn')).click(); + + expect(element(by.css('.base-class')).getAttribute('class')). + toMatch(/my-class/); + + element(by.id('clearbtn')).click(); + + expect(element(by.css('.base-class')).getAttribute('class')).not. + toMatch(/my-class/); + }); + +
+ + + ## ngClass and pre-existing CSS3 Transitions/Animations + The ngClass directive still supports CSS3 Transitions/Animations even if they do not follow the ngAnimate CSS naming structure. + Upon animation ngAnimate will apply supplementary CSS classes to track the start and end of an animation, but this will not hinder + any pre-existing CSS transitions already on the element. To get an idea of what happens during a class-based animation, be sure + to view the step by step details of {@link ngAnimate.$animate#addclass $animate.addClass} and + {@link ngAnimate.$animate#removeclass $animate.removeClass}. + */ +var ngClassDirective = classDirective('', true); + +/** + * @ngdoc directive + * @name ngClassOdd + * @restrict AC + * + * @description + * The `ngClassOdd` and `ngClassEven` directives work exactly as + * {@link ng.directive:ngClass ngClass}, except they work in + * conjunction with `ngRepeat` and take effect only on odd (even) rows. + * + * This directive can be applied only within the scope of an + * {@link ng.directive:ngRepeat ngRepeat}. + * + * @element ANY + * @param {expression} ngClassOdd {@link guide/expression Expression} to eval. The result + * of the evaluation can be a string representing space delimited class names or an array. + * + * @example + + +
    +
  1. + + {{name}} + +
  2. +
+
+ + .odd { + color: red; + } + .even { + color: blue; + } + + + it('should check ng-class-odd and ng-class-even', function() { + expect(element(by.repeater('name in names').row(0).column('name')).getAttribute('class')). + toMatch(/odd/); + expect(element(by.repeater('name in names').row(1).column('name')).getAttribute('class')). + toMatch(/even/); + }); + +
+ */ +var ngClassOddDirective = classDirective('Odd', 0); + +/** + * @ngdoc directive + * @name ngClassEven + * @restrict AC + * + * @description + * The `ngClassOdd` and `ngClassEven` directives work exactly as + * {@link ng.directive:ngClass ngClass}, except they work in + * conjunction with `ngRepeat` and take effect only on odd (even) rows. + * + * This directive can be applied only within the scope of an + * {@link ng.directive:ngRepeat ngRepeat}. + * + * @element ANY + * @param {expression} ngClassEven {@link guide/expression Expression} to eval. The + * result of the evaluation can be a string representing space delimited class names or an array. + * + * @example + + +
    +
  1. + + {{name}}       + +
  2. +
+
+ + .odd { + color: red; + } + .even { + color: blue; + } + + + it('should check ng-class-odd and ng-class-even', function() { + expect(element(by.repeater('name in names').row(0).column('name')).getAttribute('class')). + toMatch(/odd/); + expect(element(by.repeater('name in names').row(1).column('name')).getAttribute('class')). + toMatch(/even/); + }); + +
+ */ +var ngClassEvenDirective = classDirective('Even', 1); + +/** + * @ngdoc directive + * @name ngCloak + * @restrict AC + * + * @description + * The `ngCloak` directive is used to prevent the Angular html template from being briefly + * displayed by the browser in its raw (uncompiled) form while your application is loading. Use this + * directive to avoid the undesirable flicker effect caused by the html template display. + * + * The directive can be applied to the `` element, but the preferred usage is to apply + * multiple `ngCloak` directives to small portions of the page to permit progressive rendering + * of the browser view. + * + * `ngCloak` works in cooperation with the following css rule embedded within `angular.js` and + * `angular.min.js`. + * For CSP mode please add `angular-csp.css` to your html file (see {@link ng.directive:ngCsp ngCsp}). + * + * ```css + * [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + * display: none !important; + * } + * ``` + * + * When this css rule is loaded by the browser, all html elements (including their children) that + * are tagged with the `ngCloak` directive are hidden. When Angular encounters this directive + * during the compilation of the template it deletes the `ngCloak` element attribute, making + * the compiled element visible. + * + * For the best result, the `angular.js` script must be loaded in the head section of the html + * document; alternatively, the css rule above must be included in the external stylesheet of the + * application. + * + * Legacy browsers, like IE7, do not provide attribute selector support (added in CSS 2.1) so they + * cannot match the `[ng\:cloak]` selector. To work around this limitation, you must add the css + * class `ng-cloak` in addition to the `ngCloak` directive as shown in the example below. + * + * @element ANY + * + * @example + + +
{{ 'hello' }}
+
{{ 'hello IE7' }}
+
+ + it('should remove the template directive and css class', function() { + expect($('#template1').getAttribute('ng-cloak')). + toBeNull(); + expect($('#template2').getAttribute('ng-cloak')). + toBeNull(); + }); + +
+ * + */ +var ngCloakDirective = ngDirective({ + compile: function(element, attr) { + attr.$set('ngCloak', undefined); + element.removeClass('ng-cloak'); + } +}); + +/** + * @ngdoc directive + * @name ngController + * + * @description + * The `ngController` directive attaches a controller class to the view. This is a key aspect of how angular + * supports the principles behind the Model-View-Controller design pattern. + * + * MVC components in angular: + * + * * Model — Models are the properties of a scope; scopes are attached to the DOM where scope properties + * are accessed through bindings. + * * View — The template (HTML with data bindings) that is rendered into the View. + * * Controller — The `ngController` directive specifies a Controller class; the class contains business + * logic behind the application to decorate the scope with functions and values + * + * Note that you can also attach controllers to the DOM by declaring it in a route definition + * via the {@link ngRoute.$route $route} service. A common mistake is to declare the controller + * again using `ng-controller` in the template itself. This will cause the controller to be attached + * and executed twice. + * + * @element ANY + * @scope + * @param {expression} ngController Name of a globally accessible constructor function or an + * {@link guide/expression expression} that on the current scope evaluates to a + * constructor function. The controller instance can be published into a scope property + * by specifying `as propertyName`. + * + * @example + * Here is a simple form for editing user contact information. Adding, removing, clearing, and + * greeting are methods declared on the controller (see source tab). These methods can + * easily be called from the angular markup. Any changes to the data are automatically reflected + * in the View without the need for a manual update. + * + * Two different declaration styles are included below: + * + * * one binds methods and properties directly onto the controller using `this`: + * `ng-controller="SettingsController1 as settings"` + * * one injects `$scope` into the controller: + * `ng-controller="SettingsController2"` + * + * The second option is more common in the Angular community, and is generally used in boilerplates + * and in this guide. However, there are advantages to binding properties directly to the controller + * and avoiding scope. + * + * * Using `controller as` makes it obvious which controller you are accessing in the template when + * multiple controllers apply to an element. + * * If you are writing your controllers as classes you have easier access to the properties and + * methods, which will appear on the scope, from inside the controller code. + * * Since there is always a `.` in the bindings, you don't have to worry about prototypal + * inheritance masking primitives. + * + * This example demonstrates the `controller as` syntax. + * + * + * + *
+ * Name: + * [ greet ]
+ * Contact: + *
    + *
  • + * + * + * [ clear + * | X ] + *
  • + *
  • [ add ]
  • + *
+ *
+ *
+ * + * angular.module('controllerAsExample', []) + * .controller('SettingsController1', SettingsController1); + * + * function SettingsController1() { + * this.name = "John Smith"; + * this.contacts = [ + * {type: 'phone', value: '408 555 1212'}, + * {type: 'email', value: 'john.smith@example.org'} ]; + * } + * + * SettingsController1.prototype.greet = function() { + * alert(this.name); + * }; + * + * SettingsController1.prototype.addContact = function() { + * this.contacts.push({type: 'email', value: 'yourname@example.org'}); + * }; + * + * SettingsController1.prototype.removeContact = function(contactToRemove) { + * var index = this.contacts.indexOf(contactToRemove); + * this.contacts.splice(index, 1); + * }; + * + * SettingsController1.prototype.clearContact = function(contact) { + * contact.type = 'phone'; + * contact.value = ''; + * }; + * + * + * it('should check controller as', function() { + * var container = element(by.id('ctrl-as-exmpl')); + * expect(container.element(by.model('settings.name')) + * .getAttribute('value')).toBe('John Smith'); + * + * var firstRepeat = + * container.element(by.repeater('contact in settings.contacts').row(0)); + * var secondRepeat = + * container.element(by.repeater('contact in settings.contacts').row(1)); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('408 555 1212'); + * + * expect(secondRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('john.smith@example.org'); + * + * firstRepeat.element(by.linkText('clear')).click(); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe(''); + * + * container.element(by.linkText('add')).click(); + * + * expect(container.element(by.repeater('contact in settings.contacts').row(2)) + * .element(by.model('contact.value')) + * .getAttribute('value')) + * .toBe('yourname@example.org'); + * }); + * + *
+ * + * This example demonstrates the "attach to `$scope`" style of controller. + * + * + * + *
+ * Name: + * [ greet ]
+ * Contact: + *
    + *
  • + * + * + * [ clear + * | X ] + *
  • + *
  • [ add ]
  • + *
+ *
+ *
+ * + * angular.module('controllerExample', []) + * .controller('SettingsController2', ['$scope', SettingsController2]); + * + * function SettingsController2($scope) { + * $scope.name = "John Smith"; + * $scope.contacts = [ + * {type:'phone', value:'408 555 1212'}, + * {type:'email', value:'john.smith@example.org'} ]; + * + * $scope.greet = function() { + * alert($scope.name); + * }; + * + * $scope.addContact = function() { + * $scope.contacts.push({type:'email', value:'yourname@example.org'}); + * }; + * + * $scope.removeContact = function(contactToRemove) { + * var index = $scope.contacts.indexOf(contactToRemove); + * $scope.contacts.splice(index, 1); + * }; + * + * $scope.clearContact = function(contact) { + * contact.type = 'phone'; + * contact.value = ''; + * }; + * } + * + * + * it('should check controller', function() { + * var container = element(by.id('ctrl-exmpl')); + * + * expect(container.element(by.model('name')) + * .getAttribute('value')).toBe('John Smith'); + * + * var firstRepeat = + * container.element(by.repeater('contact in contacts').row(0)); + * var secondRepeat = + * container.element(by.repeater('contact in contacts').row(1)); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('408 555 1212'); + * expect(secondRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('john.smith@example.org'); + * + * firstRepeat.element(by.linkText('clear')).click(); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe(''); + * + * container.element(by.linkText('add')).click(); + * + * expect(container.element(by.repeater('contact in contacts').row(2)) + * .element(by.model('contact.value')) + * .getAttribute('value')) + * .toBe('yourname@example.org'); + * }); + * + *
+ + */ +var ngControllerDirective = [function() { + return { + scope: true, + controller: '@', + priority: 500 + }; +}]; + +/** + * @ngdoc directive + * @name ngCsp + * + * @element html + * @description + * Enables [CSP (Content Security Policy)](https://developer.mozilla.org/en/Security/CSP) support. + * + * This is necessary when developing things like Google Chrome Extensions. + * + * CSP forbids apps to use `eval` or `Function(string)` generated functions (among other things). + * For us to be compatible, we just need to implement the "getterFn" in $parse without violating + * any of these restrictions. + * + * AngularJS uses `Function(string)` generated functions as a speed optimization. Applying the `ngCsp` + * directive will cause Angular to use CSP compatibility mode. When this mode is on AngularJS will + * evaluate all expressions up to 30% slower than in non-CSP mode, but no security violations will + * be raised. + * + * CSP forbids JavaScript to inline stylesheet rules. In non CSP mode Angular automatically + * includes some CSS rules (e.g. {@link ng.directive:ngCloak ngCloak}). + * To make those directives work in CSP mode, include the `angular-csp.css` manually. + * + * In order to use this feature put the `ngCsp` directive on the root element of the application. + * + * *Note: This directive is only available in the `ng-csp` and `data-ng-csp` attribute form.* + * + * @example + * This example shows how to apply the `ngCsp` directive to the `html` tag. + ```html + + + ... + ... + + ``` + */ + +// ngCsp is not implemented as a proper directive any more, because we need it be processed while we bootstrap +// the system (before $parse is instantiated), for this reason we just have a csp() fn that looks for ng-csp attribute +// anywhere in the current doc + +/** + * @ngdoc directive + * @name ngClick + * + * @description + * The ngClick directive allows you to specify custom behavior when + * an element is clicked. + * + * @element ANY + * @priority 0 + * @param {expression} ngClick {@link guide/expression Expression} to evaluate upon + * click. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + count: {{count}} + + + it('should check ng-click', function() { + expect(element(by.binding('count')).getText()).toMatch('0'); + element(by.css('button')).click(); + expect(element(by.binding('count')).getText()).toMatch('1'); + }); + + + */ +/* + * A directive that allows creation of custom onclick handlers that are defined as angular + * expressions and are compiled and executed within the current scope. + * + * Events that are handled via these handler are always configured not to propagate further. + */ +var ngEventDirectives = {}; +forEach( + 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '), + function(name) { + var directiveName = directiveNormalize('ng-' + name); + ngEventDirectives[directiveName] = ['$parse', function($parse) { + return { + compile: function($element, attr) { + var fn = $parse(attr[directiveName]); + return function ngEventHandler(scope, element) { + element.on(lowercase(name), function(event) { + scope.$apply(function() { + fn(scope, {$event:event}); + }); + }); + }; + } + }; + }]; + } +); + +/** + * @ngdoc directive + * @name ngDblclick + * + * @description + * The `ngDblclick` directive allows you to specify custom behavior on a dblclick event. + * + * @element ANY + * @priority 0 + * @param {expression} ngDblclick {@link guide/expression Expression} to evaluate upon + * a dblclick. (The Event object is available as `$event`) + * + * @example + + + + count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngMousedown + * + * @description + * The ngMousedown directive allows you to specify custom behavior on mousedown event. + * + * @element ANY + * @priority 0 + * @param {expression} ngMousedown {@link guide/expression Expression} to evaluate upon + * mousedown. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngMouseup + * + * @description + * Specify custom behavior on mouseup event. + * + * @element ANY + * @priority 0 + * @param {expression} ngMouseup {@link guide/expression Expression} to evaluate upon + * mouseup. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + count: {{count}} + + + */ + +/** + * @ngdoc directive + * @name ngMouseover + * + * @description + * Specify custom behavior on mouseover event. + * + * @element ANY + * @priority 0 + * @param {expression} ngMouseover {@link guide/expression Expression} to evaluate upon + * mouseover. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngMouseenter + * + * @description + * Specify custom behavior on mouseenter event. + * + * @element ANY + * @priority 0 + * @param {expression} ngMouseenter {@link guide/expression Expression} to evaluate upon + * mouseenter. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngMouseleave + * + * @description + * Specify custom behavior on mouseleave event. + * + * @element ANY + * @priority 0 + * @param {expression} ngMouseleave {@link guide/expression Expression} to evaluate upon + * mouseleave. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngMousemove + * + * @description + * Specify custom behavior on mousemove event. + * + * @element ANY + * @priority 0 + * @param {expression} ngMousemove {@link guide/expression Expression} to evaluate upon + * mousemove. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngKeydown + * + * @description + * Specify custom behavior on keydown event. + * + * @element ANY + * @priority 0 + * @param {expression} ngKeydown {@link guide/expression Expression} to evaluate upon + * keydown. (Event object is available as `$event` and can be interrogated for keyCode, altKey, etc.) + * + * @example + + + + key down count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngKeyup + * + * @description + * Specify custom behavior on keyup event. + * + * @element ANY + * @priority 0 + * @param {expression} ngKeyup {@link guide/expression Expression} to evaluate upon + * keyup. (Event object is available as `$event` and can be interrogated for keyCode, altKey, etc.) + * + * @example + + +

Typing in the input box below updates the key count

+ key up count: {{count}} + +

Typing in the input box below updates the keycode

+ +

event keyCode: {{ event.keyCode }}

+

event altKey: {{ event.altKey }}

+
+
+ */ + + +/** + * @ngdoc directive + * @name ngKeypress + * + * @description + * Specify custom behavior on keypress event. + * + * @element ANY + * @param {expression} ngKeypress {@link guide/expression Expression} to evaluate upon + * keypress. ({@link guide/expression#-event- Event object is available as `$event`} + * and can be interrogated for keyCode, altKey, etc.) + * + * @example + + + + key press count: {{count}} + + + */ + + +/** + * @ngdoc directive + * @name ngSubmit + * + * @description + * Enables binding angular expressions to onsubmit events. + * + * Additionally it prevents the default action (which for form means sending the request to the + * server and reloading the current page), but only if the form does not contain `action`, + * `data-action`, or `x-action` attributes. + * + * @element form + * @priority 0 + * @param {expression} ngSubmit {@link guide/expression Expression} to eval. + * ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + +
+ Enter text and hit enter: + + +
list={{list}}
+
+
+ + it('should check ng-submit', function() { + expect(element(by.binding('list')).getText()).toBe('list=[]'); + element(by.css('#submit')).click(); + expect(element(by.binding('list')).getText()).toContain('hello'); + expect(element(by.model('text')).getAttribute('value')).toBe(''); + }); + it('should ignore empty strings', function() { + expect(element(by.binding('list')).getText()).toBe('list=[]'); + element(by.css('#submit')).click(); + element(by.css('#submit')).click(); + expect(element(by.binding('list')).getText()).toContain('hello'); + }); + +
+ */ + +/** + * @ngdoc directive + * @name ngFocus + * + * @description + * Specify custom behavior on focus event. + * + * @element window, input, select, textarea, a + * @priority 0 + * @param {expression} ngFocus {@link guide/expression Expression} to evaluate upon + * focus. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + * See {@link ng.directive:ngClick ngClick} + */ + +/** + * @ngdoc directive + * @name ngBlur + * + * @description + * Specify custom behavior on blur event. + * + * @element window, input, select, textarea, a + * @priority 0 + * @param {expression} ngBlur {@link guide/expression Expression} to evaluate upon + * blur. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + * See {@link ng.directive:ngClick ngClick} + */ + +/** + * @ngdoc directive + * @name ngCopy + * + * @description + * Specify custom behavior on copy event. + * + * @element window, input, select, textarea, a + * @priority 0 + * @param {expression} ngCopy {@link guide/expression Expression} to evaluate upon + * copy. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + copied: {{copied}} + + + */ + +/** + * @ngdoc directive + * @name ngCut + * + * @description + * Specify custom behavior on cut event. + * + * @element window, input, select, textarea, a + * @priority 0 + * @param {expression} ngCut {@link guide/expression Expression} to evaluate upon + * cut. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + cut: {{cut}} + + + */ + +/** + * @ngdoc directive + * @name ngPaste + * + * @description + * Specify custom behavior on paste event. + * + * @element window, input, select, textarea, a + * @priority 0 + * @param {expression} ngPaste {@link guide/expression Expression} to evaluate upon + * paste. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + + + + pasted: {{paste}} + + + */ + +/** + * @ngdoc directive + * @name ngIf + * @restrict A + * + * @description + * The `ngIf` directive removes or recreates a portion of the DOM tree based on an + * {expression}. If the expression assigned to `ngIf` evaluates to a false + * value then the element is removed from the DOM, otherwise a clone of the + * element is reinserted into the DOM. + * + * `ngIf` differs from `ngShow` and `ngHide` in that `ngIf` completely removes and recreates the + * element in the DOM rather than changing its visibility via the `display` css property. A common + * case when this difference is significant is when using css selectors that rely on an element's + * position within the DOM, such as the `:first-child` or `:last-child` pseudo-classes. + * + * Note that when an element is removed using `ngIf` its scope is destroyed and a new scope + * is created when the element is restored. The scope created within `ngIf` inherits from + * its parent scope using + * [prototypal inheritance](https://github.com/angular/angular.js/wiki/The-Nuances-of-Scope-Prototypal-Inheritance). + * An important implication of this is if `ngModel` is used within `ngIf` to bind to + * a javascript primitive defined in the parent scope. In this case any modifications made to the + * variable within the child scope will override (hide) the value in the parent scope. + * + * Also, `ngIf` recreates elements using their compiled state. An example of this behavior + * is if an element's class attribute is directly modified after it's compiled, using something like + * jQuery's `.addClass()` method, and the element is later removed. When `ngIf` recreates the element + * the added class will be lost because the original compiled state is used to regenerate the element. + * + * Additionally, you can provide animations via the `ngAnimate` module to animate the `enter` + * and `leave` effects. + * + * @animations + * enter - happens just after the ngIf contents change and a new DOM element is created and injected into the ngIf container + * leave - happens just before the ngIf contents are removed from the DOM + * + * @element ANY + * @scope + * @priority 600 + * @param {expression} ngIf If the {@link guide/expression expression} is falsy then + * the element is removed from the DOM tree. If it is truthy a copy of the compiled + * element is added to the DOM tree. + * + * @example + + + Click me:
+ Show when checked: + + I'm removed when the checkbox is unchecked. + +
+ + .animate-if { + background:white; + border:1px solid black; + padding:10px; + } + + .animate-if.ng-enter, .animate-if.ng-leave { + -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + } + + .animate-if.ng-enter, + .animate-if.ng-leave.ng-leave-active { + opacity:0; + } + + .animate-if.ng-leave, + .animate-if.ng-enter.ng-enter-active { + opacity:1; + } + +
+ */ +var ngIfDirective = ['$animate', function($animate) { + return { + transclude: 'element', + priority: 600, + terminal: true, + restrict: 'A', + $$tlb: true, + link: function ($scope, $element, $attr, ctrl, $transclude) { + var block, childScope, previousElements; + $scope.$watch($attr.ngIf, function ngIfWatchAction(value) { + + if (toBoolean(value)) { + if (!childScope) { + childScope = $scope.$new(); + $transclude(childScope, function (clone) { + clone[clone.length++] = document.createComment(' end ngIf: ' + $attr.ngIf + ' '); + // Note: We only need the first/last node of the cloned nodes. + // However, we need to keep the reference to the jqlite wrapper as it might be changed later + // by a directive with templateUrl when its template arrives. + block = { + clone: clone + }; + $animate.enter(clone, $element.parent(), $element); + }); + } + } else { + if(previousElements) { + previousElements.remove(); + previousElements = null; + } + if(childScope) { + childScope.$destroy(); + childScope = null; + } + if(block) { + previousElements = getBlockElements(block.clone); + $animate.leave(previousElements, function() { + previousElements = null; + }); + block = null; + } + } + }); + } + }; +}]; + +/** + * @ngdoc directive + * @name ngInclude + * @restrict ECA + * + * @description + * Fetches, compiles and includes an external HTML fragment. + * + * By default, the template URL is restricted to the same domain and protocol as the + * application document. This is done by calling {@link ng.$sce#getTrustedResourceUrl + * $sce.getTrustedResourceUrl} on it. To load templates from other domains or protocols + * you may either {@link ng.$sceDelegateProvider#resourceUrlWhitelist whitelist them} or + * [wrap them](ng.$sce#trustAsResourceUrl) as trusted values. Refer to Angular's {@link + * ng.$sce Strict Contextual Escaping}. + * + * In addition, the browser's + * [Same Origin Policy](https://code.google.com/p/browsersec/wiki/Part2#Same-origin_policy_for_XMLHttpRequest) + * and [Cross-Origin Resource Sharing (CORS)](http://www.w3.org/TR/cors/) + * policy may further restrict whether the template is successfully loaded. + * For example, `ngInclude` won't work for cross-domain requests on all browsers and for `file://` + * access on some browsers. + * + * @animations + * enter - animation is used to bring new content into the browser. + * leave - animation is used to animate existing content away. + * + * The enter and leave animation occur concurrently. + * + * @scope + * @priority 400 + * + * @param {string} ngInclude|src angular expression evaluating to URL. If the source is a string constant, + * make sure you wrap it in **single** quotes, e.g. `src="'myPartialTemplate.html'"`. + * @param {string=} onload Expression to evaluate when a new partial is loaded. + * + * @param {string=} autoscroll Whether `ngInclude` should call {@link ng.$anchorScroll + * $anchorScroll} to scroll the viewport after the content is loaded. + * + * - If the attribute is not set, disable scrolling. + * - If the attribute is set without value, enable scrolling. + * - Otherwise enable scrolling only if the expression evaluates to truthy value. + * + * @example + + +
+ + url of the template: {{template.url}} +
+
+
+
+
+
+ + angular.module('includeExample', ['ngAnimate']) + .controller('ExampleController', ['$scope', function($scope) { + $scope.templates = + [ { name: 'template1.html', url: 'template1.html'}, + { name: 'template2.html', url: 'template2.html'} ]; + $scope.template = $scope.templates[0]; + }]); + + + Content of template1.html + + + Content of template2.html + + + .slide-animate-container { + position:relative; + background:white; + border:1px solid black; + height:40px; + overflow:hidden; + } + + .slide-animate { + padding:10px; + } + + .slide-animate.ng-enter, .slide-animate.ng-leave { + -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + + position:absolute; + top:0; + left:0; + right:0; + bottom:0; + display:block; + padding:10px; + } + + .slide-animate.ng-enter { + top:-50px; + } + .slide-animate.ng-enter.ng-enter-active { + top:0; + } + + .slide-animate.ng-leave { + top:0; + } + .slide-animate.ng-leave.ng-leave-active { + top:50px; + } + + + var templateSelect = element(by.model('template')); + var includeElem = element(by.css('[ng-include]')); + + it('should load template1.html', function() { + expect(includeElem.getText()).toMatch(/Content of template1.html/); + }); + + it('should load template2.html', function() { + if (browser.params.browser == 'firefox') { + // Firefox can't handle using selects + // See https://github.com/angular/protractor/issues/480 + return; + } + templateSelect.click(); + templateSelect.all(by.css('option')).get(2).click(); + expect(includeElem.getText()).toMatch(/Content of template2.html/); + }); + + it('should change to blank', function() { + if (browser.params.browser == 'firefox') { + // Firefox can't handle using selects + return; + } + templateSelect.click(); + templateSelect.all(by.css('option')).get(0).click(); + expect(includeElem.isPresent()).toBe(false); + }); + +
+ */ + + +/** + * @ngdoc event + * @name ngInclude#$includeContentRequested + * @eventType emit on the scope ngInclude was declared in + * @description + * Emitted every time the ngInclude content is requested. + */ + + +/** + * @ngdoc event + * @name ngInclude#$includeContentLoaded + * @eventType emit on the current ngInclude scope + * @description + * Emitted every time the ngInclude content is reloaded. + */ +var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate', '$sce', + function($http, $templateCache, $anchorScroll, $animate, $sce) { + return { + restrict: 'ECA', + priority: 400, + terminal: true, + transclude: 'element', + controller: angular.noop, + compile: function(element, attr) { + var srcExp = attr.ngInclude || attr.src, + onloadExp = attr.onload || '', + autoScrollExp = attr.autoscroll; + + return function(scope, $element, $attr, ctrl, $transclude) { + var changeCounter = 0, + currentScope, + previousElement, + currentElement; + + var cleanupLastIncludeContent = function() { + if(previousElement) { + previousElement.remove(); + previousElement = null; + } + if(currentScope) { + currentScope.$destroy(); + currentScope = null; + } + if(currentElement) { + $animate.leave(currentElement, function() { + previousElement = null; + }); + previousElement = currentElement; + currentElement = null; + } + }; + + scope.$watch($sce.parseAsResourceUrl(srcExp), function ngIncludeWatchAction(src) { + var afterAnimation = function() { + if (isDefined(autoScrollExp) && (!autoScrollExp || scope.$eval(autoScrollExp))) { + $anchorScroll(); + } + }; + var thisChangeId = ++changeCounter; + + if (src) { + $http.get(src, {cache: $templateCache}).success(function(response) { + if (thisChangeId !== changeCounter) return; + var newScope = scope.$new(); + ctrl.template = response; + + // Note: This will also link all children of ng-include that were contained in the original + // html. If that content contains controllers, ... they could pollute/change the scope. + // However, using ng-include on an element with additional content does not make sense... + // Note: We can't remove them in the cloneAttchFn of $transclude as that + // function is called before linking the content, which would apply child + // directives to non existing elements. + var clone = $transclude(newScope, function(clone) { + cleanupLastIncludeContent(); + $animate.enter(clone, null, $element, afterAnimation); + }); + + currentScope = newScope; + currentElement = clone; + + currentScope.$emit('$includeContentLoaded'); + scope.$eval(onloadExp); + }).error(function() { + if (thisChangeId === changeCounter) cleanupLastIncludeContent(); + }); + scope.$emit('$includeContentRequested'); + } else { + cleanupLastIncludeContent(); + ctrl.template = null; + } + }); + }; + } + }; +}]; + +// This directive is called during the $transclude call of the first `ngInclude` directive. +// It will replace and compile the content of the element with the loaded template. +// We need this directive so that the element content is already filled when +// the link function of another directive on the same element as ngInclude +// is called. +var ngIncludeFillContentDirective = ['$compile', + function($compile) { + return { + restrict: 'ECA', + priority: -400, + require: 'ngInclude', + link: function(scope, $element, $attr, ctrl) { + $element.html(ctrl.template); + $compile($element.contents())(scope); + } + }; + }]; + +/** + * @ngdoc directive + * @name ngInit + * @restrict AC + * + * @description + * The `ngInit` directive allows you to evaluate an expression in the + * current scope. + * + *
+ * The only appropriate use of `ngInit` is for aliasing special properties of + * {@link ng.directive:ngRepeat `ngRepeat`}, as seen in the demo below. Besides this case, you + * should use {@link guide/controller controllers} rather than `ngInit` + * to initialize values on a scope. + *
+ *
+ * **Note**: If you have assignment in `ngInit` along with {@link ng.$filter `$filter`}, make + * sure you have parenthesis for correct precedence: + *
+ *   
+ *
+ *
+ * + * @priority 450 + * + * @element ANY + * @param {expression} ngInit {@link guide/expression Expression} to eval. + * + * @example + + + +
+
+
+ list[ {{outerIndex}} ][ {{innerIndex}} ] = {{value}}; +
+
+
+
+ + it('should alias index positions', function() { + var elements = element.all(by.css('.example-init')); + expect(elements.get(0).getText()).toBe('list[ 0 ][ 0 ] = a;'); + expect(elements.get(1).getText()).toBe('list[ 0 ][ 1 ] = b;'); + expect(elements.get(2).getText()).toBe('list[ 1 ][ 0 ] = c;'); + expect(elements.get(3).getText()).toBe('list[ 1 ][ 1 ] = d;'); + }); + +
+ */ +var ngInitDirective = ngDirective({ + priority: 450, + compile: function() { + return { + pre: function(scope, element, attrs) { + scope.$eval(attrs.ngInit); + } + }; + } +}); + +/** + * @ngdoc directive + * @name ngNonBindable + * @restrict AC + * @priority 1000 + * + * @description + * The `ngNonBindable` directive tells Angular not to compile or bind the contents of the current + * DOM element. This is useful if the element contains what appears to be Angular directives and + * bindings but which should be ignored by Angular. This could be the case if you have a site that + * displays snippets of code, for instance. + * + * @element ANY + * + * @example + * In this example there are two locations where a simple interpolation binding (`{{}}`) is present, + * but the one wrapped in `ngNonBindable` is left alone. + * + * @example + + +
Normal: {{1 + 2}}
+
Ignored: {{1 + 2}}
+
+ + it('should check ng-non-bindable', function() { + expect(element(by.binding('1 + 2')).getText()).toContain('3'); + expect(element.all(by.css('div')).last().getText()).toMatch(/1 \+ 2/); + }); + +
+ */ +var ngNonBindableDirective = ngDirective({ terminal: true, priority: 1000 }); + +/** + * @ngdoc directive + * @name ngPluralize + * @restrict EA + * + * @description + * `ngPluralize` is a directive that displays messages according to en-US localization rules. + * These rules are bundled with angular.js, but can be overridden + * (see {@link guide/i18n Angular i18n} dev guide). You configure ngPluralize directive + * by specifying the mappings between + * [plural categories](http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html) + * and the strings to be displayed. + * + * # Plural categories and explicit number rules + * There are two + * [plural categories](http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html) + * in Angular's default en-US locale: "one" and "other". + * + * While a plural category may match many numbers (for example, in en-US locale, "other" can match + * any number that is not 1), an explicit number rule can only match one number. For example, the + * explicit number rule for "3" matches the number 3. There are examples of plural categories + * and explicit number rules throughout the rest of this documentation. + * + * # Configuring ngPluralize + * You configure ngPluralize by providing 2 attributes: `count` and `when`. + * You can also provide an optional attribute, `offset`. + * + * The value of the `count` attribute can be either a string or an {@link guide/expression + * Angular expression}; these are evaluated on the current scope for its bound value. + * + * The `when` attribute specifies the mappings between plural categories and the actual + * string to be displayed. The value of the attribute should be a JSON object. + * + * The following example shows how to configure ngPluralize: + * + * ```html + * + * + *``` + * + * In the example, `"0: Nobody is viewing."` is an explicit number rule. If you did not + * specify this rule, 0 would be matched to the "other" category and "0 people are viewing" + * would be shown instead of "Nobody is viewing". You can specify an explicit number rule for + * other numbers, for example 12, so that instead of showing "12 people are viewing", you can + * show "a dozen people are viewing". + * + * You can use a set of closed braces (`{}`) as a placeholder for the number that you want substituted + * into pluralized strings. In the previous example, Angular will replace `{}` with + * `{{personCount}}`. The closed braces `{}` is a placeholder + * for {{numberExpression}}. + * + * # Configuring ngPluralize with offset + * The `offset` attribute allows further customization of pluralized text, which can result in + * a better user experience. For example, instead of the message "4 people are viewing this document", + * you might display "John, Kate and 2 others are viewing this document". + * The offset attribute allows you to offset a number by any desired value. + * Let's take a look at an example: + * + * ```html + * + * + * ``` + * + * Notice that we are still using two plural categories(one, other), but we added + * three explicit number rules 0, 1 and 2. + * When one person, perhaps John, views the document, "John is viewing" will be shown. + * When three people view the document, no explicit number rule is found, so + * an offset of 2 is taken off 3, and Angular uses 1 to decide the plural category. + * In this case, plural category 'one' is matched and "John, Mary and one other person are viewing" + * is shown. + * + * Note that when you specify offsets, you must provide explicit number rules for + * numbers from 0 up to and including the offset. If you use an offset of 3, for example, + * you must provide explicit number rules for 0, 1, 2 and 3. You must also provide plural strings for + * plural categories "one" and "other". + * + * @param {string|expression} count The variable to be bound to. + * @param {string} when The mapping between plural category to its corresponding strings. + * @param {number=} offset Offset to deduct from the total number. + * + * @example + + + +
+ Person 1:
+ Person 2:
+ Number of People:
+ + + Without Offset: + +
+ + + With Offset(2): + + +
+
+ + it('should show correct pluralized string', function() { + var withoutOffset = element.all(by.css('ng-pluralize')).get(0); + var withOffset = element.all(by.css('ng-pluralize')).get(1); + var countInput = element(by.model('personCount')); + + expect(withoutOffset.getText()).toEqual('1 person is viewing.'); + expect(withOffset.getText()).toEqual('Igor is viewing.'); + + countInput.clear(); + countInput.sendKeys('0'); + + expect(withoutOffset.getText()).toEqual('Nobody is viewing.'); + expect(withOffset.getText()).toEqual('Nobody is viewing.'); + + countInput.clear(); + countInput.sendKeys('2'); + + expect(withoutOffset.getText()).toEqual('2 people are viewing.'); + expect(withOffset.getText()).toEqual('Igor and Misko are viewing.'); + + countInput.clear(); + countInput.sendKeys('3'); + + expect(withoutOffset.getText()).toEqual('3 people are viewing.'); + expect(withOffset.getText()).toEqual('Igor, Misko and one other person are viewing.'); + + countInput.clear(); + countInput.sendKeys('4'); + + expect(withoutOffset.getText()).toEqual('4 people are viewing.'); + expect(withOffset.getText()).toEqual('Igor, Misko and 2 other people are viewing.'); + }); + it('should show data-bound names', function() { + var withOffset = element.all(by.css('ng-pluralize')).get(1); + var personCount = element(by.model('personCount')); + var person1 = element(by.model('person1')); + var person2 = element(by.model('person2')); + personCount.clear(); + personCount.sendKeys('4'); + person1.clear(); + person1.sendKeys('Di'); + person2.clear(); + person2.sendKeys('Vojta'); + expect(withOffset.getText()).toEqual('Di, Vojta and 2 other people are viewing.'); + }); + +
+ */ +var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interpolate) { + var BRACE = /{}/g; + return { + restrict: 'EA', + link: function(scope, element, attr) { + var numberExp = attr.count, + whenExp = attr.$attr.when && element.attr(attr.$attr.when), // we have {{}} in attrs + offset = attr.offset || 0, + whens = scope.$eval(whenExp) || {}, + whensExpFns = {}, + startSymbol = $interpolate.startSymbol(), + endSymbol = $interpolate.endSymbol(), + isWhen = /^when(Minus)?(.+)$/; + + forEach(attr, function(expression, attributeName) { + if (isWhen.test(attributeName)) { + whens[lowercase(attributeName.replace('when', '').replace('Minus', '-'))] = + element.attr(attr.$attr[attributeName]); + } + }); + forEach(whens, function(expression, key) { + whensExpFns[key] = + $interpolate(expression.replace(BRACE, startSymbol + numberExp + '-' + + offset + endSymbol)); + }); + + scope.$watch(function ngPluralizeWatch() { + var value = parseFloat(scope.$eval(numberExp)); + + if (!isNaN(value)) { + //if explicit number rule such as 1, 2, 3... is defined, just use it. Otherwise, + //check it against pluralization rules in $locale service + if (!(value in whens)) value = $locale.pluralCat(value - offset); + return whensExpFns[value](scope, element, true); + } else { + return ''; + } + }, function ngPluralizeWatchAction(newVal) { + element.text(newVal); + }); + } + }; +}]; + +/** + * @ngdoc directive + * @name ngRepeat + * + * @description + * The `ngRepeat` directive instantiates a template once per item from a collection. Each template + * instance gets its own scope, where the given loop variable is set to the current collection item, + * and `$index` is set to the item index or key. + * + * Special properties are exposed on the local scope of each template instance, including: + * + * | Variable | Type | Details | + * |-----------|-----------------|-----------------------------------------------------------------------------| + * | `$index` | {@type number} | iterator offset of the repeated element (0..length-1) | + * | `$first` | {@type boolean} | true if the repeated element is first in the iterator. | + * | `$middle` | {@type boolean} | true if the repeated element is between the first and last in the iterator. | + * | `$last` | {@type boolean} | true if the repeated element is last in the iterator. | + * | `$even` | {@type boolean} | true if the iterator position `$index` is even (otherwise false). | + * | `$odd` | {@type boolean} | true if the iterator position `$index` is odd (otherwise false). | + * + * Creating aliases for these properties is possible with {@link ng.directive:ngInit `ngInit`}. + * This may be useful when, for instance, nesting ngRepeats. + * + * # Special repeat start and end points + * To repeat a series of elements instead of just one parent element, ngRepeat (as well as other ng directives) supports extending + * the range of the repeater by defining explicit start and end points by using **ng-repeat-start** and **ng-repeat-end** respectively. + * The **ng-repeat-start** directive works the same as **ng-repeat**, but will repeat all the HTML code (including the tag it's defined on) + * up to and including the ending HTML tag where **ng-repeat-end** is placed. + * + * The example below makes use of this feature: + * ```html + *
+ * Header {{ item }} + *
+ *
+ * Body {{ item }} + *
+ *
+ * Footer {{ item }} + *
+ * ``` + * + * And with an input of {@type ['A','B']} for the items variable in the example above, the output will evaluate to: + * ```html + *
+ * Header A + *
+ *
+ * Body A + *
+ *
+ * Footer A + *
+ *
+ * Header B + *
+ *
+ * Body B + *
+ *
+ * Footer B + *
+ * ``` + * + * The custom start and end points for ngRepeat also support all other HTML directive syntax flavors provided in AngularJS (such + * as **data-ng-repeat-start**, **x-ng-repeat-start** and **ng:repeat-start**). + * + * @animations + * **.enter** - when a new item is added to the list or when an item is revealed after a filter + * + * **.leave** - when an item is removed from the list or when an item is filtered out + * + * **.move** - when an adjacent item is filtered out causing a reorder or when the item contents are reordered + * + * @element ANY + * @scope + * @priority 1000 + * @param {repeat_expression} ngRepeat The expression indicating how to enumerate a collection. These + * formats are currently supported: + * + * * `variable in expression` – where variable is the user defined loop variable and `expression` + * is a scope expression giving the collection to enumerate. + * + * For example: `album in artist.albums`. + * + * * `(key, value) in expression` – where `key` and `value` can be any user defined identifiers, + * and `expression` is the scope expression giving the collection to enumerate. + * + * For example: `(name, age) in {'adam':10, 'amalie':12}`. + * + * * `variable in expression track by tracking_expression` – You can also provide an optional tracking function + * which can be used to associate the objects in the collection with the DOM elements. If no tracking function + * is specified the ng-repeat associates elements by identity in the collection. It is an error to have + * more than one tracking function to resolve to the same key. (This would mean that two distinct objects are + * mapped to the same DOM element, which is not possible.) Filters should be applied to the expression, + * before specifying a tracking expression. + * + * For example: `item in items` is equivalent to `item in items track by $id(item)`. This implies that the DOM elements + * will be associated by item identity in the array. + * + * For example: `item in items track by $id(item)`. A built in `$id()` function can be used to assign a unique + * `$$hashKey` property to each item in the array. This property is then used as a key to associated DOM elements + * with the corresponding item in the array by identity. Moving the same object in array would move the DOM + * element in the same way in the DOM. + * + * For example: `item in items track by item.id` is a typical pattern when the items come from the database. In this + * case the object identity does not matter. Two objects are considered equivalent as long as their `id` + * property is same. + * + * For example: `item in items | filter:searchText track by item.id` is a pattern that might be used to apply a filter + * to items in conjunction with a tracking expression. + * + * @example + * This example initializes the scope to a list of names and + * then uses `ngRepeat` to display every person: + + +
+ I have {{friends.length}} friends. They are: + +
    +
  • + [{{$index + 1}}] {{friend.name}} who is {{friend.age}} years old. +
  • +
+
+
+ + .example-animate-container { + background:white; + border:1px solid black; + list-style:none; + margin:0; + padding:0 10px; + } + + .animate-repeat { + line-height:40px; + list-style:none; + box-sizing:border-box; + } + + .animate-repeat.ng-move, + .animate-repeat.ng-enter, + .animate-repeat.ng-leave { + -webkit-transition:all linear 0.5s; + transition:all linear 0.5s; + } + + .animate-repeat.ng-leave.ng-leave-active, + .animate-repeat.ng-move, + .animate-repeat.ng-enter { + opacity:0; + max-height:0; + } + + .animate-repeat.ng-leave, + .animate-repeat.ng-move.ng-move-active, + .animate-repeat.ng-enter.ng-enter-active { + opacity:1; + max-height:40px; + } + + + var friends = element.all(by.repeater('friend in friends')); + + it('should render initial data set', function() { + expect(friends.count()).toBe(10); + expect(friends.get(0).getText()).toEqual('[1] John who is 25 years old.'); + expect(friends.get(1).getText()).toEqual('[2] Jessie who is 30 years old.'); + expect(friends.last().getText()).toEqual('[10] Samantha who is 60 years old.'); + expect(element(by.binding('friends.length')).getText()) + .toMatch("I have 10 friends. They are:"); + }); + + it('should update repeater when filter predicate changes', function() { + expect(friends.count()).toBe(10); + + element(by.model('q')).sendKeys('ma'); + + expect(friends.count()).toBe(2); + expect(friends.get(0).getText()).toEqual('[1] Mary who is 28 years old.'); + expect(friends.last().getText()).toEqual('[2] Samantha who is 60 years old.'); + }); + +
+ */ +var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { + var NG_REMOVED = '$$NG_REMOVED'; + var ngRepeatMinErr = minErr('ngRepeat'); + return { + transclude: 'element', + priority: 1000, + terminal: true, + $$tlb: true, + link: function($scope, $element, $attr, ctrl, $transclude){ + var expression = $attr.ngRepeat; + var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/), + trackByExp, trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn, + lhs, rhs, valueIdentifier, keyIdentifier, + hashFnLocals = {$id: hashKey}; + + if (!match) { + throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.", + expression); + } + + lhs = match[1]; + rhs = match[2]; + trackByExp = match[3]; + + if (trackByExp) { + trackByExpGetter = $parse(trackByExp); + trackByIdExpFn = function(key, value, index) { + // assign key, value, and $index to the locals so that they can be used in hash functions + if (keyIdentifier) hashFnLocals[keyIdentifier] = key; + hashFnLocals[valueIdentifier] = value; + hashFnLocals.$index = index; + return trackByExpGetter($scope, hashFnLocals); + }; + } else { + trackByIdArrayFn = function(key, value) { + return hashKey(value); + }; + trackByIdObjFn = function(key) { + return key; + }; + } + + match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); + if (!match) { + throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.", + lhs); + } + valueIdentifier = match[3] || match[1]; + keyIdentifier = match[2]; + + // Store a list of elements from previous run. This is a hash where key is the item from the + // iterator, and the value is objects with following properties. + // - scope: bound scope + // - element: previous element. + // - index: position + var lastBlockMap = {}; + + //watch props + $scope.$watchCollection(rhs, function ngRepeatAction(collection){ + var index, length, + previousNode = $element[0], // current position of the node + nextNode, + // Same as lastBlockMap but it has the current state. It will become the + // lastBlockMap on the next iteration. + nextBlockMap = {}, + arrayLength, + childScope, + key, value, // key/value of iteration + trackById, + trackByIdFn, + collectionKeys, + block, // last object information {scope, element, id} + nextBlockOrder = [], + elementsToRemove; + + + if (isArrayLike(collection)) { + collectionKeys = collection; + trackByIdFn = trackByIdExpFn || trackByIdArrayFn; + } else { + trackByIdFn = trackByIdExpFn || trackByIdObjFn; + // if object, extract keys, sort them and use to determine order of iteration over obj props + collectionKeys = []; + for (key in collection) { + if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { + collectionKeys.push(key); + } + } + collectionKeys.sort(); + } + + arrayLength = collectionKeys.length; + + // locate existing items + length = nextBlockOrder.length = collectionKeys.length; + for(index = 0; index < length; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + trackById = trackByIdFn(key, value, index); + assertNotHasOwnProperty(trackById, '`track by` id'); + if(lastBlockMap.hasOwnProperty(trackById)) { + block = lastBlockMap[trackById]; + delete lastBlockMap[trackById]; + nextBlockMap[trackById] = block; + nextBlockOrder[index] = block; + } else if (nextBlockMap.hasOwnProperty(trackById)) { + // restore lastBlockMap + forEach(nextBlockOrder, function(block) { + if (block && block.scope) lastBlockMap[block.id] = block; + }); + // This is a duplicate and we need to throw an error + throw ngRepeatMinErr('dupes', "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}", + expression, trackById); + } else { + // new never before seen block + nextBlockOrder[index] = { id: trackById }; + nextBlockMap[trackById] = false; + } + } + + // remove existing items + for (key in lastBlockMap) { + // lastBlockMap is our own object so we don't need to use special hasOwnPropertyFn + if (lastBlockMap.hasOwnProperty(key)) { + block = lastBlockMap[key]; + elementsToRemove = getBlockElements(block.clone); + $animate.leave(elementsToRemove); + forEach(elementsToRemove, function(element) { element[NG_REMOVED] = true; }); + block.scope.$destroy(); + } + } + + // we are not using forEach for perf reasons (trying to avoid #call) + for (index = 0, length = collectionKeys.length; index < length; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + block = nextBlockOrder[index]; + if (nextBlockOrder[index - 1]) previousNode = getBlockEnd(nextBlockOrder[index - 1]); + + if (block.scope) { + // if we have already seen this object, then we need to reuse the + // associated scope/element + childScope = block.scope; + + nextNode = previousNode; + do { + nextNode = nextNode.nextSibling; + } while(nextNode && nextNode[NG_REMOVED]); + + if (getBlockStart(block) != nextNode) { + // existing item which got moved + $animate.move(getBlockElements(block.clone), null, jqLite(previousNode)); + } + previousNode = getBlockEnd(block); + } else { + // new item which we don't know about + childScope = $scope.$new(); + } + + childScope[valueIdentifier] = value; + if (keyIdentifier) childScope[keyIdentifier] = key; + childScope.$index = index; + childScope.$first = (index === 0); + childScope.$last = (index === (arrayLength - 1)); + childScope.$middle = !(childScope.$first || childScope.$last); + // jshint bitwise: false + childScope.$odd = !(childScope.$even = (index&1) === 0); + // jshint bitwise: true + + if (!block.scope) { + $transclude(childScope, function(clone) { + clone[clone.length++] = document.createComment(' end ngRepeat: ' + expression + ' '); + $animate.enter(clone, null, jqLite(previousNode)); + previousNode = clone; + block.scope = childScope; + // Note: We only need the first/last node of the cloned nodes. + // However, we need to keep the reference to the jqlite wrapper as it might be changed later + // by a directive with templateUrl when its template arrives. + block.clone = clone; + nextBlockMap[block.id] = block; + }); + } + } + lastBlockMap = nextBlockMap; + }); + } + }; + + function getBlockStart(block) { + return block.clone[0]; + } + + function getBlockEnd(block) { + return block.clone[block.clone.length - 1]; + } +}]; + +/** + * @ngdoc directive + * @name ngShow + * + * @description + * The `ngShow` directive shows or hides the given HTML element based on the expression + * provided to the ngShow attribute. The element is shown or hidden by removing or adding + * the `ng-hide` CSS class onto the element. The `.ng-hide` CSS class is predefined + * in AngularJS and sets the display style to none (using an !important flag). + * For CSP mode please add `angular-csp.css` to your html file (see {@link ng.directive:ngCsp ngCsp}). + * + * ```html + * + *
+ * + * + *
+ * ``` + * + * When the ngShow expression evaluates to false then the ng-hide CSS class is added to the class attribute + * on the element causing it to become hidden. When true, the ng-hide CSS class is removed + * from the element causing the element not to appear hidden. + * + *
+ * **Note:** Here is a list of values that ngShow will consider as a falsy value (case insensitive):
+ * "f" / "0" / "false" / "no" / "n" / "[]" + *
+ * + * ## Why is !important used? + * + * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector + * can be easily overridden by heavier selectors. For example, something as simple + * as changing the display style on a HTML list item would make hidden elements appear visible. + * This also becomes a bigger issue when dealing with CSS frameworks. + * + * By using !important, the show and hide behavior will work as expected despite any clash between CSS selector + * specificity (when !important isn't used with any conflicting styles). If a developer chooses to override the + * styling to change how to hide an element then it is just a matter of using !important in their own CSS code. + * + * ### Overriding .ng-hide + * + * By default, the `.ng-hide` class will style the element with `display:none!important`. If you wish to change + * the hide behavior with ngShow/ngHide then this can be achieved by restating the styles for the `.ng-hide` + * class in CSS: + * + * ```css + * .ng-hide { + * //this is just another form of hiding an element + * display:block!important; + * position:absolute; + * top:-9999px; + * left:-9999px; + * } + * ``` + * + * By default you don't need to override in CSS anything and the animations will work around the display style. + * + * ## A note about animations with ngShow + * + * Animations in ngShow/ngHide work with the show and hide events that are triggered when the directive expression + * is true and false. This system works like the animation system present with ngClass except that + * you must also include the !important flag to override the display property + * so that you can perform an animation when the element is hidden during the time of the animation. + * + * ```css + * // + * //a working example can be found at the bottom of this page + * // + * .my-element.ng-hide-add, .my-element.ng-hide-remove { + * transition:0.5s linear all; + * } + * + * .my-element.ng-hide-add { ... } + * .my-element.ng-hide-add.ng-hide-add-active { ... } + * .my-element.ng-hide-remove { ... } + * .my-element.ng-hide-remove.ng-hide-remove-active { ... } + * ``` + * + * Keep in mind that, as of AngularJS version 1.2.17 (and 1.3.0-beta.11), there is no need to change the display + * property to block during animation states--ngAnimate will handle the style toggling automatically for you. + * + * @animations + * addClass: .ng-hide - happens after the ngShow expression evaluates to a truthy value and the just before contents are set to visible + * removeClass: .ng-hide - happens after the ngShow expression evaluates to a non truthy value and just before the contents are set to hidden + * + * @element ANY + * @param {expression} ngShow If the {@link guide/expression expression} is truthy + * then the element is shown or hidden respectively. + * + * @example + + + Click me:
+
+ Show: +
+ I show up when your checkbox is checked. +
+
+
+ Hide: +
+ I hide when your checkbox is checked. +
+
+
+ + @import url(//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css); + + + .animate-show { + -webkit-transition:all linear 0.5s; + transition:all linear 0.5s; + line-height:20px; + opacity:1; + padding:10px; + border:1px solid black; + background:white; + } + + .animate-show.ng-hide { + line-height:0; + opacity:0; + padding:0 10px; + } + + .check-element { + padding:10px; + border:1px solid black; + background:white; + } + + + var thumbsUp = element(by.css('span.glyphicon-thumbs-up')); + var thumbsDown = element(by.css('span.glyphicon-thumbs-down')); + + it('should check ng-show / ng-hide', function() { + expect(thumbsUp.isDisplayed()).toBeFalsy(); + expect(thumbsDown.isDisplayed()).toBeTruthy(); + + element(by.model('checked')).click(); + + expect(thumbsUp.isDisplayed()).toBeTruthy(); + expect(thumbsDown.isDisplayed()).toBeFalsy(); + }); + +
+ */ +var ngShowDirective = ['$animate', function($animate) { + return function(scope, element, attr) { + scope.$watch(attr.ngShow, function ngShowWatchAction(value){ + $animate[toBoolean(value) ? 'removeClass' : 'addClass'](element, 'ng-hide'); + }); + }; +}]; + + +/** + * @ngdoc directive + * @name ngHide + * + * @description + * The `ngHide` directive shows or hides the given HTML element based on the expression + * provided to the ngHide attribute. The element is shown or hidden by removing or adding + * the `ng-hide` CSS class onto the element. The `.ng-hide` CSS class is predefined + * in AngularJS and sets the display style to none (using an !important flag). + * For CSP mode please add `angular-csp.css` to your html file (see {@link ng.directive:ngCsp ngCsp}). + * + * ```html + * + *
+ * + * + *
+ * ``` + * + * When the ngHide expression evaluates to true then the .ng-hide CSS class is added to the class attribute + * on the element causing it to become hidden. When false, the ng-hide CSS class is removed + * from the element causing the element not to appear hidden. + * + *
+ * **Note:** Here is a list of values that ngHide will consider as a falsy value (case insensitive):
+ * "f" / "0" / "false" / "no" / "n" / "[]" + *
+ * + * ## Why is !important used? + * + * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector + * can be easily overridden by heavier selectors. For example, something as simple + * as changing the display style on a HTML list item would make hidden elements appear visible. + * This also becomes a bigger issue when dealing with CSS frameworks. + * + * By using !important, the show and hide behavior will work as expected despite any clash between CSS selector + * specificity (when !important isn't used with any conflicting styles). If a developer chooses to override the + * styling to change how to hide an element then it is just a matter of using !important in their own CSS code. + * + * ### Overriding .ng-hide + * + * By default, the `.ng-hide` class will style the element with `display:none!important`. If you wish to change + * the hide behavior with ngShow/ngHide then this can be achieved by restating the styles for the `.ng-hide` + * class in CSS: + * + * ```css + * .ng-hide { + * //this is just another form of hiding an element + * display:block!important; + * position:absolute; + * top:-9999px; + * left:-9999px; + * } + * ``` + * + * By default you don't need to override in CSS anything and the animations will work around the display style. + * + * ## A note about animations with ngHide + * + * Animations in ngShow/ngHide work with the show and hide events that are triggered when the directive expression + * is true and false. This system works like the animation system present with ngClass, except that the `.ng-hide` + * CSS class is added and removed for you instead of your own CSS class. + * + * ```css + * // + * //a working example can be found at the bottom of this page + * // + * .my-element.ng-hide-add, .my-element.ng-hide-remove { + * transition:0.5s linear all; + * } + * + * .my-element.ng-hide-add { ... } + * .my-element.ng-hide-add.ng-hide-add-active { ... } + * .my-element.ng-hide-remove { ... } + * .my-element.ng-hide-remove.ng-hide-remove-active { ... } + * ``` + * + * Keep in mind that, as of AngularJS version 1.2.17 (and 1.3.0-beta.11), there is no need to change the display + * property to block during animation states--ngAnimate will handle the style toggling automatically for you. + * + * @animations + * removeClass: .ng-hide - happens after the ngHide expression evaluates to a truthy value and just before the contents are set to hidden + * addClass: .ng-hide - happens after the ngHide expression evaluates to a non truthy value and just before the contents are set to visible + * + * @element ANY + * @param {expression} ngHide If the {@link guide/expression expression} is truthy then + * the element is shown or hidden respectively. + * + * @example + + + Click me:
+
+ Show: +
+ I show up when your checkbox is checked. +
+
+
+ Hide: +
+ I hide when your checkbox is checked. +
+
+
+ + @import url(//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css); + + + .animate-hide { + -webkit-transition:all linear 0.5s; + transition:all linear 0.5s; + line-height:20px; + opacity:1; + padding:10px; + border:1px solid black; + background:white; + } + + .animate-hide.ng-hide { + line-height:0; + opacity:0; + padding:0 10px; + } + + .check-element { + padding:10px; + border:1px solid black; + background:white; + } + + + var thumbsUp = element(by.css('span.glyphicon-thumbs-up')); + var thumbsDown = element(by.css('span.glyphicon-thumbs-down')); + + it('should check ng-show / ng-hide', function() { + expect(thumbsUp.isDisplayed()).toBeFalsy(); + expect(thumbsDown.isDisplayed()).toBeTruthy(); + + element(by.model('checked')).click(); + + expect(thumbsUp.isDisplayed()).toBeTruthy(); + expect(thumbsDown.isDisplayed()).toBeFalsy(); + }); + +
+ */ +var ngHideDirective = ['$animate', function($animate) { + return function(scope, element, attr) { + scope.$watch(attr.ngHide, function ngHideWatchAction(value){ + $animate[toBoolean(value) ? 'addClass' : 'removeClass'](element, 'ng-hide'); + }); + }; +}]; + +/** + * @ngdoc directive + * @name ngStyle + * @restrict AC + * + * @description + * The `ngStyle` directive allows you to set CSS style on an HTML element conditionally. + * + * @element ANY + * @param {expression} ngStyle + * + * {@link guide/expression Expression} which evals to an + * object whose keys are CSS style names and values are corresponding values for those CSS + * keys. + * + * Since some CSS style names are not valid keys for an object, they must be quoted. + * See the 'background-color' style in the example below. + * + * @example + + + + + +
+ Sample Text +
myStyle={{myStyle}}
+
+ + span { + color: black; + } + + + var colorSpan = element(by.css('span')); + + it('should check ng-style', function() { + expect(colorSpan.getCssValue('color')).toBe('rgba(0, 0, 0, 1)'); + element(by.css('input[value=\'set color\']')).click(); + expect(colorSpan.getCssValue('color')).toBe('rgba(255, 0, 0, 1)'); + element(by.css('input[value=clear]')).click(); + expect(colorSpan.getCssValue('color')).toBe('rgba(0, 0, 0, 1)'); + }); + +
+ */ +var ngStyleDirective = ngDirective(function(scope, element, attr) { + scope.$watch(attr.ngStyle, function ngStyleWatchAction(newStyles, oldStyles) { + if (oldStyles && (newStyles !== oldStyles)) { + forEach(oldStyles, function(val, style) { element.css(style, '');}); + } + if (newStyles) element.css(newStyles); + }, true); +}); + +/** + * @ngdoc directive + * @name ngSwitch + * @restrict EA + * + * @description + * The `ngSwitch` directive is used to conditionally swap DOM structure on your template based on a scope expression. + * Elements within `ngSwitch` but without `ngSwitchWhen` or `ngSwitchDefault` directives will be preserved at the location + * as specified in the template. + * + * The directive itself works similar to ngInclude, however, instead of downloading template code (or loading it + * from the template cache), `ngSwitch` simply chooses one of the nested elements and makes it visible based on which element + * matches the value obtained from the evaluated expression. In other words, you define a container element + * (where you place the directive), place an expression on the **`on="..."` attribute** + * (or the **`ng-switch="..."` attribute**), define any inner elements inside of the directive and place + * a when attribute per element. The when attribute is used to inform ngSwitch which element to display when the on + * expression is evaluated. If a matching expression is not found via a when attribute then an element with the default + * attribute is displayed. + * + *
+ * Be aware that the attribute values to match against cannot be expressions. They are interpreted + * as literal string values to match against. + * For example, **`ng-switch-when="someVal"`** will match against the string `"someVal"` not against the + * value of the expression `$scope.someVal`. + *
+ + * @animations + * enter - happens after the ngSwitch contents change and the matched child element is placed inside the container + * leave - happens just after the ngSwitch contents change and just before the former contents are removed from the DOM + * + * @usage + * + * ``` + * + * ... + * ... + * ... + * + * ``` + * + * + * @scope + * @priority 800 + * @param {*} ngSwitch|on expression to match against ng-switch-when. + * On child elements add: + * + * * `ngSwitchWhen`: the case statement to match against. If match then this + * case will be displayed. If the same match appears multiple times, all the + * elements will be displayed. + * * `ngSwitchDefault`: the default case when no other case match. If there + * are multiple default cases, all of them will be displayed when no other + * case match. + * + * + * @example + + +
+ + selection={{selection}} +
+
+
Settings Div
+
Home Span
+
default
+
+
+
+ + angular.module('switchExample', ['ngAnimate']) + .controller('ExampleController', ['$scope', function($scope) { + $scope.items = ['settings', 'home', 'other']; + $scope.selection = $scope.items[0]; + }]); + + + .animate-switch-container { + position:relative; + background:white; + border:1px solid black; + height:40px; + overflow:hidden; + } + + .animate-switch { + padding:10px; + } + + .animate-switch.ng-animate { + -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + + position:absolute; + top:0; + left:0; + right:0; + bottom:0; + } + + .animate-switch.ng-leave.ng-leave-active, + .animate-switch.ng-enter { + top:-50px; + } + .animate-switch.ng-leave, + .animate-switch.ng-enter.ng-enter-active { + top:0; + } + + + var switchElem = element(by.css('[ng-switch]')); + var select = element(by.model('selection')); + + it('should start in settings', function() { + expect(switchElem.getText()).toMatch(/Settings Div/); + }); + it('should change to home', function() { + select.all(by.css('option')).get(1).click(); + expect(switchElem.getText()).toMatch(/Home Span/); + }); + it('should select default', function() { + select.all(by.css('option')).get(2).click(); + expect(switchElem.getText()).toMatch(/default/); + }); + +
+ */ +var ngSwitchDirective = ['$animate', function($animate) { + return { + restrict: 'EA', + require: 'ngSwitch', + + // asks for $scope to fool the BC controller module + controller: ['$scope', function ngSwitchController() { + this.cases = {}; + }], + link: function(scope, element, attr, ngSwitchController) { + var watchExpr = attr.ngSwitch || attr.on, + selectedTranscludes = [], + selectedElements = [], + previousElements = [], + selectedScopes = []; + + scope.$watch(watchExpr, function ngSwitchWatchAction(value) { + var i, ii; + for (i = 0, ii = previousElements.length; i < ii; ++i) { + previousElements[i].remove(); + } + previousElements.length = 0; + + for (i = 0, ii = selectedScopes.length; i < ii; ++i) { + var selected = selectedElements[i]; + selectedScopes[i].$destroy(); + previousElements[i] = selected; + $animate.leave(selected, function() { + previousElements.splice(i, 1); + }); + } + + selectedElements.length = 0; + selectedScopes.length = 0; + + if ((selectedTranscludes = ngSwitchController.cases['!' + value] || ngSwitchController.cases['?'])) { + scope.$eval(attr.change); + forEach(selectedTranscludes, function(selectedTransclude) { + var selectedScope = scope.$new(); + selectedScopes.push(selectedScope); + selectedTransclude.transclude(selectedScope, function(caseElement) { + var anchor = selectedTransclude.element; + + selectedElements.push(caseElement); + $animate.enter(caseElement, anchor.parent(), anchor); + }); + }); + } + }); + } + }; +}]; + +var ngSwitchWhenDirective = ngDirective({ + transclude: 'element', + priority: 800, + require: '^ngSwitch', + link: function(scope, element, attrs, ctrl, $transclude) { + ctrl.cases['!' + attrs.ngSwitchWhen] = (ctrl.cases['!' + attrs.ngSwitchWhen] || []); + ctrl.cases['!' + attrs.ngSwitchWhen].push({ transclude: $transclude, element: element }); + } +}); + +var ngSwitchDefaultDirective = ngDirective({ + transclude: 'element', + priority: 800, + require: '^ngSwitch', + link: function(scope, element, attr, ctrl, $transclude) { + ctrl.cases['?'] = (ctrl.cases['?'] || []); + ctrl.cases['?'].push({ transclude: $transclude, element: element }); + } +}); + +/** + * @ngdoc directive + * @name ngTransclude + * @restrict AC + * + * @description + * Directive that marks the insertion point for the transcluded DOM of the nearest parent directive that uses transclusion. + * + * Any existing content of the element that this directive is placed on will be removed before the transcluded content is inserted. + * + * @element ANY + * + * @example + + + +
+
+
+ {{text}} +
+
+ + it('should have transcluded', function() { + var titleElement = element(by.model('title')); + titleElement.clear(); + titleElement.sendKeys('TITLE'); + var textElement = element(by.model('text')); + textElement.clear(); + textElement.sendKeys('TEXT'); + expect(element(by.binding('title')).getText()).toEqual('TITLE'); + expect(element(by.binding('text')).getText()).toEqual('TEXT'); + }); + +
+ * + */ +var ngTranscludeDirective = ngDirective({ + link: function($scope, $element, $attrs, controller, $transclude) { + if (!$transclude) { + throw minErr('ngTransclude')('orphan', + 'Illegal use of ngTransclude directive in the template! ' + + 'No parent directive that requires a transclusion found. ' + + 'Element: {0}', + startingTag($element)); + } + + $transclude(function(clone) { + $element.empty(); + $element.append(clone); + }); + } +}); + +/** + * @ngdoc directive + * @name script + * @restrict E + * + * @description + * Load the content of a ` + + Load inlined template +
+ + + it('should load template defined inside script tag', function() { + element(by.css('#tpl-link')).click(); + expect(element(by.css('#tpl-content')).getText()).toMatch(/Content of the template/); + }); + + + */ +var scriptDirective = ['$templateCache', function($templateCache) { + return { + restrict: 'E', + terminal: true, + compile: function(element, attr) { + if (attr.type == 'text/ng-template') { + var templateUrl = attr.id, + // IE is not consistent, in scripts we have to read .text but in other nodes we have to read .textContent + text = element[0].text; + + $templateCache.put(templateUrl, text); + } + } + }; +}]; + +var ngOptionsMinErr = minErr('ngOptions'); +/** + * @ngdoc directive + * @name select + * @restrict E + * + * @description + * HTML `SELECT` element with angular data-binding. + * + * # `ngOptions` + * + * The `ngOptions` attribute can be used to dynamically generate a list of `` + * DOM element. + * * `trackexpr`: Used when working with an array of objects. The result of this expression will be + * used to identify the objects in the array. The `trackexpr` will most likely refer to the + * `value` variable (e.g. `value.propertyName`). + * + * @example + + + +
+
    +
  • + Name: + [X] +
  • +
  • + [add] +
  • +
+
+ Color (null not allowed): +
+ + Color (null allowed): + + +
+ + Color grouped by shade: +
+ + + Select bogus.
+
+ Currently selected: {{ {selected_color:myColor} }} +
+
+
+
+ + it('should check ng-options', function() { + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('red'); + element.all(by.model('myColor')).first().click(); + element.all(by.css('select[ng-model="myColor"] option')).first().click(); + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('black'); + element(by.css('.nullable select[ng-model="myColor"]')).click(); + element.all(by.css('.nullable select[ng-model="myColor"] option')).first().click(); + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('null'); + }); + +
+ */ + +var ngOptionsDirective = valueFn({ terminal: true }); +// jshint maxlen: false +var selectDirective = ['$compile', '$parse', function($compile, $parse) { + //000011111111110000000000022222222220000000000000000000003333333333000000000000004444444444444440000000005555555555555550000000666666666666666000000000000000777777777700000000000000000008888888888 + var NG_OPTIONS_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/, + nullModelCtrl = {$setViewValue: noop}; +// jshint maxlen: 100 + + return { + restrict: 'E', + require: ['select', '?ngModel'], + controller: ['$element', '$scope', '$attrs', function($element, $scope, $attrs) { + var self = this, + optionsMap = {}, + ngModelCtrl = nullModelCtrl, + nullOption, + unknownOption; + + + self.databound = $attrs.ngModel; + + + self.init = function(ngModelCtrl_, nullOption_, unknownOption_) { + ngModelCtrl = ngModelCtrl_; + nullOption = nullOption_; + unknownOption = unknownOption_; + }; + + + self.addOption = function(value) { + assertNotHasOwnProperty(value, '"option value"'); + optionsMap[value] = true; + + if (ngModelCtrl.$viewValue == value) { + $element.val(value); + if (unknownOption.parent()) unknownOption.remove(); + } + }; + + + self.removeOption = function(value) { + if (this.hasOption(value)) { + delete optionsMap[value]; + if (ngModelCtrl.$viewValue == value) { + this.renderUnknownOption(value); + } + } + }; + + + self.renderUnknownOption = function(val) { + var unknownVal = '? ' + hashKey(val) + ' ?'; + unknownOption.val(unknownVal); + $element.prepend(unknownOption); + $element.val(unknownVal); + unknownOption.prop('selected', true); // needed for IE + }; + + + self.hasOption = function(value) { + return optionsMap.hasOwnProperty(value); + }; + + $scope.$on('$destroy', function() { + // disable unknown option so that we don't do work when the whole select is being destroyed + self.renderUnknownOption = noop; + }); + }], + + link: function(scope, element, attr, ctrls) { + // if ngModel is not defined, we don't need to do anything + if (!ctrls[1]) return; + + var selectCtrl = ctrls[0], + ngModelCtrl = ctrls[1], + multiple = attr.multiple, + optionsExp = attr.ngOptions, + nullOption = false, // if false, user will not be able to select it (used by ngOptions) + emptyOption, + // we can't just jqLite('