├── .gitignore ├── LICENSE.txt ├── README.rst ├── Vagrantfile ├── nginx └── shared_canvas_channels.conf ├── requirements.txt ├── shared_canvas_channels ├── assets │ ├── css │ │ └── shared_canvas_channels.css │ └── js │ │ ├── shared_canvas_channels.js │ │ ├── sketch.js │ │ └── sketch.min.js ├── auth.json ├── manage.py ├── shared_canvas │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── consumers.py │ ├── jwt_decorators.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── shared_canvas_channels │ ├── __init__.py │ ├── asgi.py │ ├── routing.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── templates │ ├── base.html │ └── home.html └── supervisor ├── shared_canvas_channels_daphne.conf └── shared_canvas_channels_runworker.conf /.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | .idea 3 | __pycache__ 4 | *.pyc 5 | shared_canvas_channels_venv/ 6 | .vagrant 7 | static/ 8 | components/ 9 | *.log 10 | *.pid 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Agustin Barto 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | shared_canvas_channels 3 | ====================== 4 | 5 | This project was coded to test how to use `JSON WebTokens `_ (JWT) authentication with a `Django channels `_ (with the `Redis `_ layer) site. The motivation came from the fact that sometimes we're not allowed to use cookies or sessions on our projects. Luckily, there are several ways we can overcome this restriction, like token based authentication. JWT in particular is one possible solution that is pretty simple to deploy and maintain. 6 | 7 | I'm using a `forked version `_ of `django-jwt-auth `_ that provides JWT authentication and allows for token refresh. 8 | 9 | 2016.03.29 Update 10 | ================= 11 | 12 | I've created the `offload-saving `_ branch to demonstrate how to use channels to offload work to separate consumers and workers. 13 | 14 | The application 15 | =============== 16 | 17 | The sample application is a web based shared whiteboard using WebSockets for communication. The user provides her credentials that are used to request a JWT token which in turn used to open the WebSocket and to authenticate each individual message sent through it. 18 | 19 | The decorators 20 | ============== 21 | 22 | The authentication is provided by two decorators: @jwt_request_parameter and @jwt_message_text_field. 23 | 24 | @jwt_request_parameter 25 | ---------------------- 26 | 27 | This decorator is used on HTTP channels that need to be authenticated using a request parameter. On the sample application we use it to control the access to the ``websocket.connect`` channel. When the user opens up the WebSocket, we verify that the "token" request parameter is present and it is still valid. If all the checks pass, the original consumer is invoked. Otherwise, the reply channel is closed: 28 | 29 | :: 30 | 31 | def jwt_request_parameter(func): 32 | """ 33 | Checks the presence of a "token" request parameter and tries to 34 | authenticate the user based on its content. 35 | """ 36 | @wraps(func) 37 | def inner(message, *args, **kwargs): 38 | # Taken from channels.session.http_session 39 | try: 40 | if "method" not in message.content: 41 | message.content['method'] = "FAKE" 42 | request = AsgiRequest(message) 43 | except Exception as e: 44 | raise ValueError("Cannot parse HTTP message - are you sure this is a HTTP consumer? %s" % e) 45 | 46 | token = request.GET.get("token", None) 47 | if token is None: 48 | _close_reply_channel(message) 49 | raise ValueError("Missing token request parameter. Closing channel.") 50 | 51 | user = authenticate(token) 52 | 53 | message.token = token 54 | message.user = user 55 | 56 | return func(message, *args, **kwargs) 57 | return inner 58 | 59 | @jwt_message_text_field 60 | ----------------------- 61 | 62 | This decorator is used to authenticate each message received through the ``websocket.receive`` channel. We check that the ``text`` field in the message payload contains a ``token`` field and that its contents are a valid JWT token. If the token is valid, we invoke the wrapped consumer, otherwise we close the reply channel. 63 | 64 | :: 65 | 66 | def jwt_message_text_field(func): 67 | """ 68 | Checks the presence of a "token" field on the message's text field and 69 | tries to authenticate the user based on its content. 70 | """ 71 | @wraps(func) 72 | def inner(message, *args, **kwargs): 73 | message_text = message.get('text', None) 74 | if message_text is None: 75 | _close_reply_channel(message) 76 | raise ValueError("Missing text field. Closing channel.") 77 | 78 | try: 79 | message_text_json = loads(message_text) 80 | except ValueError: 81 | _close_reply_channel(message) 82 | raise 83 | 84 | token = message_text_json.pop('token', None) 85 | if token is None: 86 | _close_reply_channel(message) 87 | raise ValueError("Missing token field. Closing channel.") 88 | 89 | user = authenticate(token) 90 | 91 | message.token = token 92 | message.user = user 93 | message.text = dumps(message_text_json) 94 | 95 | return func(message, *args, **kwargs) 96 | return inner 97 | 98 | Conclusion 99 | ========== 100 | 101 | Although the authentication methods demonstrated in this project are specific to JWT, the same basic principles can be applied to other forms of authentication. 102 | 103 | Vagrant 104 | ======= 105 | 106 | A `Vagrant `_ configuration file is included if you want to test the project. 107 | 108 | Feedback 109 | ======== 110 | 111 | Comments, issues and pull requests are welcome. Don't hesitate to contact me if you something a could have done better. 112 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure(2) do |config| 5 | config.vm.box = "ubuntu/trusty64" 6 | 7 | config.vm.define "shared-canvas-channels-vm" do |vm_define| 8 | end 9 | 10 | config.vm.hostname = "shared-canvas-channels.local" 11 | 12 | config.vm.network "forwarded_port", guest: 80, host: 8000 13 | config.vm.network "forwarded_port", guest: 8000, host: 8001 14 | 15 | config.vm.synced_folder ".", "/home/vagrant/shared_canvas_channels/" 16 | 17 | config.vm.provider "virtualbox" do |vb| 18 | vb.memory = "1024" 19 | vb.cpus = 2 20 | vb.name = "shared-canvas-channels-vm" 21 | end 22 | 23 | config.vm.provision "shell", inline: <<-SHELL 24 | apt-get update 25 | apt-get install -y supervisor nginx git build-essential python python-dev python-virtualenv postgresql postgresql-server-dev-all redis-server 26 | 27 | sudo -u postgres psql --command="CREATE USER shared_canvas_channels WITH PASSWORD 'shared_canvas_channels';" 28 | sudo -u postgres psql --command="CREATE DATABASE shared_canvas_channels WITH OWNER shared_canvas_channels;" 29 | sudo -u postgres psql --command="GRANT ALL PRIVILEGES ON DATABASE shared_canvas_channels TO shared_canvas_channels;" 30 | SHELL 31 | 32 | config.vm.provision "shell", privileged: false, inline: <<-SHELL 33 | virtualenv --no-pip shared_canvas_channels_venv 34 | source shared_canvas_channels_venv/bin/activate 35 | curl --silent --show-error --retry 5 https://bootstrap.pypa.io/get-pip.py | python 36 | 37 | pip install -r shared_canvas_channels/requirements.txt 38 | 39 | cd shared_canvas_channels/shared_canvas_channels/ 40 | 41 | nodeenv --prebuilt --python-virtualenv 42 | npm install --global bower 43 | 44 | python manage.py migrate 45 | python manage.py bower_install 46 | python manage.py loaddata auth.json 47 | python manage.py collectstatic --noinput 48 | SHELL 49 | 50 | config.vm.provision "shell", inline: <<-SHELL 51 | echo ' 52 | upstream shared_canvas_channels_upstream { 53 | server 127.0.0.1:8000 fail_timeout=0; 54 | } 55 | 56 | map $http_upgrade $connection_upgrade { 57 | default upgrade; 58 | "" close; 59 | } 60 | 61 | server { 62 | listen 80; 63 | server_name localhost; 64 | 65 | client_max_body_size 4G; 66 | 67 | access_log /home/vagrant/shared_canvas_channels/nginx_access.log; 68 | error_log /home/vagrant/shared_canvas_channels/nginx_error.log; 69 | 70 | location /static/ { 71 | alias /home/vagrant/shared_canvas_channels/shared_canvas_channels/static/; 72 | } 73 | 74 | location / { 75 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 76 | proxy_set_header Host $http_host; 77 | proxy_redirect off; 78 | if (!-f $request_filename) { 79 | proxy_pass http://shared_canvas_channels_upstream; 80 | break; 81 | } 82 | } 83 | 84 | location /events { 85 | proxy_pass http://shared_canvas_channels_upstream; 86 | proxy_set_header Host $http_host; 87 | proxy_redirect off; 88 | proxy_buffering off; 89 | proxy_http_version 1.1; 90 | proxy_set_header Upgrade $http_upgrade; 91 | proxy_set_header Connection "upgrade"; 92 | } 93 | } 94 | ' > /etc/nginx/conf.d/shared_canvas_channels.conf 95 | 96 | /usr/sbin/service nginx restart 97 | 98 | echo ' 99 | [program:shared_canvas_channels_daphne] 100 | user = vagrant 101 | directory = /home/vagrant/shared_canvas_channels/shared_canvas_channels 102 | command = /home/vagrant/shared_canvas_channels_venv/bin/daphne shared_canvas_channels.asgi:channel_layer 103 | autostart = true 104 | autorestart = true 105 | stderr_logfile = /home/vagrant/shared_canvas_channels/daphne_stderr.log 106 | stdout_logfile = /home/vagrant/shared_canvas_channels/daphne_stdout.log 107 | stopsignal = INT 108 | ' > /etc/supervisor/conf.d/shared_canvas_channels_daphne.conf 109 | 110 | echo ' 111 | [program:shared_canvas_channels_runworker] 112 | process_name = shared_canvas_channels_runworker-%(process_num)s 113 | user = vagrant 114 | directory = /home/vagrant/shared_canvas_channels/shared_canvas_channels 115 | command = /home/vagrant/shared_canvas_channels_venv/bin/python /home/vagrant/shared_canvas_channels/shared_canvas_channels/manage.py runworker 116 | numprocs = 6 117 | autostart = true 118 | autorestart = true 119 | stderr_logfile = /home/vagrant/shared_canvas_channels/runworker_stderr.log 120 | stdout_logfile = /home/vagrant/shared_canvas_channels/runworker_stdout.log 121 | stopsignal = INT 122 | ' > /etc/supervisor/conf.d/shared_canvas_channels_runworker.conf 123 | 124 | /usr/bin/supervisorctl reload 125 | SHELL 126 | end 127 | -------------------------------------------------------------------------------- /nginx/shared_canvas_channels.conf: -------------------------------------------------------------------------------- 1 | upstream shared_canvas_channels_upstream { 2 | # fail_timeout=0 means we always retry an upstream even if it failed 3 | # to return a good HTTP response (in case the Unicorn master nukes a 4 | # single worker for timing out). 5 | 6 | server 127.0.0.1:8000 fail_timeout=0; 7 | } 8 | 9 | map $http_upgrade $connection_upgrade { 10 | default upgrade; 11 | '' close; 12 | } 13 | 14 | server { 15 | listen 80; 16 | server_name localhost; 17 | 18 | client_max_body_size 4G; 19 | 20 | access_log /path/to/shared_canvas_channels/logs/nginx_access.log; 21 | error_log /path/to/shared_canvas_channels/logs/nginx_error.log; 22 | 23 | location /static/ { 24 | alias /path/to/shared_canvas_channels/static/; 25 | } 26 | 27 | location /media/ { 28 | alias /path/to/shared_canvas_channels/media/; 29 | } 30 | 31 | location / { 32 | # an HTTP header important enough to have its own Wikipedia entry: 33 | # http://en.wikipedia.org/wiki/X-Forwarded-For 34 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 35 | 36 | # enable this if and only if you use HTTPS, this helps Rack 37 | # set the proper protocol for doing redirects: 38 | # proxy_set_header X-Forwarded-Proto https; 39 | 40 | # pass the Host: header from the client right along so redirects 41 | # can be set properly within the Rack application 42 | proxy_set_header Host $http_host; 43 | 44 | # we don't want nginx trying to do something clever with 45 | # redirects, we set the Host: header above already. 46 | proxy_redirect off; 47 | 48 | # set "proxy_buffering off" *only* for Rainbows! when doing 49 | # Comet/long-poll stuff. It's also safe to set if you're 50 | # using only serving fast clients with Unicorn + nginx. 51 | # Otherwise you _want_ nginx to buffer responses to slow 52 | # clients, really. 53 | # proxy_buffering off; 54 | 55 | # Try to serve static files from nginx, no point in making an 56 | # *application* server like Unicorn/Rainbows! serve static files. 57 | if (!-f $request_filename) { 58 | proxy_pass http://shared_canvas_channels_upstream; 59 | break; 60 | } 61 | } 62 | 63 | location /events { 64 | proxy_pass http://shared_canvas_channels_upstream; 65 | proxy_set_header Host $http_host; 66 | proxy_redirect off; 67 | proxy_buffering off; 68 | proxy_http_version 1.1; 69 | proxy_set_header Upgrade $http_upgrade; 70 | proxy_set_header Connection "upgrade"; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.9.4 2 | git+https://github.com/abarto/django-jwt-auth.git@implement-refresh 3 | channels==0.9.5 4 | asgiref==0.9.1 5 | daphne==0.9.3 6 | asgi-redis==0.8.3 7 | redis==2.10.5 8 | psycopg2==2.6.1 9 | nodeenv==0.13.6 10 | django-bower==5.1.0 11 | -------------------------------------------------------------------------------- /shared_canvas_channels/assets/css/shared_canvas_channels.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: white; 3 | font-family: 'Roboto', sans-serif; 4 | overflow: hidden; 5 | margin: 0; 6 | } 7 | 8 | .center-container { 9 | display: flex; 10 | height: 100vh; 11 | justify-content: center; 12 | align-items: center; 13 | flex-direction: column; 14 | } 15 | 16 | .login-form { 17 | display: block; 18 | width: 25vw; 19 | } 20 | 21 | .login-text { 22 | font-size: 3vh; 23 | font-weight: 900; 24 | } 25 | 26 | .login-form-panel { 27 | background-color: #ccc; 28 | padding: 10px; 29 | } 30 | 31 | .login-alert { 32 | display: none; 33 | } 34 | 35 | .sketch { 36 | background-color: white; 37 | display: hidden; 38 | } 39 | -------------------------------------------------------------------------------- /shared_canvas_channels/assets/js/shared_canvas_channels.js: -------------------------------------------------------------------------------- 1 | var token = null; 2 | var socket = null; 3 | var sketch = null; 4 | 5 | $(function() { 6 | function getCredentials() { 7 | return { 8 | "username": $("[data-js-username]").val(), 9 | "password": $("[data-js-password]").val() 10 | } 11 | } 12 | 13 | function obtainJwtToken(url, credentials, fail, done) { 14 | $.ajax({ 15 | url: url, 16 | data: JSON.stringify(credentials), 17 | contentType: "application/json", 18 | method: "POST" 19 | }) 20 | .fail(fail) 21 | .done(done); 22 | } 23 | 24 | function refreshJwtToken(url, old_token, fail, done) { 25 | $.ajax({ 26 | url: url, 27 | data: JSON.stringify({"token": old_token}), 28 | contentType: "application/json", 29 | method: "POST", 30 | beforeSend: function(jqXHR) { 31 | jqXHR.setRequestHeader("Authorization", "Bearer " + old_token); 32 | } 33 | }) 34 | .fail(fail) 35 | .done(done); 36 | } 37 | 38 | function showDisconnectModal() { 39 | $("[data-js-disconnect-modal]").modal({"keyboard": false}); 40 | } 41 | 42 | function hideDisconnectModal() { 43 | $("[data-js-disconnect-modal]").modal('hide'); 44 | } 45 | 46 | function openWebSocket() { 47 | console.log('openWebSocket'); 48 | 49 | socket = new WebSocket( 50 | webSocketUrl + "?" + $.param({"token": token}) 51 | ); 52 | 53 | socket.onopen = function(event) { 54 | console.log("opopen", event); 55 | } 56 | socket.onmessage = function(event) { 57 | console.log("onmessage", event); 58 | touch = $.parseJSON(event.data); 59 | 60 | sketch.beginPath(); 61 | sketch.moveTo(touch.ox, touch.oy); 62 | sketch.lineTo(touch.x, touch.y); 63 | sketch.stroke(); 64 | } 65 | socket.onclose = function(event) { 66 | console.log("onclose", event); 67 | showDisconnectModal(); 68 | } 69 | socket.onerror = function(event) { 70 | console.log("onerror", event); 71 | } 72 | } 73 | 74 | function setUpSketch() { 75 | sketch = Sketch.create({ 76 | container: $("[data-js-sketch]")[0], 77 | autoclear: false, 78 | 79 | setup: function() { 80 | this.fillStyle = this.strokeStyle = '#000000'; 81 | this.lineCap = 'round'; 82 | this.lineJoin = 'round'; 83 | this.lineWidth = 5; 84 | }, 85 | 86 | update: function() { 87 | }, 88 | 89 | keydown: function() { 90 | if (this.keys.C) this.clear(); 91 | }, 92 | 93 | touchmove: function() { 94 | if (this.dragging) { 95 | touch = this.touches[0]; 96 | if (socket !== null) { 97 | touch["token"] = token; 98 | socket.send(JSON.stringify(touch)); 99 | } else { 100 | sketch.beginPath(); 101 | sketch.moveTo( touch.ox, touch.oy ); 102 | sketch.lineTo( touch.x, touch.y ); 103 | sketch.stroke(); 104 | } 105 | } 106 | } 107 | }); 108 | } 109 | 110 | function handleLoginFail(jqXHR, textStatus, errorThrown) { 111 | console.log('handleLoginFail', jqXHR, textStatus, errorThrown); 112 | $("[data-js-login-alert]").fadeIn(100).delay(2000).fadeOut(100); 113 | } 114 | 115 | function handleLoginDone(data, textStatus, jqXHR) { 116 | console.log('handleLoginDone', data, textStatus, jqXHR); 117 | 118 | token = data["token"]; 119 | 120 | $('[data-js-center-container]').hide( 121 | 100, 122 | function() { 123 | $('[data-js-sketch]').show( 124 | 100, 125 | function() { 126 | openWebSocket(); 127 | setUpSketch(); 128 | } 129 | ); 130 | } 131 | ); 132 | } 133 | 134 | function handleReconnectFail(jqXHR, textStatus, errorThrown) { 135 | console.log('handleReconnectFail', jqXHR, textStatus, errorThrown); 136 | $("[data-js-disconnect-message]").html('Unable to reconnect. Please check console logs.'); 137 | } 138 | 139 | function handleReconnectDone(data, textStatus, jqXHR) { 140 | console.log('handleReconnectDone', data, textStatus, jqXHR); 141 | 142 | token = data["token"]; 143 | hideDisconnectModal(); 144 | openWebSocket(); 145 | } 146 | 147 | $('[data-js-reconnect-button]').click(function(event) { 148 | console.log('[data-js-reconnect-button] click', event); 149 | event.preventDefault(); 150 | 151 | refreshJwtToken( 152 | refreshJwtTokenUrl, 153 | token, 154 | handleReconnectFail, 155 | handleReconnectDone 156 | ); 157 | }); 158 | 159 | $('[data-js-login-button]').click(function(event) { 160 | console.log('[data-js-login-button] click', event); 161 | event.preventDefault(); 162 | 163 | obtainJwtToken( 164 | obtainJwtTokenUrl, 165 | getCredentials(), 166 | handleLoginFail, 167 | handleLoginDone 168 | ); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /shared_canvas_channels/assets/js/sketch.js: -------------------------------------------------------------------------------- 1 | 2 | /* Copyright (C) 2013 Justin Windle, http://soulwire.co.uk */ 3 | 4 | (function ( root, factory ) { 5 | 6 | if ( typeof exports === 'object' ) { 7 | 8 | // CommonJS like 9 | module.exports = factory(root, root.document); 10 | 11 | } else if ( typeof define === 'function' && define.amd ) { 12 | 13 | // AMD 14 | define( function() { return factory( root, root.document ); }); 15 | 16 | } else { 17 | 18 | // Browser global 19 | root.Sketch = factory( root, root.document ); 20 | } 21 | 22 | }( typeof window !== "undefined" ? window : this, function ( window, document ) { 23 | 24 | 25 | "use strict"; 26 | 27 | /* 28 | ---------------------------------------------------------------------- 29 | 30 | Config 31 | 32 | ---------------------------------------------------------------------- 33 | */ 34 | 35 | var MATH_PROPS = 'E LN10 LN2 LOG2E LOG10E PI SQRT1_2 SQRT2 abs acos asin atan ceil cos exp floor log round sin sqrt tan atan2 pow max min'.split( ' ' ); 36 | var HAS_SKETCH = '__hasSketch'; 37 | var M = Math; 38 | 39 | var CANVAS = 'canvas'; 40 | var WEBGL = 'webgl'; 41 | var DOM = 'dom'; 42 | 43 | var doc = document; 44 | var win = window; 45 | 46 | var instances = []; 47 | 48 | var defaults = { 49 | 50 | fullscreen: true, 51 | autostart: true, 52 | autoclear: true, 53 | autopause: true, 54 | container: doc.body, 55 | interval: 1, 56 | globals: true, 57 | retina: false, 58 | type: CANVAS 59 | }; 60 | 61 | var keyMap = { 62 | 63 | 8: 'BACKSPACE', 64 | 9: 'TAB', 65 | 13: 'ENTER', 66 | 16: 'SHIFT', 67 | 27: 'ESCAPE', 68 | 32: 'SPACE', 69 | 37: 'LEFT', 70 | 38: 'UP', 71 | 39: 'RIGHT', 72 | 40: 'DOWN' 73 | }; 74 | 75 | /* 76 | ---------------------------------------------------------------------- 77 | 78 | Utilities 79 | 80 | ---------------------------------------------------------------------- 81 | */ 82 | 83 | function isArray( object ) { 84 | 85 | return Object.prototype.toString.call( object ) == '[object Array]'; 86 | } 87 | 88 | function isFunction( object ) { 89 | 90 | return typeof object == 'function'; 91 | } 92 | 93 | function isNumber( object ) { 94 | 95 | return typeof object == 'number'; 96 | } 97 | 98 | function isString( object ) { 99 | 100 | return typeof object == 'string'; 101 | } 102 | 103 | function keyName( code ) { 104 | 105 | return keyMap[ code ] || String.fromCharCode( code ); 106 | } 107 | 108 | function extend( target, source, overwrite ) { 109 | 110 | for ( var key in source ) 111 | 112 | if ( overwrite || !( key in target ) ) 113 | 114 | target[ key ] = source[ key ]; 115 | 116 | return target; 117 | } 118 | 119 | function proxy( method, context ) { 120 | 121 | return function() { 122 | 123 | method.apply( context, arguments ); 124 | }; 125 | } 126 | 127 | function clone( target ) { 128 | 129 | var object = {}; 130 | 131 | for ( var key in target ) { 132 | 133 | if ( key === 'webkitMovementX' || key === 'webkitMovementY' ) 134 | continue; 135 | 136 | if ( isFunction( target[ key ] ) ) 137 | 138 | object[ key ] = proxy( target[ key ], target ); 139 | 140 | else 141 | 142 | object[ key ] = target[ key ]; 143 | } 144 | 145 | return object; 146 | } 147 | 148 | /* 149 | ---------------------------------------------------------------------- 150 | 151 | Constructor 152 | 153 | ---------------------------------------------------------------------- 154 | */ 155 | 156 | function constructor( context ) { 157 | 158 | var request, handler, target, parent, bounds, index, suffix, clock, node, copy, type, key, val, min, max, w, h; 159 | 160 | var counter = 0; 161 | var touches = []; 162 | var resized = false; 163 | var setup = false; 164 | var ratio = win.devicePixelRatio || 1; 165 | var isDiv = context.type == DOM; 166 | var is2D = context.type == CANVAS; 167 | 168 | var mouse = { 169 | x: 0.0, y: 0.0, 170 | ox: 0.0, oy: 0.0, 171 | dx: 0.0, dy: 0.0 172 | }; 173 | 174 | var eventMap = [ 175 | 176 | context.eventTarget || context.element, 177 | 178 | pointer, 'mousedown', 'touchstart', 179 | pointer, 'mousemove', 'touchmove', 180 | pointer, 'mouseup', 'touchend', 181 | pointer, 'click', 182 | pointer, 'mouseout', 183 | pointer, 'mouseover', 184 | 185 | doc, 186 | 187 | keypress, 'keydown', 'keyup', 188 | 189 | win, 190 | 191 | active, 'focus', 'blur', 192 | resize, 'resize' 193 | ]; 194 | 195 | var keys = {}; for ( key in keyMap ) keys[ keyMap[ key ] ] = false; 196 | 197 | function trigger( method ) { 198 | 199 | if ( isFunction( method ) ) 200 | 201 | method.apply( context, [].splice.call( arguments, 1 ) ); 202 | } 203 | 204 | function bind( on ) { 205 | 206 | for ( index = 0; index < eventMap.length; index++ ) { 207 | 208 | node = eventMap[ index ]; 209 | 210 | if ( isString( node ) ) 211 | 212 | target[ ( on ? 'add' : 'remove' ) + 'EventListener' ].call( target, node, handler, false ); 213 | 214 | else if ( isFunction( node ) ) 215 | 216 | handler = node; 217 | 218 | else target = node; 219 | } 220 | } 221 | 222 | function update() { 223 | 224 | cAF( request ); 225 | request = rAF( update ); 226 | 227 | if ( !setup ) { 228 | 229 | trigger( context.setup ); 230 | setup = isFunction( context.setup ); 231 | } 232 | 233 | if ( !resized ) { 234 | trigger( context.resize ); 235 | resized = isFunction( context.resize ); 236 | } 237 | 238 | if ( context.running && !counter ) { 239 | 240 | context.dt = ( clock = +new Date() ) - context.now; 241 | context.millis += context.dt; 242 | context.now = clock; 243 | 244 | trigger( context.update ); 245 | 246 | // Pre draw 247 | 248 | if ( is2D ) { 249 | 250 | if ( context.retina ) { 251 | 252 | context.save(); 253 | context.scale( ratio, ratio ); 254 | } 255 | 256 | if ( context.autoclear ) 257 | 258 | context.clear(); 259 | } 260 | 261 | // Draw 262 | 263 | trigger( context.draw ); 264 | 265 | // Post draw 266 | 267 | if ( is2D && context.retina ) 268 | 269 | context.restore(); 270 | } 271 | 272 | counter = ++counter % context.interval; 273 | } 274 | 275 | function resize() { 276 | 277 | target = isDiv ? context.style : context.canvas; 278 | suffix = isDiv ? 'px' : ''; 279 | 280 | w = context.width; 281 | h = context.height; 282 | 283 | if ( context.fullscreen ) { 284 | 285 | h = context.height = win.innerHeight; 286 | w = context.width = win.innerWidth; 287 | } 288 | 289 | if ( context.retina && is2D && ratio ) { 290 | 291 | target.style.height = h + 'px'; 292 | target.style.width = w + 'px'; 293 | 294 | w *= ratio; 295 | h *= ratio; 296 | } 297 | 298 | if ( target.height !== h ) 299 | 300 | target.height = h + suffix; 301 | 302 | if ( target.width !== w ) 303 | 304 | target.width = w + suffix; 305 | 306 | if ( setup ) trigger( context.resize ); 307 | } 308 | 309 | function align( touch, target ) { 310 | 311 | bounds = target.getBoundingClientRect(); 312 | 313 | touch.x = touch.pageX - bounds.left - (win.scrollX || win.pageXOffset); 314 | touch.y = touch.pageY - bounds.top - (win.scrollY || win.pageYOffset); 315 | 316 | if ( context.retina && is2D && ratio ) { 317 | 318 | touch.x *= ratio; 319 | touch.y *= ratio; 320 | 321 | } 322 | 323 | return touch; 324 | } 325 | 326 | function augment( touch, target ) { 327 | 328 | align( touch, context.element ); 329 | 330 | target = target || {}; 331 | 332 | target.ox = target.x || touch.x; 333 | target.oy = target.y || touch.y; 334 | 335 | target.x = touch.x; 336 | target.y = touch.y; 337 | 338 | target.dx = target.x - target.ox; 339 | target.dy = target.y - target.oy; 340 | 341 | return target; 342 | } 343 | 344 | function process( event ) { 345 | 346 | event.preventDefault(); 347 | 348 | copy = clone( event ); 349 | copy.originalEvent = event; 350 | 351 | if ( copy.touches ) { 352 | 353 | touches.length = copy.touches.length; 354 | 355 | for ( index = 0; index < copy.touches.length; index++ ) 356 | 357 | touches[ index ] = augment( copy.touches[ index ], touches[ index ] ); 358 | 359 | } else { 360 | 361 | touches.length = 0; 362 | touches[0] = augment( copy, mouse ); 363 | } 364 | 365 | extend( mouse, touches[0], true ); 366 | 367 | return copy; 368 | } 369 | 370 | function pointer( event ) { 371 | 372 | event = process( event ); 373 | 374 | min = ( max = eventMap.indexOf( type = event.type ) ) - 1; 375 | 376 | context.dragging = 377 | 378 | /down|start/.test( type ) ? true : 379 | 380 | /up|end/.test( type ) ? false : 381 | 382 | context.dragging; 383 | 384 | while( min ) 385 | 386 | isString( eventMap[ min ] ) ? 387 | 388 | trigger( context[ eventMap[ min-- ] ], event ) : 389 | 390 | isString( eventMap[ max ] ) ? 391 | 392 | trigger( context[ eventMap[ max++ ] ], event ) : 393 | 394 | min = 0; 395 | } 396 | 397 | function keypress( event ) { 398 | 399 | key = event.keyCode; 400 | val = event.type == 'keyup'; 401 | keys[ key ] = keys[ keyName( key ) ] = !val; 402 | 403 | trigger( context[ event.type ], event ); 404 | } 405 | 406 | function active( event ) { 407 | 408 | if ( context.autopause ) 409 | 410 | ( event.type == 'blur' ? stop : start )(); 411 | 412 | trigger( context[ event.type ], event ); 413 | } 414 | 415 | // Public API 416 | 417 | function start() { 418 | 419 | context.now = +new Date(); 420 | context.running = true; 421 | } 422 | 423 | function stop() { 424 | 425 | context.running = false; 426 | } 427 | 428 | function toggle() { 429 | 430 | ( context.running ? stop : start )(); 431 | } 432 | 433 | function clear() { 434 | 435 | if ( is2D ) 436 | 437 | context.clearRect( 0, 0, context.width, context.height ); 438 | } 439 | 440 | function destroy() { 441 | 442 | parent = context.element.parentNode; 443 | index = instances.indexOf( context ); 444 | 445 | if ( parent ) parent.removeChild( context.element ); 446 | if ( ~index ) instances.splice( index, 1 ); 447 | 448 | bind( false ); 449 | stop(); 450 | } 451 | 452 | extend( context, { 453 | 454 | touches: touches, 455 | mouse: mouse, 456 | keys: keys, 457 | 458 | dragging: false, 459 | running: false, 460 | millis: 0, 461 | now: NaN, 462 | dt: NaN, 463 | 464 | destroy: destroy, 465 | toggle: toggle, 466 | clear: clear, 467 | start: start, 468 | stop: stop 469 | }); 470 | 471 | instances.push( context ); 472 | 473 | return ( context.autostart && start(), bind( true ), resize(), update(), context ); 474 | } 475 | 476 | /* 477 | ---------------------------------------------------------------------- 478 | 479 | Global API 480 | 481 | ---------------------------------------------------------------------- 482 | */ 483 | 484 | var element, context, Sketch = { 485 | 486 | CANVAS: CANVAS, 487 | WEB_GL: WEBGL, 488 | WEBGL: WEBGL, 489 | DOM: DOM, 490 | 491 | instances: instances, 492 | 493 | install: function( context ) { 494 | 495 | if ( !context[ HAS_SKETCH ] ) { 496 | 497 | for ( var i = 0; i < MATH_PROPS.length; i++ ) 498 | 499 | context[ MATH_PROPS[i] ] = M[ MATH_PROPS[i] ]; 500 | 501 | extend( context, { 502 | 503 | TWO_PI: M.PI * 2, 504 | HALF_PI: M.PI / 2, 505 | QUARTER_PI: M.PI / 4, 506 | 507 | random: function( min, max ) { 508 | 509 | if ( isArray( min ) ) 510 | 511 | return min[ ~~( M.random() * min.length ) ]; 512 | 513 | if ( !isNumber( max ) ) 514 | 515 | max = min || 1, min = 0; 516 | 517 | return min + M.random() * ( max - min ); 518 | }, 519 | 520 | lerp: function( min, max, amount ) { 521 | 522 | return min + amount * ( max - min ); 523 | }, 524 | 525 | map: function( num, minA, maxA, minB, maxB ) { 526 | 527 | return ( num - minA ) / ( maxA - minA ) * ( maxB - minB ) + minB; 528 | } 529 | }); 530 | 531 | context[ HAS_SKETCH ] = true; 532 | } 533 | }, 534 | 535 | create: function( options ) { 536 | 537 | options = extend( options || {}, defaults ); 538 | 539 | if ( options.globals ) Sketch.install( self ); 540 | 541 | element = options.element = options.element || doc.createElement( options.type === DOM ? 'div' : 'canvas' ); 542 | 543 | context = options.context = options.context || (function() { 544 | 545 | switch( options.type ) { 546 | 547 | case CANVAS: 548 | 549 | return element.getContext( '2d', options ); 550 | 551 | case WEBGL: 552 | 553 | return element.getContext( 'webgl', options ) || element.getContext( 'experimental-webgl', options ); 554 | 555 | case DOM: 556 | 557 | return element.canvas = element; 558 | } 559 | 560 | })(); 561 | 562 | ( options.container || doc.body ).appendChild( element ); 563 | 564 | return Sketch.augment( context, options ); 565 | }, 566 | 567 | augment: function( context, options ) { 568 | 569 | options = extend( options || {}, defaults ); 570 | 571 | options.element = context.canvas || context; 572 | options.element.className += ' sketch'; 573 | 574 | extend( context, options, true ); 575 | 576 | return constructor( context ); 577 | } 578 | }; 579 | 580 | /* 581 | ---------------------------------------------------------------------- 582 | 583 | Shims 584 | 585 | ---------------------------------------------------------------------- 586 | */ 587 | 588 | var vendors = [ 'ms', 'moz', 'webkit', 'o' ]; 589 | var scope = self; 590 | var then = 0; 591 | 592 | var a = 'AnimationFrame'; 593 | var b = 'request' + a; 594 | var c = 'cancel' + a; 595 | 596 | var rAF = scope[ b ]; 597 | var cAF = scope[ c ]; 598 | 599 | for ( var i = 0; i < vendors.length && !rAF; i++ ) { 600 | 601 | rAF = scope[ vendors[ i ] + 'Request' + a ]; 602 | cAF = scope[ vendors[ i ] + 'Cancel' + a ]; 603 | } 604 | 605 | scope[ b ] = rAF = rAF || function( callback ) { 606 | 607 | var now = +new Date(); 608 | var dt = M.max( 0, 16 - ( now - then ) ); 609 | var id = setTimeout( function() { 610 | callback( now + dt ); 611 | }, dt ); 612 | 613 | then = now + dt; 614 | return id; 615 | }; 616 | 617 | scope[ c ] = cAF = cAF || function( id ) { 618 | clearTimeout( id ); 619 | }; 620 | 621 | /* 622 | ---------------------------------------------------------------------- 623 | 624 | Output 625 | 626 | ---------------------------------------------------------------------- 627 | */ 628 | 629 | return Sketch; 630 | 631 | })); 632 | -------------------------------------------------------------------------------- /shared_canvas_channels/assets/js/sketch.min.js: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2013 Justin Windle, http://soulwire.co.uk */ 2 | !function(e,t){"object"==typeof exports?module.exports=t(e,e.document):"function"==typeof define&&define.amd?define(function(){return t(e,e.document)}):e.Sketch=t(e,e.document)}("undefined"!=typeof window?window:this,function(e,t){"use strict";function n(e){return"[object Array]"==Object.prototype.toString.call(e)}function o(e){return"function"==typeof e}function r(e){return"number"==typeof e}function i(e){return"string"==typeof e}function u(e){return C[e]||String.fromCharCode(e)}function a(e,t,n){for(var o in t)!n&&o in e||(e[o]=t[o]);return e}function c(e,t){return function(){e.apply(t,arguments)}}function s(e){var t={};for(var n in e)"webkitMovementX"!==n&&"webkitMovementY"!==n&&(t[n]=o(e[n])?c(e[n],e):e[n]);return t}function l(e){function t(t){o(t)&&t.apply(e,[].splice.call(arguments,1))}function n(e){for(_=0;_ 2 | 3 | {% load staticfiles %} 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}{% endblock title %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | {% block extra_css %}{% endblock extra_css %} 24 | 25 | 26 | {% block content %}{% endblock content %} 27 | 28 | 29 | 30 | 31 | {% block extra_js %}{% endblock extra_js %} 32 | 33 | 34 | -------------------------------------------------------------------------------- /shared_canvas_channels/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load staticfiles %} 4 | 5 | {% block content %} 6 | 7 | 8 | Login 9 | 10 | 11 | 12 | Username 13 | 14 | 15 | 16 | 17 | 18 | Password 19 | 20 | 21 | 22 | 23 | 24 | 25 | Unable to login 26 | 27 | 28 | Login 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | Lost connection with the server. 39 | 40 | 43 | 44 | 45 | 46 | {% endblock %} 47 | 48 | {% block extra_js %} 49 | 50 | 51 | 52 | 57 | {% endblock extra_js %} 58 | -------------------------------------------------------------------------------- /supervisor/shared_canvas_channels_daphne.conf: -------------------------------------------------------------------------------- 1 | [program:shared_canvas_channels_daphne] 2 | user = vagrant 3 | directory = /path/to/shared_canvas_channels/shared_canvas_channels 4 | command = /path/to/shared_canvas_channels_venv/bin/daphne shared_canvas_channels.asgi:channel_layer 5 | autostart = true 6 | autorestart = true 7 | stderr_logfile = /path/to/shared_canvas_channels/daphne_stderr.log 8 | stdout_logfile = /path/to/shared_canvas_channels/daphne_stdout.log 9 | stopsignal = INT 10 | -------------------------------------------------------------------------------- /supervisor/shared_canvas_channels_runworker.conf: -------------------------------------------------------------------------------- 1 | [program:shared_canvas_channels_runworker] 2 | process_name = shared_canvas_channels_runworker-%(process_num)s 3 | user = vagrant 4 | directory = /path/to/shared_canvas_channels/shared_canvas_channels 5 | command = /path/to/tracker_project_venv/bin/python /path/to/shared_canvas_channels/shared_canvas_channels/manage.py runworker 6 | numprocs = 6 7 | autostart = true 8 | autorestart = true 9 | stderr_logfile = /path/to/shared_canvas_channels/runworker_stderr.log 10 | stdout_logfile = /path/to/shared_canvas_channels/runworker_stdout.log 11 | stopsignal = INT 12 | --------------------------------------------------------------------------------