diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 0d20b64..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.pyc diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d645695..0000000 --- a/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - 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 - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - 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/README.md b/README.md deleted file mode 100644 index 2a1dac3..0000000 --- a/README.md +++ /dev/null @@ -1,21 +0,0 @@ -cosmopolite -=========== - -Client/server publish/subscribe and key/value storage for AppEngine. - -Components: -* A server API built on the AppEngine Python framework. -* A browser client library written in JavaScript. - -Feature overview: -* Near-realtime notification to subscribers of messages published to a "subject" -* Server-side storage of past messages for replay later to clients -* Support for associating a key with a message and for lookup of the most recent - message for a given key -* Client identification persistence via localStorage tokens or in combination - with Google account signin -* Complex messages supported via transparent JSON serialization -* Server-side strict ordering of messages -* Client-side message queueing in localStorage and resumption on restart -* Message duplication detection and elimination -* Promise support for notification of client -> server operation completion diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/api.py b/api.py deleted file mode 100644 index cd5b10b..0000000 --- a/api.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright 2014, Ian Gulliver -# -# 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 -import webapp2 - -from google.appengine.api import channel -from google.appengine.ext import db - -from cosmopolite.lib import auth -from cosmopolite.lib import models -from cosmopolite.lib import security -from cosmopolite.lib import session -from cosmopolite.lib import utils - -import config - - -def CreateChannel(google_user, client, args): - token = channel.create_channel( - client_id=str(client.key()), - duration_minutes=config.CHANNEL_DURATION_SECONDS / 60) - events = [] - if google_user: - events.append({ - 'event_type': 'login', - 'profile': str(client.parent_key()), - 'google_user': google_user.email(), - }) - else: - events.append({ - 'event_type': 'logout', - 'profile': str(client.parent_key()), - }) - - return { - 'token': token, - 'events': events, - } - - -def SendMessage(google_user, client, args): - subject = args['subject'] - message = args['message'] - sender_message_id = args['sender_message_id'] - key = args.get('key', None) - - try: - models.Subject.FindOrCreate(subject).SendMessage( - message, client.parent_key(), sender_message_id, key) - except models.DuplicateMessage: - logging.exception('Duplicate message: %s', sender_message_id) - return { - 'result': 'duplicate_message', - } - except models.AccessDenied: - logging.exception('SendMessage access denied') - return { - 'result': 'access_denied', - } - - return { - 'result': 'ok', - } - - -def Subscribe(google_user, client, args): - subject = models.Subject.FindOrCreate(args['subject']) - messages = args.get('messages', 0) - last_id = args.get('last_id', None) - keys = args.get('keys', []) - - try: - ret = { - 'result': 'ok', - 'events': models.Subscription.FindOrCreate(subject, client, messages, last_id), - } - except models.AccessDenied: - logging.exception('Subscribe access denied') - return { - 'result': 'access_denied', - } - - for key in keys: - message = subject.GetKey(key) - if message: - ret['events'].append(message.ToEvent()) - - return ret - - -def Unsubscribe(google_user, client, args): - subject = models.Subject.FindOrCreate(args['subject']) - models.Subscription.Remove(subject, client) - - return {} - - -class APIWrapper(webapp2.RequestHandler): - - _COMMANDS = { - 'createChannel': CreateChannel, - 'sendMessage': SendMessage, - 'subscribe': Subscribe, - 'unsubscribe': Unsubscribe, - } - - @utils.chaos_monkey - @utils.expects_json - @utils.returns_json - @utils.local_namespace - @security.google_user_xsrf_protection - @security.weak_security_checks - @session.session_required - def post(self): - ret = { - 'status': 'ok', - 'responses': [], - 'events': [], - } - for command in self.request_json['commands']: - callback = self._COMMANDS[command['command']] - result = callback( - self.verified_google_user, - self.client, - command.get('arguments', {})) - # Magic: if result contains "events", haul them up a level so the - # client can see them as a single stream. - ret['events'].extend(result.pop('events', [])) - ret['responses'].append(result) - return ret - - -app = webapp2.WSGIApplication([ - (config.URL_PREFIX + '/api', APIWrapper), -]) diff --git a/auth.py b/auth.py deleted file mode 100644 index 412c6f6..0000000 --- a/auth.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2014, Ian Gulliver -# -# 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 webapp2 - -from google.appengine.api import users - -from cosmopolite.lib import security - -import config - - -class Login(webapp2.RequestHandler): - @security.weak_security_checks - def get(self): - self.redirect(users.create_login_url( - dest_url=config.URL_PREFIX + '/static/login_complete.html')) - - -class Logout(webapp2.RequestHandler): - @security.weak_security_checks - def get(self): - self.redirect(users.create_logout_url( - dest_url=config.URL_PREFIX + '/static/logout_complete.html')) - - -app = webapp2.WSGIApplication([ - (config.URL_PREFIX + '/auth/login', Login), - (config.URL_PREFIX + '/auth/logout', Logout), -]) diff --git a/channel.py b/channel.py deleted file mode 100644 index e502bf8..0000000 --- a/channel.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2014, Ian Gulliver -# -# 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 webapp2 - -from google.appengine.ext import db - -from cosmopolite.lib import auth -from cosmopolite.lib import models -from cosmopolite.lib import utils - - -class OnChannelConnect(webapp2.RequestHandler): - @utils.local_namespace - @db.transactional() - def post(self): - client = models.Client.get(self.request.get('from')) - client.channel_active = True - client.put() - - -class OnChannelDisconnect(webapp2.RequestHandler): - @utils.local_namespace - def post(self): - client = models.Client.get(self.request.get('from')) - client.channel_active = False - client.put() - - subscriptions = models.Subscription.all().filter('client =', client) - for subscription in subscriptions: - subscription.delete() - - -app = webapp2.WSGIApplication([ - ('/_ah/channel/connected/', OnChannelConnect), - ('/_ah/channel/disconnected/', OnChannelDisconnect), -]) diff --git a/config.py b/config.py deleted file mode 100644 index 92e43f6..0000000 --- a/config.py +++ /dev/null @@ -1,9 +0,0 @@ -# Co-existence -URL_PREFIX = '/cosmopolite' -NAMESPACE = 'cosmopolite' - -# Timings -CHANNEL_DURATION_SECONDS = 60 * 60 * 2 # 2 hours - -# Probabilities -CHAOS_PROBABILITY = 0.50 diff --git a/include.yaml b/include.yaml deleted file mode 100644 index 55edd70..0000000 --- a/include.yaml +++ /dev/null @@ -1,19 +0,0 @@ -handlers: -- url: /cosmopolite/api - script: cosmopolite.api.app - secure: always - -- url: /cosmopolite/auth/.* - script: cosmopolite.auth.app - secure: always - -- url: /_ah/channel/.* - script: cosmopolite.channel.app - -- url: /cosmopolite/static - static_dir: cosmopolite/static - secure: always - http_headers: - X-Frame-Options: DENY - X-Content-Type-Options: nosniff - Strict-Transport-Security: max-age=31536000; includeSubDomains diff --git a/lib/__init__.py b/lib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/auth.py b/lib/auth.py deleted file mode 100644 index 4d21c44..0000000 --- a/lib/auth.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2014, Ian Gulliver -# -# 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 hashlib -import hmac -import random -import string - -from google.appengine.api import memcache -from google.appengine.ext import db - - -class BadSignature(Exception): - pass - - -class AuthKey(db.Model): - auth_key = db.ByteStringProperty(required=True) - live = db.BooleanProperty(required=True, default=True) - - -_KEY_CHARS = string.ascii_letters + string.digits -_KEY_LENGTH = 64 - -def GetAuthKey(): - auth_key = memcache.get('auth_key') - if auth_key: - return auth_key - - for key in AuthKey.all().filter('live =', True): - auth_key = key.auth_key - - if not auth_key: - auth_key = ''.join(random.choice(_KEY_CHARS) for _ in xrange(_KEY_LENGTH)) - AuthKey(auth_key=auth_key).put() - - memcache.set('auth_key', auth_key) - return auth_key - - -def Sign(value): - sig = hmac.new(GetAuthKey(), str(value), hashlib.sha512) - return '%s:%s' % (value, sig.hexdigest()) - - -def Parse(token): - if not token: - return None - value, sig_digest = token.split(':', 1) - if token != Sign(value): - raise BadSignature - return value - - -def ParseKey(token): - if not token: - return None - return db.Key(encoded=Parse(token)) diff --git a/lib/models.py b/lib/models.py deleted file mode 100644 index 973298a..0000000 --- a/lib/models.py +++ /dev/null @@ -1,288 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014, Ian Gulliver -# -# 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 json -import logging - -from google.appengine.api import channel -from google.appengine.ext import db - -import utils - - -# Profile -# ↳ Client -# -# Subject -# ↳ Message -# ↳ Subscription (⤴︎ Client) - - -class DuplicateMessage(Exception): - pass - - -class AccessDenied(Exception): - pass - - -class Profile(db.Model): - google_user = db.UserProperty() - - @classmethod - def FromGoogleUser(cls, google_user): - if not google_user: - profile = Profile() - profile.put() - return profile - - profiles = Profile.all().filter('google_user =', google_user).fetch(1) - if profiles: - return profiles[0] - else: - # TODO(flamingcow): Fetch-then-store uniqueness is a race. - profile = Profile(google_user=google_user) - profile.put() - return profile - - def MergeFrom(self, source_profile): - # This is non-transactional and racy (new messages can be introduced by the - # old client after we start). This is hard to solve because a) we're not in - # a single hierarchy and b) we don't revoke the old client ID, so it can - # still be used. - for message in Message.all().filter('sender =', source_profile): - message.sender = self; - message.put() - - -class Client(db.Model): - # parent=Profile - - first_seen = db.DateTimeProperty(required=True, auto_now_add=True) - channel_active = db.BooleanProperty(required=True, default=False) - - @classmethod - def FromProfile(cls, profile): - client = cls(parent=profile) - client.put() - return client - - @classmethod - def FromGoogleUser(cls, google_user): - profile = Profile.FromGoogleUser(google_user) - return cls.FromProfile(profile) - - def SendMessage(self, msg): - self.SendByKey(self.key(), msg) - - @staticmethod - def SendByKey(key, msg): - channel.send_message(str(key), json.dumps(msg, default=utils.EncodeJSON)) - - -class Subject(db.Model): - - name = db.StringProperty(required=True) - readable_only_by = db.ReferenceProperty( - reference_class=Profile, collection_name='readable_subject_set') - writable_only_by = db.ReferenceProperty( - reference_class=Profile, collection_name='writable_subject_set') - - next_message_id = db.IntegerProperty(required=True, default=1) - - @classmethod - def FindOrCreate(cls, subject): - if 'readable_only_by' in subject: - readable_only_by = Profile.get(subject['readable_only_by']) - else: - readable_only_by = None - - if 'writable_only_by' in subject: - writable_only_by = Profile.get(subject['writable_only_by']) - else: - writable_only_by = None - - subjects = ( - cls.all() - .filter('name =', subject['name']) - .filter('readable_only_by =', readable_only_by) - .filter('writable_only_by =', writable_only_by) - .fetch(1)) - if subjects: - return subjects[0] - subject = cls( - name=subject['name'], - readable_only_by=readable_only_by, - writable_only_by=writable_only_by) - subject.put() - return subject - - @db.transactional() - def GetRecentMessages(self, num_messages): - query = ( - Message.all() - .ancestor(self) - .order('-id_')) - if num_messages <= 0: - num_messages = None - return reversed(query.fetch(limit=num_messages)) - - @db.transactional() - def GetMessagesSince(self, last_id): - query = ( - Message.all() - .ancestor(self) - .filter('id_ >', last_id) - .order('id_')) - return list(query) - - @db.transactional() - def GetKey(self, key): - messages = ( - Message.all() - .ancestor(self) - .filter('key_ =', key) - .order('-id_') - .fetch(1)) - if messages: - return messages[0] - return None - - @db.transactional() - def PutMessage(self, message, sender, sender_message_id, key=None): - """Internal helper for SendMessage(). - - Unless/until channel.send_message becomes transactional, we have to finish - the datastore work (and any retries) before we start transmitting to - channels. - """ - # We have to reload the Subject inside the transaction to get transactional - # ID generation - subject = Subject.get(self.key()) - - # sender_message_id should be universal across all subjects, but we check - # it within just this subject to allow in-transaction verification. - messages = ( - Message.all() - .ancestor(subject) - .filter('sender_message_id =', sender_message_id) - .fetch(1)) - if messages: - raise DuplicateMessage(sender_message_id) - - message_id = subject.next_message_id - subject.next_message_id += 1 - subject.put() - - obj = Message( - parent=subject, - message=message, - sender=sender, - sender_message_id=sender_message_id, - id_=message_id, - key_=key) - obj.put() - - return ( - obj, - [Subscription.client.get_value_for_datastore(subscription) - for subscription in Subscription.all().ancestor(subject)]) - - def SendMessage(self, message, sender, sender_message_id, key=None): - writable_only_by = Subject.writable_only_by.get_value_for_datastore(self) - if (writable_only_by and - writable_only_by != sender): - raise AccessDenied - obj, subscriptions = self.PutMessage(message, sender, sender_message_id, key) - event = obj.ToEvent() - for subscription in subscriptions: - Client.SendByKey(subscription, event) - - def ToDict(self): - ret = { - 'name': self.name, - } - readable_only_by = Subject.readable_only_by.get_value_for_datastore(self) - if readable_only_by: - ret['readable_only_by'] = readable_only_by - writable_only_by = Subject.writable_only_by.get_value_for_datastore(self) - if writable_only_by: - ret['writable_only_by'] = writable_only_by - return ret - - -class Subscription(db.Model): - # parent=Subject - - client = db.ReferenceProperty(reference_class=Client) - - @classmethod - @db.transactional() - def FindOrCreate(cls, subject, client, messages=0, last_id=None): - readable_only_by = ( - Subject.readable_only_by.get_value_for_datastore(subject)) - if (readable_only_by and - readable_only_by != client.parent_key()): - raise AccessDenied - - subscriptions = ( - cls.all(keys_only=True) - .ancestor(subject) - .filter('client =', client) - .fetch(1)) - if not subscriptions: - cls(parent=subject, client=client).put() - events = [] - if messages: - events.extend(m.ToEvent() for m in subject.GetRecentMessages(messages)) - if last_id is not None: - events.extend(m.ToEvent() for m in subject.GetMessagesSince(last_id)) - return events - - @classmethod - @db.transactional() - def Remove(cls, subject, client): - subscriptions = ( - cls.all() - .ancestor(subject) - .filter('client =', client)) - for subscription in subscriptions: - subscription.delete() - - -class Message(db.Model): - # parent=Subject - - created = db.DateTimeProperty(required=True, auto_now_add=True) - message = db.TextProperty(required=True) - sender = db.ReferenceProperty(required=True, reference_class=Profile) - sender_message_id = db.StringProperty(required=True) - # id is reserved - id_ = db.IntegerProperty(required=True) - # key and key_name are reserved - key_ = db.StringProperty() - - def ToEvent(self): - ret = { - 'event_type': 'message', - 'id': self.id_, - 'sender': str(Message.sender.get_value_for_datastore(self)), - 'subject': self.parent().ToDict(), - 'created': self.created, - 'message': self.message, - } - if self.key_: - ret['key'] = self.key_ - return ret diff --git a/lib/security.py b/lib/security.py deleted file mode 100644 index f84252b..0000000 --- a/lib/security.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright 2014, Ian Gulliver -# -# 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 functools - -from google.appengine.api import users - -from cosmopolite.lib import auth - - -def google_user_xsrf_protection(handler): - """Validate google user cookie. - - We can't trust that the action being requested is being made by this user due - to XSRF concerns (since google user is stored in a cookie). We have to make - sure that this user can actually receive responses, so we ask them to pass a - second token about their user that we can validate. - """ - - @functools.wraps(handler) - def ValidateGoogleUser(self): - self.verified_google_user = None - - google_user = users.get_current_user() - if not google_user: - return handler(self) - - google_user_id = auth.Parse(self.request_json.get('google_user_id', None)) - if (not google_user_id or - google_user_id != google_user.user_id()): - return { - 'status': 'retry', - 'google_user_id': auth.Sign(google_user.user_id()), - } - - self.verified_google_user = google_user - return handler(self) - - return ValidateGoogleUser - - -def weak_security_checks(handler): - - @functools.wraps(handler) - def CheckOriginHeader(self): - origin = self.request.headers.get('Origin') - if origin: - host = self.request.headers.get('Host') - possible_origins = { - 'http://%s' % host, - 'https://%s' % host, - } - if origin not in possible_origins: - self.error(403) - self.response.out.write('Origin/Host header mismatch') - return - return handler(self) - - return CheckOriginHeader diff --git a/lib/session.py b/lib/session.py deleted file mode 100644 index 0d5449b..0000000 --- a/lib/session.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright 2014, Ian Gulliver -# -# 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 functools - -from google.appengine.api import users - -from cosmopolite.lib import auth -from cosmopolite.lib import models - - -def _CheckClientAndGoogleUser(client, google_user): - if not google_user: - # Nothing to check. If there's a user on the profile, it can stay there. - return client - - client_profile_google_user = client.parent().google_user - if client_profile_google_user: - if client_profile_google_user == google_user: - return client - else: - # Shared computer? Google account wins. - return models.Client.FromGoogleUser(google_user) - - # User just signed in. Their anonymous profile gets permanently - # associated with this Google account. - profiles = (models.Profile.all() - .filter('google_user =', google_user) - .fetch(1)) - if profiles: - # We can't convert the anonymous profile because there's already - # a profile for this Google user. Create a new client_id pointing to that - # profile. - # TODO(flamingcow): Fetch-then-store uniqueness is a race. - google_profile = profiles[0] - google_profile.MergeFrom(client.parent_key()) - return models.Client.FromProfile(google_profile) - - # First time signin. - client_profile = client.parent() - client_profile.google_user = google_user - client_profile.put() - return client - - -def session_required(handler): - """Find or create a session for this user. - - Find or create a Client and Profile for this user. Muck with the return value - to wrap it in an object that contains session info for the client. - - Make sure to wrap this in google_user_xsrf_protection. - """ - - @functools.wraps(handler) - def FindOrCreateSession(self): - client_key = auth.ParseKey(self.request_json.get('client_id', None)) - - # The hunt for a Profile begins. - if client_key: - self.client = _CheckClientAndGoogleUser( - models.Client.get(client_key), - self.verified_google_user) - else: - self.client = models.Client.FromGoogleUser(self.verified_google_user) - - ret = handler(self) - if client_key != self.client.key(): - # Tell the client that this changed - ret['client_id'] = auth.Sign(self.client.key()) - - return ret - - return FindOrCreateSession diff --git a/lib/utils.py b/lib/utils.py deleted file mode 100644 index 58b00f6..0000000 --- a/lib/utils.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright 2014, Ian Gulliver -# -# 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 datetime -import functools -import json -import random -import time - -from google.appengine.api import namespace_manager - -from cosmopolite import config - -from cosmopolite.lib import auth - - -def expects_json(handler): - - @functools.wraps(handler) - def ParseInput(self): - self.request_json = json.load(self.request.body_file) - return handler(self) - - return ParseInput - - -def returns_json(handler): - - @functools.wraps(handler) - def SerializeResult(self): - json.dump(handler(self), self.response.out, default=EncodeJSON) - - return SerializeResult - - -def chaos_monkey(handler): - - @functools.wraps(handler) - def IntroduceFailures(self): - if random.random() < config.CHAOS_PROBABILITY: - self.response.headers['Retry-After'] = '0' - self.error(503) - return - return handler(self) - - return IntroduceFailures - - -def local_namespace(handler): - - @functools.wraps(handler) - def SetNamespace(self): - import logging - namespace_manager.set_namespace(config.NAMESPACE) - return handler(self) - - return SetNamespace - - -def EncodeJSON(o): - if isinstance(o, datetime.datetime): - return time.mktime(o.timetuple()) - return json.JSONEncoder.default(o) diff --git a/static/cosmopolite.js b/static/cosmopolite.js deleted file mode 100644 index fcdf7d5..0000000 --- a/static/cosmopolite.js +++ /dev/null @@ -1,796 +0,0 @@ -/** - * @license - * Copyright 2014, Ian Gulliver - * - * 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. - */ - -// We use long keys in many places. Provide a method to trim those down for -// human readability. -String.prototype.hashCode = function() { - var hash = 0; - for (i = 0; i < this.length; i++) { - var char = this.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return hash; -}; - -/** - * @constructor - * @param {Object=} callbacks Callback dictionary - * @param {string=} urlPrefix Absolute URL prefix for generating API URL - * @param {string=} namespace Prefix for localStorage entries. - */ -var Cosmopolite = function(callbacks, urlPrefix, namespace) { - this.callbacks_ = callbacks || {}; - this.urlPrefix_ = urlPrefix || '/cosmopolite'; - this.namespace_ = namespace || 'cosmopolite'; - - this.channelState_ = this.ChannelState.CLOSED; - this.shutdown_ = false; - - this.rpcQueue_ = []; - this.subscriptions_ = {}; - this.profilePromises_ = []; - - this.messageQueueKey_ = this.namespace_ + ':message_queue'; - if (this.messageQueueKey_ in localStorage) { - var messages = JSON.parse(localStorage[this.messageQueueKey_]); - if (messages.length) { - console.log( - this.loggingPrefix_(), '(re-)sending queued messages:', messages); - } - messages.forEach(function(message) { - // We don't use sendMessage because we need to preserve the first - // message's client_message_id, which is intentionally not exposed via - // the sendMessage API - this.sendRPC_( - 'sendMessage', message, - this.onMessageSent_.bind(this, message, null, null)); - }.bind(this)); - } else { - localStorage[this.messageQueueKey_] = JSON.stringify([]); - } - - var scriptUrls = [ - '/_ah/channel/jsapi', - ]; - this.numScriptsToLoad_ = scriptUrls.length; - scriptUrls.forEach(function(scriptUrl) { - var script = document.createElement('script'); - script.src = scriptUrl; - script.onload = this.onLoad_.bind(this); - document.body.appendChild(script); - }, this); -}; - - -/** - * Channel states - * @enum {number} - * @const - * @private - */ -Cosmopolite.prototype.ChannelState = { - // No channel open, no RPC pending - CLOSED: 1, - // No channel open, RPC pending - PENDING: 2, - // RPC complete, channel opening - OPENING: 3, - // Channel opened - OPEN: 3, -}; - - -/** - * Subscription states - * @enum {number} - * @const - * @private - */ -Cosmopolite.prototype.SubscriptionState = { - PENDING: 1, - ACTIVE: 2, -}; - - -/** - * Shutdown this instance. - * - * No callbacks will fire after this returns. - */ -Cosmopolite.prototype.shutdown = function() { - console.log(this.loggingPrefix_(), 'shutdown'); - this.shutdown_ = true; - if (this.socket_) { - this.socket_.close(); - } - if (this.messageHandler_) { - window.removeEventListener('message', this.messageHandler_); - } -}; - -/** - * Subscribe to a subject. - * - * Start receiving messages sent to this subject via the onMessage callback. - * - * @param {!*} subject Subject name or object - * @param {number=} messages Number of recent messages to request; 0 for none, -1 for all - * @param {number=} last_id ID of last message received; fetch all messages since - * @param {Array.=} keys Key names to ensure we receive at least 1 message defining - */ -Cosmopolite.prototype.subscribe = function(subject, messages, last_id, keys) { - return new Promise(function(resolve, reject) { - var canonicalSubject = this.canonicalSubject_(subject); - var subjectString = JSON.stringify(canonicalSubject); - if (!(subjectString in this.subscriptions_)) { - this.subscriptions_[subjectString] = { - 'messages': [], - 'keys': {}, - 'state': this.SubscriptionState.PENDING, - }; - } - - var args = { - 'subject': canonicalSubject, - }; - if (messages) { - args['messages'] = messages; - } - if (last_id != null) { - args['last_id'] = last_id; - } - if (keys != null) { - args['keys'] = keys; - } - - this.sendRPC_('subscribe', args, function(response) { - // unsubscribe may have been called since we sent the RPC. That's racy - // without waiting for the promise, but do our best - if (subjectString in this.subscriptions_) { - this.subscriptions_[subjectString].state = this.SubscriptionState.ACTIVE; - } - var result = response['result']; - if (result == 'ok') { - resolve(); - } else { - reject(); - } - }.bind(this)); - }.bind(this)); -}; - -/** - * Unsubscribe from a subject and destroy all listeners. - * - * Note that no reference counting is done, so a single call to unsubscribe() - * undoes multiple calls to subscribe(). - * - * @param {!string} subject Subject name, as passed to subscribe() - */ -Cosmopolite.prototype.unsubscribe = function(subject) { - return new Promise(function(resolve, reject) { - var canonicalSubject = this.canonicalSubject_(subject); - var subjectString = JSON.stringify(canonicalSubject); - delete this.subscriptions_[subjectString]; - var args = { - 'subject': canonicalSubject, - } - this.sendRPC_('unsubscribe', args, resolve); - }.bind(this)); -}; - -/** - * Post a message to the given subject, storing it and notifying all listeners. - * - * @param {!string} subject Subject name - * @param {!*} message Message string or object - * @param {string=} key Key name to associate this message with - */ -Cosmopolite.prototype.sendMessage = function(subject, message, key) { - return new Promise(function(resolve, reject) { - var args = { - 'subject': this.canonicalSubject_(subject), - 'message': JSON.stringify(message), - 'sender_message_id': this.uuid_(), - }; - if (key) { - args['key'] = key; - } - - // No message left behind. - var messageQueue = JSON.parse(localStorage[this.messageQueueKey_]); - messageQueue.push(args); - localStorage[this.messageQueueKey_] = JSON.stringify(messageQueue); - - this.sendRPC_( - 'sendMessage', args, - this.onMessageSent_.bind(this, args, resolve, reject)); - }.bind(this)); -}; - -/** - * Fetch all received messages for a subject - * - * @param {!string} subject Subject name - * @const - */ -Cosmopolite.prototype.getMessages = function(subject) { - var canonicalSubject = this.canonicalSubject_(subject); - var subjectString = JSON.stringify(canonicalSubject); - return this.subscriptions_[subjectString].messages; -}; - -/** - * Fetch the most recent message that defined a key - * - * @param {!string} subject Subject name - * @param {!string} key Key name - * @const - */ -Cosmopolite.prototype.getKeyMessage = function(subject, key) { - var canonicalSubject = this.canonicalSubject_(subject); - var subjectString = JSON.stringify(canonicalSubject); - return this.subscriptions_[subjectString].keys[key]; -}; - -/** - * Return a Promise for our profile ID. - */ -Cosmopolite.prototype.getProfile = function() { - return new Promise(function(resolve, reject) { - if (this.profile_) { - resolve(this.profile_); - } else { - this.profilePromises_.push(resolve); - } - }.bind(this)); -}; - - /** - * Return our current profile ID, if known. - * - * @return {?string} Profile ID. - * @const - */ -Cosmopolite.prototype.currentProfile = function() { - return this.profile_; -}; - -/** - * Generate a string identifying us to be included in log messages. - * - * @return {string} Log line prefix. - * @const - */ -Cosmopolite.prototype.loggingPrefix_ = function() { - return 'cosmopolite (' + this.namespace_ + '):'; -}; - -/** - * Generate a v4 UUID. - * - * @return {string} A universally-unique random value. - * @const - */ -Cosmopolite.prototype.uuid_ = function() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - var r = (Math.random() * 16) | 0; - if (c == 'x') { - return r.toString(16); - } else { - return (r & (0x03 | 0x08)).toString(16); - } - }); -}; - -/** - * Canonicalize a subject name or object - * - * @param {!*} subject A simple or complex representation of a subject - * @return {Object} A canonicalized object for RPCs - */ -Cosmopolite.prototype.canonicalSubject_ = function(subject) { - if (typeof(subject) == 'number') { - subject = subject.toString(); - } - if (typeof(subject) == 'string') { - subject = { - 'name': subject, - } - } - if (subject['readable_only_by'] === null) { - delete subject['readable_only_by']; - }; - if (subject['writable_only_by'] === null) { - delete subject['writable_only_by']; - }; - return subject; -}; - - -/** - * Callback when a script loads. - */ -Cosmopolite.prototype.onLoad_ = function() { - if (--this.numScriptsToLoad_ > 0) { - return; - } - if (this.shutdown_) { - // Shutdown during startup - return; - } - this.registerMessageHandlers_(); - this.createChannel_(); -}; - -/** - * Callback for a message from another browser window - * - * @param {!string} data Message contents - */ -Cosmopolite.prototype.onReceiveMessage_ = function(data) { - switch (data) { - case 'login_complete': - if (this.socket_) { - this.socket_.close(); - } - break; - case 'logout_complete': - localStorage.removeItem(this.namespace_ + ':client_id'); - localStorage.removeItem(this.namespace_ + ':google_user_id'); - if (this.socket_) { - this.socket_.close(); - } - break; - default: - console.log(this.loggingPrefix_(), 'unknown event type:', data); - break; - } -}; - -/** - * Register onReceiveMessage to receive callbacks - * - * Note that we share this bus with at least the channel code, so spurious - * messages are normal. - */ -Cosmopolite.prototype.registerMessageHandlers_ = function() { - this.messageHandler_ = function(e) { - if (e.origin != window.location.origin) { - // Probably talkgadget - return; - } - console.log(this.loggingPrefix_(), 'received browser message:', e.data); - this.onReceiveMessage_(e.data); - }.bind(this); - window.addEventListener('message', this.messageHandler_); -}; - -/** - * Callback for a sendMessage RPC ack by the server. - * - * @param {Object} message Message details. - * @param {function()=} resolve Promise resolution callback. - * @param {function()=} reject Promise rejection callback. - * @param {Object=} response Server RPC response. - */ -Cosmopolite.prototype.onMessageSent_ = function( - message, resolve, reject, response) { - // No message left behind. - var messageQueue = JSON.parse(localStorage[this.messageQueueKey_]); - messageQueue = messageQueue.filter(function(queuedMessage) { - return message['sender_message_id'] != queuedMessage['sender_message_id']; - }); - localStorage[this.messageQueueKey_] = JSON.stringify(messageQueue); - var result = response['result']; - if (result == 'ok' || result == 'duplicate_message') { - if (resolve) { - resolve(); - } - } else { - if (reject) { - reject(); - } - } -}; - -/** - * Send a single RPC to the server. - * - * See sendRPCs_() - * - * @param {!string} command Command name to call - * @param {!Object} args Arguments to pass to server - * @param {function(Object)=} onSuccess Success callback function - */ -Cosmopolite.prototype.sendRPC_ = function(command, args, onSuccess) { - var rpc = { - 'command': command, - 'arguments': args, - 'onSuccess': onSuccess, - }; - if (this.maySendRPC_()) { - this.sendRPCs_([rpc]); - } else { - // Queue instead of sending. - this.rpcQueue_.push(rpc); - } -}; - -/** - * Send one or more RPCs to the server. - * - * Wraps handling of authentication to the server, even in cases where we need - * to retry with more data. Also retries in cases of failure with exponential - * backoff. - * - * @param {!Array.<{command:string, arguments:Object, onSuccess:function(Object)}>} commands List of commands to execute - * @param {number=} delay Seconds waited before executing this call (for backoff) - */ -Cosmopolite.prototype.sendRPCs_ = function(commands, delay) { - if (this.shutdown_ || !commands.length) { - return; - } - var request = { - 'commands': [], - }; - commands.forEach(function(command) { - var request_command = { - 'command': command['command'], - }; - if ('arguments' in command) { - request_command['arguments'] = command['arguments']; - } - request.commands.push(request_command); - }); - if (this.namespace_ + ':client_id' in localStorage) { - request['client_id'] = localStorage[this.namespace_ + ':client_id']; - } - if (this.namespace_ + ':google_user_id' in localStorage) { - request['google_user_id'] = localStorage[this.namespace_ + ':google_user_id']; - } - - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - - var retryAfterDelay = function() { - var intDelay = - xhr.getResponseHeader('Retry-After') || - Math.min(32, Math.max(2, delay || 2)); - console.log( - this.loggingPrefix_(), - 'RPC failed; will retry in ' + intDelay + ' seconds'); - var retry = function() { - this.sendRPCs_(commands, Math.pow(intDelay, 2)); - }.bind(this); - window.setTimeout(retry, intDelay * 1000); - }.bind(this); - - xhr.addEventListener('load', function(e) { - if (xhr.status != 200) { - retryAfterDelay(); - return; - } - var data = xhr.response; - - if ('google_user_id' in data) { - localStorage[this.namespace_ + ':google_user_id'] = - data['google_user_id']; - } - if ('client_id' in data) { - localStorage[this.namespace_ + ':client_id'] = data['client_id']; - } - - if (data['status'] == 'retry') { - // Discard delay - this.sendRPCs_(commands); - return; - } - if (data['status'] != 'ok') { - console.log(this.loggingPrefix_(), - 'server returned unknown status:', data['status']); - // TODO(flamingcow): Refresh the page? Show an alert? - return; - } - - this.flushRPCQueue_(); - - // Handle events that were immediately available as if they came over the - // channel. Fire them before the message callbacks, so clients can use - // events like the subscribe promise fulfillment as a barrier for initial - // data. - data['events'].forEach(this.onServerEvent_, this); - - for (var i = 0; i < data['responses'].length; i++) { - if (commands[i]['onSuccess']) { - commands[i]['onSuccess'].bind(this)(data['responses'][i]); - } - } - }.bind(this)); - - xhr.addEventListener('error', retryAfterDelay); - xhr.open('POST', this.urlPrefix_ + '/api'); - xhr.send(JSON.stringify(request)); -}; - -/** - * Are we currently clear to put RPCs on the wire? - * - * @return {Boolean} Yes or no? - */ -Cosmopolite.prototype.maySendRPC_ = function() { - if (!(this.namespace_ + ':client_id' in localStorage)) { - return false; - } - - if (this.channelState_ != this.ChannelState.OPEN) { - return false; - } - - return true; -} - -/** - * Send queued RPCs - */ -Cosmopolite.prototype.flushRPCQueue_ = function() { - if (!this.maySendRPC_() || !this.rpcQueue_.length) { - return; - } - - this.sendRPCs_(this.rpcQueue_); - this.rpcQueue_ = []; -}; - -/** - * Resubscribe to subjects (i.e. after reconnection) - */ -Cosmopolite.prototype.resubscribe_ = function() { - var rpcs = []; - for (var subject in this.subscriptions_) { - var subscription = this.subscriptions_[subject]; - var canonicalSubject = JSON.parse(subject); - if (subscription.state != this.SubscriptionState.ACTIVE) { - continue; - } - var last_id = 0; - if (subscription.messages.length > 0) { - last_id = subscription.messages[subscription.messages.length - 1]['id']; - } - rpcs.push({ - 'command': 'subscribe', - 'arguments': { - 'subject': canonicalSubject, - 'last_id': last_id, - } - }); - } - this.sendRPCs_(rpcs); -}; - -/** - * Send RPC to create a server -> client channel - */ -Cosmopolite.prototype.createChannel_ = function() { - if (this.channelState_ == this.ChannelState.CLOSED) { - this.channelState_ = this.ChannelState.PENDING; - } else { - return; - } - - var rpcs = [ - { - 'command': 'createChannel', - 'onSuccess': this.onCreateChannel_, - }, - ]; - // sendRPCs instead of sendRPC so we don't queue. - this.sendRPCs_(rpcs); -}; - -/** - * Callback for channel creation on the server side - * - * @suppress {missingProperties} - * - * @param {!Object} data Server response including channel token - */ -Cosmopolite.prototype.onCreateChannel_ = function(data) { - if (this.shutdown_) { - return; - } - - if (this.channelState_ == this.ChannelState.PENDING) { - this.channelState_ = this.ChannelState.OPENING; - } else { - return; - } - - var channel = new goog.appengine.Channel(data['token']); - console.log(this.loggingPrefix_(), 'opening channel:', data['token']); - this.socket_ = channel.open({ - onopen: this.onSocketOpen_.bind(this), - onclose: this.onSocketClose_.bind(this), - onmessage: this.onSocketMessage_.bind(this), - onerror: this.onSocketError_.bind(this), - }); -}; - -/** - * Callback from channel library for successful open - */ -Cosmopolite.prototype.onSocketOpen_ = function() { - console.log(this.loggingPrefix_(), 'channel opened'); - - if (this.shutdown_ && this.socket_) { - this.socket_.close(); - }; - - if (this.channelState_ == this.ChannelState.OPENING) { - this.channelState_ = this.ChannelState.OPEN; - } else { - return; - } - - this.flushRPCQueue_(); - this.resubscribe_(); -}; - -/** - * Callback from channel library for closure; reopen. - */ -Cosmopolite.prototype.onSocketClose_ = function() { - console.log(this.loggingPrefix_(), 'channel closed'); - - if (this.shutdown_) { - return; - } - - if (this.channelState_ == this.ChannelState.OPEN) { - this.channelState_ = this.ChannelState.CLOSED; - } else { - return; - } - - this.createChannel_(); -}; - -/** - * Callback from channel library for message reception over channel - * - * @param {!Object} msg Message contents - */ -Cosmopolite.prototype.onSocketMessage_ = function(msg) { - this.onServerEvent_(JSON.parse(msg.data)); -}; - -/** - * Callback from channel library for error on channel - * - * @param {!string} msg Descriptive text - */ -Cosmopolite.prototype.onSocketError_ = function(msg) { - console.log(this.loggingPrefix_(), 'socket error:', msg); - if (this.socket_) { - this.socket_.close(); - } -}; - -/** - * Callback on receiving a 'login' event from the server - * - * @param {!Object} e Event object - */ -Cosmopolite.prototype.onLogin_ = function(e) { - if ('onLogin' in this.callbacks_) { - this.callbacks_['onLogin']( - e['google_user'], - this.urlPrefix_ + '/auth/logout'); - } -}; - -/** - * Callback on receiving a 'logout' event from the server - * - * @param {!Object} e Event object - */ -Cosmopolite.prototype.onLogout_ = function(e) { - if ('onLogout' in this.callbacks_) { - this.callbacks_['onLogout']( - this.urlPrefix_ + '/auth/login'); - } -}; - -/** - * Callback on receiving a 'message' event from the server - * - * @param {!Object} e Event object - */ -Cosmopolite.prototype.onMessage_ = function(e) { - var subjectString = JSON.stringify(e['subject']); - var subscription = this.subscriptions_[subjectString]; - if (!subscription) { - console.log( - this.loggingPrefix_(), - 'message from unrecognized subject:', e); - return; - } - var duplicate = subscription.messages.some(function(message) { - return message['id'] == e.id; - }); - if (duplicate) { - console.log(this.loggingPrefix_(), 'duplicate message:', e); - return; - } - e['message'] = JSON.parse(e['message']); - - // Reverse search for the position to insert this message, as iit will most - // likely be at the end. - var insertAfter; - for (var insertAfter = subscription.messages.length - 1; - insertAfter >= 0; insertAfter--) { - var message = subscription.messages[insertAfter]; - if (message['id'] < e['id']) { - break; - } - } - subscription.messages.splice(insertAfter + 1, 0, e); - - if (e['key']) { - subscription.keys[e['key']] = e; - } - if ('onMessage' in this.callbacks_) { - this.callbacks_['onMessage'](e); - } -}; - -/** - * Callback for Cosmopolite event (received via channel or pseudo-channel) - * - * @param {!Object} e Deserialized event object - */ -Cosmopolite.prototype.onServerEvent_ = function(e) { - if (this.shutdown_) { - return; - } - if (e['profile']) { - this.profile_ = e['profile']; - this.profilePromises_.forEach(function(resolve) { - resolve(this.profile_); - }.bind(this)); - this.profilePromises_ = []; - } - switch (e['event_type']) { - case 'login': - this.onLogin_(e); - break; - case 'logout': - this.onLogout_(e); - break; - case 'message': - this.onMessage_(e); - break; - default: - // Client out of date? Force refresh? - console.log(this.loggingPrefix_(), 'unknown channel event:', e); - break; - } -}; - -/* Exported values */ -window.Cosmopolite = Cosmopolite; diff --git a/static/debug-unity.html b/static/debug-unity.html deleted file mode 100644 index 4206edd..0000000 --- a/static/debug-unity.html +++ /dev/null @@ -1,79 +0,0 @@ - - - Cosmopolite Unity Demo - - - - - -
- - - diff --git a/static/debug.html b/static/debug.html deleted file mode 100644 index 465600d..0000000 --- a/static/debug.html +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - -
Google user:
- -
- Subject: - -
-
- - -
- -
- -
- Message: -
-
- Key: -
-
- -
- -
- -
- -
- -
- - - - diff --git a/static/debug.unity3d b/static/debug.unity3d deleted file mode 100644 index 63fecbf..0000000 Binary files a/static/debug.unity3d and /dev/null differ diff --git a/static/login_complete.html b/static/login_complete.html deleted file mode 100644 index 97c3674..0000000 --- a/static/login_complete.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/static/logout_complete.html b/static/logout_complete.html deleted file mode 100644 index 186a292..0000000 --- a/static/logout_complete.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/static/test.html b/static/test.html deleted file mode 100644 index db9b85f..0000000 --- a/static/test.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - Cosmopolite tests - - - -
-
- - - - - diff --git a/static/test.js b/static/test.js deleted file mode 100644 index dddfe7f..0000000 --- a/static/test.js +++ /dev/null @@ -1,508 +0,0 @@ -/** - * @license - * Copyright 2014, Ian Gulliver - * - * 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. - */ - -/* -A quick note on testing philosophy: - -These tests cover both the client (JavaScript) and the server (Python), as -well as the server's interaction with appengine (via dev_appserver or a -deployed instance). There is intentionally no mock server. The client and -server interactions are complex and tests should be structured to verify them, -not to verify the behavior of a simulation. -*/ - -/* -These tests break if you turn on global pollution detection because of at -least: - -* goog: goog.appengine.Channel seems to always be global. -* closure_lm_*: The Channel code has a bug that puts this in globals. -*/ - -var randstring = function() { - var ret = []; - for (var i = 0; i < 16; i++) { - var ran = (Math.random() * 16) | 0; - ret.push(ran.toString(16)); - } - return ret.join(''); -}; - -var logout = function(callback) { - var innerCallback = function(e) { - window.removeEventListener('message', innerCallback); - if (e.origin != window.location.origin || - e.data != 'logout_complete') { - return; - } - if (callback) { - callback(); - } - }; - - window.addEventListener('message', innerCallback); - window.open('/cosmopolite/auth/logout'); -}; - -QUnit.testStart(localStorage.clear.bind(localStorage)); -QUnit.testDone(localStorage.clear.bind(localStorage)); - -module('All platforms'); - -test('Construct/shutdown', function() { - expect(2); - var cosmo = new Cosmopolite({}, null, randstring()); - ok(true, 'new Cosmopolite() succeeds'); - cosmo.shutdown(); - ok(true, 'shutdown() succeeds'); -}); - -asyncTest('onLogout fires', function() { - expect(1); - - logout(function() { - var callbacks = { - 'onLogout': function(login_url) { - ok(true, 'onLogout fired'); - cosmo.shutdown(); - start(); - } - }; - var cosmo = new Cosmopolite(callbacks, null, randstring()); - }); -}); - -asyncTest('Message round trip', function() { - expect(2); - - var subject = randstring(); - var message = randstring(); - - var callbacks = { - 'onMessage': function(e) { - equal(e['subject']['name'], subject, 'subject matches'); - equal(e['message'], message, 'message matches'); - cosmo.shutdown(); - start(); - }, - }; - - var cosmo = new Cosmopolite(callbacks, null, randstring()); - cosmo.sendMessage(subject, message); - cosmo.subscribe(subject, -1); -}); - -asyncTest('Overwrite key', function() { - expect(8); - - var subject = randstring(); - var message1 = randstring(); - var message2 = randstring(); - var key = randstring(); - - var messages = 0; - - var callbacks = { - 'onMessage': function(e) { - messages++; - equal(e['subject']['name'], subject, 'subject matches'); - equal(e['key'], key, 'key matches'); - if (messages == 1) { - equal(e['message'], message1, 'message #1 matches'); - equal(cosmo.getKeyMessage(subject, key)['message'], message1, 'message #1 matches by key') - cosmo.sendMessage(subject, message2, key); - return; - } - equal(e['message'], message2, 'message #2 matches'); - equal(cosmo.getKeyMessage(subject, key)['message'], message2, 'message #2 matches by key') - cosmo.shutdown(); - start(); - }, - }; - - var cosmo = new Cosmopolite(callbacks, null, randstring()); - cosmo.subscribe(subject, -1); - cosmo.sendMessage(subject, message1, key); -}); - -asyncTest('Complex object', function() { - expect(2); - - var subject = randstring(); - var message = { - 'foo': 'bar', - 5: 'zig', - 'zag': [16, 22, 59, 76], - 'boo': { - 'nested': 'object', - 10: 100, - }, - 'unicode': '☠☣☃𠜎', - }; - - var callbacks = { - 'onMessage': function(e) { - equal(e['subject']['name'], subject, 'subject matches'); - deepEqual(e['message'], message, 'message matches'); - cosmo.shutdown(); - start(); - }, - }; - - var cosmo = new Cosmopolite(callbacks, null, randstring()); - cosmo.sendMessage(subject, message); - cosmo.subscribe(subject, -1); -}); - -asyncTest('sendMessage Promise', function() { - expect(1); - - var subject = randstring(); - var message = randstring(); - - var cosmo = new Cosmopolite({}, null, randstring()); - cosmo.sendMessage(subject, message).then(function() { - ok(true, 'sendMessage Promise fulfilled'); - cosmo.shutdown(); - start(); - }); -}); - -asyncTest('subscribe/unsubscribe Promise', function() { - expect(2); - - var subject = randstring(); - var message = randstring(); - - var cosmo = new Cosmopolite({}, null, randstring()); - cosmo.subscribe(subject).then(function() { - ok(true, 'subscribe Promise fulfilled'); - cosmo.unsubscribe(subject).then(function() { - ok(true, 'unsubscribe Promise fulfilled'); - cosmo.shutdown(); - start(); - }); - }); -}); - -asyncTest('Duplicate message suppression', function() { - expect(3); - - var subject = randstring(); - var key = randstring(); - var message1 = randstring(); - var message2 = randstring(); - - var callbacks = { - 'onMessage': function (msg) { - equal(msg['subject']['name'], subject, 'subject matches'); - equal(msg['key'], key, 'key matches'); - equal(msg['message'], message1, 'message matches'); - cosmo.shutdown(); - start(); - }, - }; - - var cosmo = new Cosmopolite(callbacks, null, randstring()); - // Break cosmo's UUID generator so that it generates duplicate values. - cosmo.uuid_ = function() { - return '4'; - // chosen by fair dice roll. - // guaranteed to be random. - }; - cosmo.sendMessage(subject, message1, key).then(function() { - cosmo.sendMessage(subject, message2, key).then(function() { - cosmo.subscribe(subject, 0, null, [key]); - }); - }); -}); - -asyncTest('Message persistence', function() { - expect(2); - - var subject = randstring(); - var message = randstring(); - var namespace = randstring(); - - // Send a message and shut down too fast for it to hit the wire. - var cosmo1 = new Cosmopolite({}, null, namespace); - cosmo1.sendMessage(subject, message); - cosmo1.shutdown(); - - var callbacks = { - 'onMessage': function(msg) { - equal(msg['subject']['name'], subject, 'subject matches'); - equal(msg['message'], message, 'message matches'); - cosmo2.shutdown(); - start(); - }, - }; - - var cosmo2 = new Cosmopolite(callbacks, null, namespace); - cosmo2.subscribe(subject, -1); - // Should pick up the message from the persistent queue. -}); - -test('getMessages/subscribe', function() { - expect(2); - - var subject = randstring(); - - var cosmo = new Cosmopolite({}, null, randstring()); - throws( - cosmo.getMessages.bind(undefined, subject), - 'getMessages before subscribe fails'); - cosmo.subscribe(subject); - // Verify that we can call getMessages immediately after subscribe - cosmo.getMessages(subject); - ok(true, 'getMessages after subscribe succeeds'); - - cosmo.shutdown(); -}); - -asyncTest('subscribe barrier', function() { - expect(3); - - var subject = randstring(); - var message = randstring(); - - var cosmo = new Cosmopolite({}, null, randstring()); - - cosmo.sendMessage(subject, message).then(function() { - cosmo.subscribe(subject, -1).then(function() { - // We are validating that the message event generated by the subscribe - // call has already been processed by the time this promise fires - equal(cosmo.getMessages(subject).length, 1, 'one message'); - equal(cosmo.getMessages(subject)[0]['subject']['name'], subject, 'subject matches'); - equal(cosmo.getMessages(subject)[0]['message'], message, 'message matches'); - cosmo.shutdown(); - start(); - }); - }); -}); - -asyncTest('resubscribe', function() { - expect(4); - - var subject = randstring(); - var message = randstring(); - - var cosmo = new Cosmopolite({}, null, randstring()); - - cosmo.sendMessage(subject, message).then(function() { - cosmo.subscribe(subject).then(function() { - equal(cosmo.getMessages(subject).length, 0, 'zero messages'); - cosmo.subscribe(subject, -1).then(function() { - var messages = cosmo.getMessages(subject); - equal(messages.length, 1, 'one message'); - equal(messages[0]['subject']['name'], subject, 'subject matches'); - equal(messages[0]['message'], message, 'message matches'); - cosmo.shutdown(); - start(); - }); - }); - }); -}); - -asyncTest('Message ordering', function() { - expect(5); - - var subject = randstring(); - var messages = [ 'A', 'B', 'C', 'D', 'E', 'F' ]; - var keys = [ null, 'X', 'X', null, null, null ]; - - var cosmo = new Cosmopolite({}, null, randstring()); - - var sendNextMessage = function() { - if (messages.length) { - cosmo.sendMessage(subject, messages.shift(), keys.shift()).then(sendNextMessage); - } else { - cosmo.subscribe(subject, 1).then(function() { - cosmo.subscribe(subject, 0, null, ['X']).then(function() { - cosmo.subscribe(subject, 2).then(function() { - var fetched = cosmo.getMessages(subject); - equal(fetched.length, 3, 'three messages'); - equal(fetched[0]['message'], 'C', 'message 0: C matches'); - equal(fetched[1]['message'], 'E', 'message 1: E matches'); - equal(fetched[2]['message'], 'F', 'message 2: F matches'); - equal(cosmo.getKeyMessage(subject, 'X')['message'], 'C', 'key X matches'); - cosmo.shutdown(); - start(); - }); - }); - }); - } - }; - - sendNextMessage(); -}); - -asyncTest('Reconnect channel', function() { - expect(2); - - var subject = randstring(); - var message = randstring(); - - var callbacks = { - 'onMessage': function(msg) { - equal(msg['subject']['name'], subject, 'subject matches'); - equal(msg['message'], message, 'message matches'); - cosmo.shutdown(); - start(); - }, - }; - - var cosmo = new Cosmopolite(callbacks, null, randstring()); - cosmo.subscribe(subject, 0).then(function() { - // Reach inside to forcefully close the socket - cosmo.socket_.close(); - cosmo.sendMessage(subject, message); - }); -}); - -asyncTest('subscribe ACL', function() { - expect(2); - - var subject = randstring(); - - logout(function() { - var tempCosmo = new Cosmopolite({}, null, randstring()); - tempCosmo.getProfile().then(function(tempProfile) { - tempCosmo.shutdown(); - - var cosmo = new Cosmopolite({}, null, randstring()); - cosmo.getProfile().then(function(profile) { - cosmo.subscribe({ - 'name': subject, - 'readable_only_by': profile, - }).then(function() { - ok(true, 'correct ACL succeeds'); - - cosmo.subscribe({ - 'name': subject, - 'readable_only_by': tempProfile, - }).then(null, function() { - ok(true, 'bad ACL fails'); - cosmo.shutdown(); - start(); - }); - - }); - }); - }); - }); -}); - -asyncTest('sendMessage ACL', function() { - expect(2); - - var subject = randstring(); - var message = randstring(); - - logout(function() { - var tempCosmo = new Cosmopolite({}, null, randstring()); - tempCosmo.getProfile().then(function(tempProfile) { - tempCosmo.shutdown(); - - var cosmo = new Cosmopolite({}, null, randstring()); - cosmo.getProfile().then(function(profile) { - cosmo.sendMessage({ - 'name': subject, - 'writable_only_by': profile, - }, message).then(function() { - ok(true, 'correct ACL succeeds'); - - cosmo.sendMessage({ - 'name': subject, - 'writable_only_by': tempProfile, - }, message).then(null, function() { - ok(true, 'bad ACL fails'); - cosmo.shutdown(); - start(); - }); - - }); - }); - }); - }); -}); - - -module('dev_appserver only'); - -asyncTest('Login', function() { - expect(3); - - var anonymousProfile; - - logout(function() { - var callbacks = { - 'onLogout': function(login_url) { - ok(true, 'onLogout fired'); - anonymousProfile = cosmo.currentProfile(); - // Entirely magic URL that sets the login cookie and redirects. - window.open('/_ah/login?email=test%40example.com&action=Login&continue=/cosmopolite/static/login_complete.html'); - }, - 'onLogin': function(login_url) { - ok(true, 'onLogin fired'); - notEqual(anonymousProfile, cosmo.currentProfile(), 'profile changed'); - cosmo.shutdown(); - logout(); - start(); - }, - }; - var cosmo = new Cosmopolite(callbacks, null, randstring()); - }); -}); - -asyncTest('Profile merge', function() { - expect(6); - - var subject = randstring(); - var message = randstring(); - - var messages = 0; - - logout(function() { - var callbacks = { - 'onMessage': function(msg) { - messages++; - equal(msg['subject']['name'], subject, - 'message #' + messages + ': subject matches'); - equal(msg['message'], message, - 'message #' + messages + ': message matches'); - equal(msg['sender'], cosmo.currentProfile(), - 'message #' + messages + ': profile matches'); - if (messages == 1) { - cosmo.unsubscribe(subject); - // Entirely magic URL that sets the login cookie and redirects. - window.open('/_ah/login?email=test%40example.com&action=Login&continue=/cosmopolite/static/login_complete.html'); - } - if (messages == 2) { - cosmo.shutdown(); - start(); - } - }, - 'onLogin': function(logout_url) { - cosmo.subscribe(subject, -1); - }, - }; - var cosmo = new Cosmopolite(callbacks, null, randstring()); - cosmo.sendMessage(subject, message); - cosmo.subscribe(subject, -1); - }); -}); diff --git a/static/verify.sh b/static/verify.sh deleted file mode 100755 index 7c85a2a..0000000 --- a/static/verify.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh - -curl \ - --silent \ - --data compilation_level=ADVANCED_OPTIMIZATIONS \ - --data output_format=json \ - --data output_info=errors \ - --data output_info=warnings \ - --data language=ECMASCRIPT5 \ - --data warning_level=verbose \ - --data externs_url=https://closure-compiler.googlecode.com/git/contrib/externs/jquery-1.8.js \ - --data-urlencode "js_code@cosmopolite.js" \ - http://closure-compiler.appspot.com/compile - -echo