Skip to content
Snippets Groups Projects
websocket_messaging.py 8.95 KiB
Newer Older
Nils G.'s avatar
Nils G. committed
# -*- coding: utf-8 -*-
#!/usr/bin/env python

"""
This file is part of the web2py Web Framework
Copyrighted by Massimo Di Pierro <mdip...@cs.depaul.edu>
License: LGPLv3 (http://www.gnu.org/licenses/lgpl.html)

Attention: Requires Chrome or Safari. For IE of Firefox you need https://github.com/gimite/web-socket-js

1) install tornado (requires Tornado 3.0 or later)

   easy_install tornado

2) start this app:

   python gluon/contrib/websocket_messaging.py -k mykey -p 8888

3) from any web2py app you can post messages with

   from gluon.contrib.websocket_messaging import websocket_send
   websocket_send('http://127.0.0.1:8888', 'Hello World', 'mykey', 'mygroup')

4) from any template you can receive them with

   <script>
   $(document).ready(function(){
      if(!$.web2py.web2py_websocket('ws://127.0.0.1:8888/realtime/mygroup', function(e){alert(e.data)}))

         alert("html5 websocket not supported by your browser, try Google Chrome");
   });
   </script>

When the server posts a message, all clients connected to the page will popup an alert message
Or if you want to send json messages and store evaluated json in a var called data:

   <script>
   $(document).ready(function(){
      var data;
      $.web2py.web2py_websocket('ws://127.0.0.1:8888/realtime/mygroup', function(e){data=eval('('+e.data+')')});
   });
   </script>

- All communications between web2py and websocket_messaging will be digitally signed with hmac.
- All validation is handled on the web2py side and there is no need to modify websocket_messaging.py
- Multiple web2py instances can talk with one or more websocket_messaging servers.
- "ws://127.0.0.1:8888/realtime/" must be contain the IP of the websocket_messaging server.
- Via group='mygroup' name you can support multiple groups of clients (think of many chat-rooms)


Here is a complete sample web2py action:

    def index():
        form=LOAD('default', 'ajax_form', ajax=True)
        script=SCRIPT('''
            jQuery(document).ready(function(){
              var callback=function(e){alert(e.data)};
              if(!$.web2py.web2py_websocket('ws://127.0.0.1:8888/realtime/mygroup', callback))

                alert("html5 websocket not supported by your browser, try Google Chrome");
            });
        ''')
        return dict(form=form, script=script)

    def ajax_form():
        form=SQLFORM.factory(Field('message'))
        if form.accepts(request,session):
            from gluon.contrib.websocket_messaging import websocket_send
            websocket_send(
                'http://127.0.0.1:8888', form.vars.message, 'mykey', 'mygroup')
        return form

https is possible too using 'https://127.0.0.1:8888' instead of 'http://127.0.0.1:8888', but need to
be started with

   python gluon/contrib/websocket_messaging.py -k mykey -p 8888 -s keyfile.pem -c certfile.pem

for secure websocket do:

   web2py_websocket('wss://127.0.0.1:8888/realtime/mygroup',callback)

Acknowledgements:
Tornado code inspired by http://thomas.pelletier.im/2010/08/websocket-tornado-redis/

"""
from __future__ import print_function
import tornado.httpserver
import tornado.websocket
import tornado.ioloop
import tornado.web
import hmac
import sys
import optparse
import time
import sys
import gluon.utils
import hashlib
from gluon._compat import to_native, to_bytes, urlencode, urlopen

listeners, names, tokens = {}, {}, {}

def websocket_send(url, message, hmac_key=None, group='default'):
    sig = hmac_key and hmac.new(to_bytes(hmac_key), to_bytes(message), digestmod=hashlib.md5).hexdigest() or ''
    params = urlencode(
        {'message': message, 'signature': sig, 'group': group})
    f = urlopen(url, to_bytes(params))
    data = f.read()
    f.close()
    return data


class PostHandler(tornado.web.RequestHandler):
    """
    only authorized parties can post messages
    """
    def post(self):
        if hmac_key and not 'signature' in self.request.arguments:
            self.send_error(401)
        if 'message' in self.request.arguments:
            message = self.request.arguments['message'][0].decode(encoding='UTF-8')
            group = self.request.arguments.get('group', ['default'])[0].decode(encoding='UTF-8')
            print('%s:MESSAGE to %s:%s' % (time.time(), group, message))
            if hmac_key:
                signature = self.request.arguments['signature'][0]
                actual_signature = hmac.new(to_bytes(hmac_key), to_bytes(message), digestmod=hashlib.md5).hexdigest()
                if not gluon.utils.compare(to_native(signature), actual_signature):
                    self.send_error(401)
            for client in listeners.get(group, []):
                client.write_message(message)


class TokenHandler(tornado.web.RequestHandler):
    """
    if running with -t post a token to allow a client to join using the token
    the message here is the token (any uuid)
    allows only authorized parties to joins, for example, a chat
    """
    def post(self):
        if hmac_key and not 'message' in self.request.arguments:
            self.send_error(401)
        if 'message' in self.request.arguments:
            message = self.request.arguments['message'][0]
            if hmac_key:
                signature = self.request.arguments['signature'][0]
                actual_signature = hmac.new(to_bytes(hmac_key), to_bytes(message), digestmod=hashlib.md5).hexdigest()
                if not gluon.utils.compare(to_native(signature), actual_signature):
                    self.send_error(401)
            tokens[message] = None


class DistributeHandler(tornado.websocket.WebSocketHandler):

    def check_origin(self, origin):
        return True

    def open(self, params):
        group, token, name = params.split('/') + [None, None]
        self.group = group or 'default'
        self.token = token or 'none'
        self.name = name or 'anonymous'
        # only authorized parties can join
        if DistributeHandler.tokens:
            if not self.token in tokens or not token[self.token] is None:
                self.close()
            else:
                tokens[self.token] = self
        if not self.group in listeners:
            listeners[self.group] = []
        # notify clients that a member has joined the groups
        for client in listeners.get(self.group, []):
            client.write_message('+' + self.name)
        listeners[self.group].append(self)
        names[self] = self.name
        print('%s:CONNECT to %s' % (time.time(), self.group))

    def on_message(self, message):
        pass

    def on_close(self):
        if self.group in listeners:
            listeners[self.group].remove(self)
        del names[self]
        # notify clients that a member has left the groups
        for client in listeners.get(self.group, []):
            client.write_message('-' + self.name)
        print('%s:DISCONNECT from %s' % (time.time(), self.group))

# if your webserver is different from tornado server uncomment this
# or override using something more restrictive:
# http://tornado.readthedocs.org/en/latest/websocket.html#tornado.websocket.WebSocketHandler.check_origin
# def check_origin(self, origin):
#    return True

if __name__ == "__main__":
    usage = __doc__
    version = ""
    parser = optparse.OptionParser(usage, None, optparse.Option, version)
    parser.add_option('-p',
                      '--port',
                      default='8888',
                      dest='port',
                      help='socket')
    parser.add_option('-l',
                      '--listen',
                      default='0.0.0.0',
                      dest='address',
                      help='listener address')
    parser.add_option('-k',
                      '--hmac_key',
                      default='',
                      dest='hmac_key',
                      help='hmac_key')
    parser.add_option('-t',
                      '--tokens',
                      action='store_true',
                      default=False,
                      dest='tokens',
                      help='require tockens to join')
    parser.add_option('-s',
                      '--sslkey',
                      default=False,
                      dest='keyfile',
                      help='require ssl keyfile full path')
    parser.add_option('-c',
                      '--sslcert',
                      default=False,
                      dest='certfile',
                      help='require ssl certfile full path')
    (options, args) = parser.parse_args()
    hmac_key = options.hmac_key
    DistributeHandler.tokens = options.tokens
    urls = [
        (r'/', PostHandler),
        (r'/token', TokenHandler),
        (r'/realtime/(.*)', DistributeHandler)]
    application = tornado.web.Application(urls, auto_reload=True)
    if options.keyfile and options.certfile:
        ssl_options = dict(certfile=options.certfile, keyfile=options.keyfile)
    else:
        ssl_options = None
    http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options)
    http_server.listen(int(options.port), address=options.address)
    tornado.ioloop.IOLoop.instance().start()