Repository: zoofIO/flexx
Pull request #317 (2016-12-11 12:17:16)

SSL support for remotely running Jupyter notebooks
* app.init_notebook() fix added WSS to url
* TornadoServer  Comparing hostnames in case of Jupyter notebooks connecting_host and serving_host have same host names but different ports


Patch:
diff --git a/flexx/_config.py b/flexx/_config.py
index ac0127a..8823be4 100644
--- a/flexx/_config.py
+++ b/flexx/_config.py
@@ -1,12 +1,14 @@
 from .util.config import Config
 
 config = Config('flexx', '~appdata/.flexx.cfg',
-    log_level=('info', str, 'The log level to use (DEBUG, INFO, WARNING, ERROR)'),
-    hostname=('localhost', str, 'The default hostname to serve apps.'),
-    port=(0, int, 'The default port to serve apps. Zero means auto-select.'),
-    webruntime=('', str, 'The default web runtime to use. Default is xul/browser.'),
-    ws_timeout=(20, int, 'If the websocket is idle for this amount of seconds, '
-                         'it is closed.'),
-    ssl_certfile=('', str, 'cert file for https server.'),
-    ssl_keyfile=('', str, 'key file for https server.'),
-    )
+        log_level=('info', str, 'The log level to use (DEBUG, INFO, WARNING, ERROR)'),
+        hostname=('localhost', str, 'The default hostname to serve apps.'),
+        port=(0, int, 'The default port to serve apps. Zero means auto-select.'),
+        webruntime=('', str, 'The default web runtime to use. Default is xul/browser.'),
+        ws_timeout=(20, int, 'If the websocket is idle for this amount of seconds, '
+                 'it is closed.'),
+        ssl_certfile=('', str, 'cert file for https server.'),
+        ssl_keyfile=('', str, 'key file for https server.'),
+        host_whitelist=('', str, 'Comma separated list of allowed <host>:<port> '
+                      'values to pass cross-origin checks.'),
+        )
diff --git a/flexx/app/__init__.py b/flexx/app/__init__.py
index 9be49ab..984a55a 100644
--- a/flexx/app/__init__.py
+++ b/flexx/app/__init__.py
@@ -166,14 +166,26 @@ Data can be provided per session or shared between sessions:
     # Add shared data
     link = app.assets.add_shared_data('some_name.png', binary_blob)
 
+Futher, it is possible to send data from Python to JS within a model class:
 
-Note that ``binary_blob`` can also be a string starting with ``http://`` or
-``file://``. In the future we plan to make it easier to load arbitrary
-data in the client (mainly for scientific purposes).
 
-Note that this API for providing the client with data may change in a
-following release. If you rely on this, please make an issue so we can
-work out a smooth transition if necessary.
+.. code-block:: py
+
+    class MyModel(app.Model):
+    
+        def some_method(self):
+            self.send_data(binary_blob, meta_dict)
+        
+        class JS:
+        
+            def receive_data(self, array_buffer, meta_dict):
+                # This gets called when the data arrives.
+                
+                ...  # handle the data
+
+In this case, ``binary_blob`` can also be a URL where the client should
+download the binary data from.
+
 
 Some background info on the server process
 ------------------------------------------
diff --git a/flexx/app/_app.py b/flexx/app/_app.py
index bdf8b0d..258b409 100644
--- a/flexx/app/_app.py
+++ b/flexx/app/_app.py
@@ -256,7 +256,8 @@ class AppManager(event.HasEvents):
             raise ValueError('Given app does not have a valid name %r' % name)
         pending, connected = [], []
         if name in self._appinfo:
-            if app is not self._appinfo[name][0]:
+            old_app, pending, connected = self._appinfo[name]
+            if app is not old_app:
                 logger.warn('Re-registering app class %r' % name)
         self._appinfo[name] = app, pending, connected
     
diff --git a/flexx/app/_asset.py b/flexx/app/_asset.py
index a789225..ce2ea27 100644
--- a/flexx/app/_asset.py
+++ b/flexx/app/_asset.py
@@ -14,7 +14,7 @@ from . import logger
 HEADER = 'Autogenerated code from Flexx. Code Subject to the BSD-2-clause license.'
 HEADER = '/* %s */\n\n' % HEADER
 
-uri_starts = 'http://', 'https://', 'file://'
+url_starts = 'http://', 'https://'
 
 
 # Although these two funcs are better off in modules.py, that causes circular refs.
@@ -94,7 +94,7 @@ class Asset:
         # Handle name
         if not isinstance(name, str):
             raise TypeError('Asset name must be str.')
-        if name.startswith(uri_starts):
+        if name.startswith(url_starts):
             if source is not None:
                 raise TypeError('Remote assets cannot have a source: %s' % name)
             source = name
@@ -110,8 +110,11 @@ class Asset:
         if source is None:
             raise TypeError('Asset needs a source.')
         elif isinstance(source, str):
-            if source.startswith(uri_starts):
+            if source.startswith(url_starts):
                 self._remote = True
+            elif source.startswith('file://'):
+                raise TypeError('Cannot specify an asset using "file://", '
+                                'use http or open the file and use contents.')
             else:
                 self._source_str = source
         elif callable(source):
@@ -130,14 +133,14 @@ class Asset:
     
     @property
     def source(self):
-        """ The source for this asset. Can be str, URI or callable.
+        """ The source for this asset. Can be str, URL or callable.
         """
         return self._source
     
     @property
     def remote(self):
         """ Whether the asset is remote (client will load it from elsewhere).
-        If True, the source specifies the URI.
+        If True, the source specifies the URL.
         """
         return self._remote
     
@@ -189,21 +192,16 @@ class Asset:
                     t = 'Source function of asset %r did not return a str, but a %s.'
                     raise ValueError(t % (self.name, self._source.__class__.__name__))
             elif self._remote:
-                self._source_str = self._get_from_uri(self._source)
+                self._source_str = self._get_from_url(self._source)
             else:  # pragma: no cover
                 assert False, 'This should not happen'
         return self._source_str
     
-    def _get_from_uri(self, uri):
-        if uri.startswith(('http://', 'https://')):
-            return urlopen(uri, timeout=5.0).read().decode()
-        elif uri.startswith('file://'):
-            fname = uri.split('//', 1)[1]
-            if sys.platform.startswith('win'):
-                fname = fname.lstrip('/')
-            return open(fname, 'rb').read().decode()
+    def _get_from_url(self, url):
+        if url.startswith(url_starts):
+            return urlopen(url, timeout=5.0).read().decode()
         else:  # pragma: no cover
-            raise ValueError('_get_from_url() needs a uri string.')
+            raise ValueError('_get_from_url() needs a URL string.')
 
 
 class Bundle(Asset):
diff --git a/flexx/app/_assetstore.py b/flexx/app/_assetstore.py
index cafac95..1f5b3dd 100644
--- a/flexx/app/_assetstore.py
+++ b/flexx/app/_assetstore.py
@@ -6,7 +6,6 @@ etc.) needed by the applications.
 
 import os
 import shutil
-from urllib.request import urlopen
 
 from ..pyscript import create_js_module, get_all_std_names, get_full_std_lib
 
@@ -343,13 +342,13 @@ class AssetStore:
         
         Parameters:
             name (str): the asset name, e.g. 'foo.js' or 'bar.css'. Can contain
-                slashes to emulate a file system. e.g. 'spam/foo.js'. If a URI
+                slashes to emulate a file system. e.g. 'spam/foo.js'. If a URL
                 is given, both name and source are implicitly set (and its
                 a remote asset).
             source (str, function): the source for this asset. Can be:
             
                 * The source code.
-                * A URI (str starting with 'http://', 'https://' or 'file://'),
+                * A URL (str starting with 'http://' or 'https://'),
                   making this a "remote asset". Note that ``app.export()``
                   provides control over how (remote) assets are handled.
                 * A funcion that should return the source code, and which is
@@ -418,26 +417,21 @@ class AssetStore:
     def add_shared_data(self, name, data):
         """ Add data to serve to the client (e.g. images), which is shared
         between sessions. It is an error to add data with a name that is
-        already registered. See ``Session.add_data()`` to set dataper-session.
+        already registered. See ``Session.add_data()`` to set data per-session
+        and `Model.send_data()`` to send data to Model objects directly.
         
         Parameters:
             name (str): the name of the data, e.g. 'icon.png'. 
-            data (bytes): the data blob. Can also be a uri to the blob
-                (string starting with "file://", "http://" or "https://").
-                in which case the code is (down)loaded on the server.
+            data (bytes): the data blob.
         
         Returns:
             url: the (relative) url at which the data can be retrieved.
+        
         """
         if not isinstance(name, str):
             raise TypeError('add_shared_data() name must be a str.')
         if name in self._data:
             raise ValueError('add_shared_data() got existing name %r.' % name)
-        if isinstance(data, str):
-            if data.startswith('file://'):
-                data = open(data.split('//', 1)[1], 'rb').read()
-            elif data.startswith(('http://', 'https://')):
-                data = urlopen(data, timeout=5.0).read()
         if not isinstance(data, bytes):
             raise TypeError('add_shared_data() data must be bytes.')
         self._data[name] = data
diff --git a/flexx/app/_funcs.py b/flexx/app/_funcs.py
index a4df230..a11037f 100644
--- a/flexx/app/_funcs.py
+++ b/flexx/app/_funcs.py
@@ -187,9 +187,10 @@ def init_notebook():
     # Install helper to make things work in exported notebooks
     NoteBookHelper(session)
     
-    # We set session_id and app_name in a way that it does not end up
-    # in exported notebook.
-    url = 'ws://%s:%i/flexx/ws/%s' % (host, port, session.app_name)
+    proto = 'wss' if server.protocol == 'https' else 'ws'
+    
+    url = '%s://%s:%i/flexx/ws/%s' % (proto, host, port, session.app_name)
+
     flexx_pre_init = """<script>window.flexx = {};
                                 window.flexx.app_name = "%s";
                                 window.flexx.session_id = "%s";
diff --git a/flexx/app/_model.py b/flexx/app/_model.py
index 510b389..c0772de 100644
--- a/flexx/app/_model.py
+++ b/flexx/app/_model.py
@@ -575,6 +575,25 @@ class Model(with_metaclass(ModelMeta, event.HasEvents)):
         cmd = 'flexx.instances.%s.%s;' % (self._id, call)
         self._session._exec(cmd)
     
+    def send_data(self, data, meta=None):
+        """ Send data to the JS side, where ``retreive_data()`` will be called,
+        which will eventually call ``receive_data()`` with the corresponding
+        data and meta data.
+        
+        AJAX is used to retrieve the data. In the future we may want to use
+        a dedicated binary websocket for better performance.
+        
+        Parameters:
+            data (bytes, str): the data blob. Can also be a URL
+                (a string starting with "http://" or "https://") where the
+                client can download the data from.
+            meta (dict, optional): information associated with the data
+                that the JS side can use to interpret the data. This function
+                will add an "id" field to the meta data.
+        """
+        meta = {} if meta is None else meta
+        return self.session._send_data(self.id, data, meta)
+    
     
     class JS:
         
@@ -675,6 +694,33 @@ class Model(with_metaclass(ModelMeta, event.HasEvents)):
                 txt = serializer.saves(ev)
                 if window.flexx.ws:
                     window.flexx.ws.send('EVENT ' + [self.id, type, txt].join(' '))
+        
+        def retrieve_data(self, url, meta):
+            """ Make an AJAX call to retrieve a blob of data. When the
+            data is received, receive_data() is called.
+            """
+            # Define handler
+            # print('retrieving data for', self.id, 'from', url)
+            def process_response():
+                if xhr.status == 200:
+                    self.receive_data(xhr.response, meta)
+                else:
+                    raise RuntimeError("Retrieving data for %s failed with "
+                                       "HTTP status %s" % (self.id, xhr.status))
+            # Make AJAX call
+            xhr = window.XMLHttpRequest()
+            xhr.open("GET", url)
+            xhr.responseType = "arraybuffer"
+            xhr.onload = process_response
+            xhr.send()
+        
+        def receive_data(self, data, meta):
+            """ Function that gets called when data is send to it. Models that
+            want to receive data must overload this in order to process the
+            received data. The ``data`` is an ``ArrayBuffer``, and ``meta`` is
+            a ``dict`` as given in ``send_data()``.
+            """
+            print(self.id, 'received data but did not handle it')
 
 
 # Make model objects de-serializable
diff --git a/flexx/app/_session.py b/flexx/app/_session.py
index 11112ca..e3ddada 100644
--- a/flexx/app/_session.py
+++ b/flexx/app/_session.py
@@ -7,10 +7,9 @@ import json
 import random
 import hashlib
 import weakref
-from urllib.request import urlopen
 
 from ._model import Model, new_type
-from ._asset import Asset, Bundle, solve_dependencies
+from ._asset import url_starts, Asset, Bundle, solve_dependencies
 from ._assetstore import AssetStore, export_assets_and_data, INDEX
 from ._assetstore import assets as assetstore
 from . import logger
@@ -73,6 +72,7 @@ class Session:
         
         # Data for this session (in addition to the data provided by the store)
         self._data = {}
+        self._data_volatile = {}  # deleted after retrieving
         
         # More vars
         self._runtime = None  # init web runtime, will be set when used
@@ -169,6 +169,9 @@ class Session:
             if self._model:
                 self._model.dispose()
                 self._model = None
+            # Discard data
+            self._data = {}
+            self._data_volatile = {}
         finally:
             self._closing = False
     
@@ -206,55 +209,89 @@ class Session:
     
     ## Data
     
-    def get_data_names(self):
-        """ Get a list of names of the data provided by this session, in
-        the order that they were added.
-        """
-        return list(self._data.keys())  # Note: order matters
-    
-    def get_data(self, name):
-        """ Get the data corresponding to the given name. This can be
-        data local to the session, or global data. Returns None if data
-        by that name is unknown.
+    def _send_data(self, id, data, meta):
+        """ Send data to a model on the JS side. The corresponding object's
+        receive_data() method is called when the data is available in JS.
+        This is called by ``Model.send_data()`` and works in the same way.
         """
-        data = self._data.get(name, None)
-        if data is None:
-            data = self._store.get_data(name)
-        return data
-    
-    # todo: the way that we do assets now makes me wonder whether there are better ways
-    # to deal with data handling ...
+        # Check id
+        if not isinstance(id, str):
+            raise TypeError('session.send_data() first arg must be a str id.')
+        if not self._model_instances.get(id, None):
+            raise ValueError('session.send_data() first arg must be an id '
+                             'corresponding to an existing model: %r' % id)
+        # Check meta
+        if not isinstance(meta, dict):
+            raise TypeError('session.send_data() meta must be a dict.')
+        # Check data - url or blob
+        data_name = None
+        if isinstance(data, str) and data.startswith(url_starts):
+            # URL: tell client to retrieve it with AJAX
+            url = data
+        elif isinstance(data, bytes):
+            # Blob: store it, and tell client to retieve it with AJAX
+            # todo: have a second ws connection for pushing data
+            data_name = 'blob-' + get_random_string()
+            url = '/flexx/data/%s/%s' % (self.id, data_name)
+            self._data_volatile[data_name] = data
+            if self.id == self.app_name:  # Maintain data if we're being exported
+                self._data[data_name] = data
+        else:
+            raise TypeError('session.send_data() data must be a bytes or a URL, '
+                            'not %s.' % data.__class__.__name__)
+        
+        # Tell JS to retrieve data
+        t = 'window.flexx.instances.%s.retrieve_data("%s", %s);'
+        self._exec(t % (id, url, reprs(meta)))
     
-    def add_data(self, name, data):  # todo: add option to clear data after its loaded?
+    def add_data(self, name, data):
         """ Add data to serve to the client (e.g. images), specific to this
         session. Returns the link at which the data can be retrieved.
-        See ``app.assets.add_shared_data()`` to provide shared data.
+        See ``Session.send_data()`` for a send-and-forget mechanism, and
+        ``app.assets.add_shared_data()`` to provide shared data.
         
         Parameters:
             name (str): the name of the data, e.g. 'icon.png'. If data has
                 already been set on this name, it is overwritten.
-            data (bytes): the data blob. Can also be a uri to the blob
-                (string starting with "file://", "http://" or "https://").
+            data (bytes): the data blob.
+        
+        Returns:
+            url: the (relative) url at which the data can be retrieved.
         """
         if not isinstance(name, str):
             raise TypeError('Session.add_data() name must be a str.')
         if name in self._data:
             raise ValueError('Session.add_data() got existing name %r.' % name)
-        if isinstance(data, str):
-            if data.startswith('file://'):
-                data = open(data.split('//', 1)[1], 'rb').read()
-            elif data.startswith(('http://', 'https://')):
-                data = urlopen(data, timeout=5.0).read()
         if not isinstance(data, bytes):
-            raise TypeError('Session.add_data() data must be a bytes.')
+            raise TypeError('Session.add_data() data must be bytes.')
         self._data[name] = data
         return '_data/%s/%s' % (self.id, name)  # relative path so it works /w export
     
     def remove_data(self, name):
-        """ Remove the data associated with the given name.
+        """ Remove the data associated with the given name. If you need this,
+        also consider ``send_data()``. Also note that data is automatically
+        released when the session is closed.
         """
         self._data.pop(name, None)
     
+    def get_data_names(self):
+        """ Get a list of names of the data provided by this session.
+        """
+        return list(self._data.keys())
+    
+    def get_data(self, name):
+        """ Get the data corresponding to the given name. This can be
+        data local to the session, or global data. Returns None if data
+        by that name is unknown.
+        """
+        if True:
+            data = self._data_volatile.pop(name, None)
+        if data is None:
+            data = self._data.get(name, None)
+        if data is None:
+            data = self._store.get_data(name)
+        return data
+    
     def _export_data(self, dirname, clear=False):
         """ Export all assets and data specific to this session.
         Private method, used by app.export().
@@ -399,7 +436,7 @@ class Session:
         for asset in assets:
             if asset.name in self._assets_to_ignore:
                 continue
-            logger.info('Loading asset %s' % asset.name)
+            logger.debug('Loading asset %s' % asset.name)
             # Determine command suffix. All our sources come in bundles,
             # for which we use eval because it makes sourceURL work on FF.
             suffix = asset.name.split('.')[-1].upper()
@@ -556,7 +593,7 @@ def _get_page(session, js_assets, css_assets, link, export):
                 else:
                     html = asset.to_html(pre_path + '/shared/{}', link)
             codes.append(html)
-            if export:
+            if export and assets is js_assets:
                 codes.append('<script>window.flexx.spin();</script>')
         codes.append('')  # whitespace between css and js assets
     
diff --git a/flexx/app/_tornadoserver.py b/flexx/app/_tornadoserver.py
index 86571d8..9100aaa 100644
--- a/flexx/app/_tornadoserver.py
+++ b/flexx/app/_tornadoserver.py
@@ -353,8 +353,10 @@ class MainHandler(RequestHandler):
         
         # Get asset provider: store or session
         asset_provider = assets
-        if session_id:
+        if session_id and selector != 'data':
             return self.write('Only supports shared assets, not ' % filename)
+        elif session_id:
+            asset_provider = manager.get_session_by_id(session_id)
         
         # Checks
         if asset_provider is None:
@@ -408,10 +410,10 @@ class MainHandler(RequestHandler):
             # Retrieve data
             res = asset_provider.get_data(filename)
             if res is None:
-                self.write('Could not load data %r' % filename)
+                return self.send_error(404)
             else:
                 self._guess_mime_type(filename)  # so that images show up
-                self.write(res)
+                return self.write(res)
         
         else:
             raise RuntimeError('Invalid asset type %r' % selector)
@@ -678,6 +680,7 @@ class WSHandler(WebSocketHandler):
         serving_host = self.request.headers.get("Host")
         serving_hostname = serving_host.split(':')[0]
         connecting_host = urlparse(origin).netloc
+
         
         if serving_hostname == 'localhost':
             return True  # Safe
@@ -685,6 +688,8 @@ class WSHandler(WebSocketHandler):
             return True  # we cannot know if the origin matches
         elif serving_host == connecting_host:
             return True
+        elif connecting_host in config.host_whitelist:
+            return True
         else:
             logger.info('Connection refused from %s' % origin)
             return False
diff --git a/flexx/event/_handler.py b/flexx/event/_handler.py
index a6ac00e..577e6a5 100644
--- a/flexx/event/_handler.py
+++ b/flexx/event/_handler.py
@@ -366,6 +366,7 @@ class Handler:
         obname_full, path = path[0], path[1:]
         obname = obname_full.rstrip('*')
         selector = obname_full[len(obname):]
+
         # Internally, 3-star notation is used for optional selectors
         if selector == '***':
             self._seek_event_object(index, path, ob)
@@ -384,9 +385,8 @@ class Handler:
                 self._seek_event_object(index, path, sub_ob)
             return
         elif selector == '*':  # "**" is recursive, so allow more
-            t = "Invalid connection {obname_full} because {obname} \
-                is not a tuple/list."
-            raise RuntimeError(t.replace("{opname_full}", obname_full)
-                .replace("{opname}", obname))
+            t = "Invalid connection {name_full} because {name} is not a tuple/list."
+            raise RuntimeError(t.replace("{name_full}", obname_full)
+                .replace("{name}", obname))
         else:
             return self._seek_event_object(index, path, ob)
diff --git a/flexx/ui/examples/send_data.py b/flexx/ui/examples/send_data.py
new file mode 100644
index 0000000..f7bb40e
--- /dev/null
+++ b/flexx/ui/examples/send_data.py
@@ -0,0 +1,71 @@
+# doc-export: SendData
+"""
+Example that demonstrates sending binray data from Python to JS. The
+``Model.send_data()`` mechanism can be a powerful tool to send
+(scientific) data, especially for large amounts of data. The method
+accepts bytes or a URL ("http://" or "https://") where the
+data can be retrieved. At his point AJAX is used to retrieve the data.
+In the future we might push the data over a dedicated binary websocket
+for greater performance.
+
+If you find that you have a property that is a large list of numbers, maybe
+that should be considered data instead of a property. 
+
+Exported apps that use ``send_data()` work, but only if they are served
+(e.g. on a blog, readthedocs or S3), because browsers typically refuse
+to load local files via AJAX.
+"""
+
+from flexx import ui, app
+from flexx.pyscript.stubs import window
+
+try:
+    import numpy as np
+except ImportError:
+    np = None
+
+
+N = 1000
+
+# Create array of N random float values. On numpy its easy
+if np:
+    data = np.random.normal(0, 1, N)
+else:
+    # fallback for when numpy is not avail
+    import random
+    import ctypes
+    data = (ctypes.c_double * N)()
+    for i in range(N):
+        data[i] = random.random(0, 1)
+
+
+class SendData(ui.Widget):
+    """ A simple example demonstrating sending binary data from Python to JS.
+    """
+    
+    def init(self):
+        self.label = ui.Label()
+        self.style = 'overflow-y: scroll'  # enable scrolling
+        
+        # Send data to the JS side. In this case we don't need meta data
+        meta = {}
+        self.send_data(bytes(data), meta)
+    
+    class JS:
+        
+        def receive_data(self, blob, meta):
+            # This is the method to implement to handle received binary data
+            
+            # The first argument is an (untyped) arraybuffer,
+            # we know its float64 in this case.
+            data = window.Float64Array(blob)
+            
+            # Show the data as text. We could also e.g. plot it.
+            text = ['%i: %f<br />' % (i, data[i]) for i in range(len(data))]
+            header = 'This data was send in binary form:<br />'
+            self.label.text = header + ''.join(text)
+
+
+if __name__ == '__main__':
+    m = app.launch(SendData, 'firefox')
+    app.run()
diff --git a/flexx/ui/examples/serve_data.py b/flexx/ui/examples/serve_data.py
index 100558d..85b33a3 100644
--- a/flexx/ui/examples/serve_data.py
+++ b/flexx/ui/examples/serve_data.py
@@ -18,26 +18,34 @@ Similarly, the data provided by the server can be obtained using Ajax
 
 Note that this example will only load random images if its live (i.e.
 not exported).
+
+See video_viewer.py for an example on providing data without reading it
+in memory, and send_data.py for one-shot sending of data from Python to JS.
 """
 
 import random
-
-import imageio
+from urllib.request import urlopen
 
 from flexx import app, ui
-from flexx.util import png
 
 
 # Define names of standard images
-imageio_standard_images = ['clock.png', 'page.png', 'camera.png', 'coins.png',
-                           'hubble_deep_field.png', 'text.png', 'chelsea.png',
-                           'coffee.png', 'horse.png', 'wikkie.png', 'moon.png',
-                           'astronaut.png', 'immunohistochemistry.png']
+image_names = ['clock.png', 'page.png', 'camera.png', 'coins.png',
+               'hubble_deep_field.png', 'text.png', 'chelsea.png',
+               'coffee.png', 'horse.png', 'wikkie.png', 'moon.png',
+               'astronaut.png', 'immunohistochemistry.png']
+
+
+def get_img_blob(name):
+    """ Given an image name, download the raw bytes from imageio's repository
+    of standard images.
+    """
+    url_root = 'https://github.com/imageio/imageio-binaries/raw/master/images/'
+    return urlopen(url_root + name, timeout=2.0).read()
+
 
 # Randomly select a shared image
-fname = random.choice(imageio_standard_images)
-image_data = png.write_png(imageio.imread(fname))
-app.assets.add_shared_data('image.png', image_data)
+app.assets.add_shared_data('image.png', get_img_blob(random.choice(image_names)))
 
 
 class Example(ui.Widget):
@@ -45,9 +53,8 @@ class Example(ui.Widget):
     def init(self):
         
         # Randomly select image - different between sessions
-        fname = random.choice(imageio_standard_images)
-        image_data = png.write_png(imageio.imread(fname))
-        link = self.session.add_data('image.png', image_data)
+        link = self.session.add_data('image.png',
+                                     get_img_blob(random.choice(image_names)))
 
         # Create HTML with the two images
         html = '<p>Hit F5 to reload the page (i.e. create a new session'
@@ -61,6 +68,7 @@ class Example(ui.Widget):
 
 if __name__ == '__main__':
     # Launch the app twice to show how different sessions have different data
-    m1 = app.launch(Example, 'browser')
-    m2 = app.launch(Example, 'browser')
+    a = app.App(Example)
+    m1 = a.launch('browser')
+    m2 = a.launch('browser')
     app.run()
diff --git a/flexx/ui/examples/video_viewer.py b/flexx/ui/examples/video_viewer.py
new file mode 100644
index 0000000..0b8df66
--- /dev/null
+++ b/flexx/ui/examples/video_viewer.py
@@ -0,0 +1,69 @@
+# doc-export: VideoViewer
+"""
+This example demonstrates how static files can be served by making use
+of a static file server.
+
+If you intend to create a web application, note that using a static
+server is a potential security risk. Use only when needed. Other options
+that scale better for large websites are e.g. Nginx, Apache, or 3d party
+services like Azure Storage or Amazon S3.
+
+When exported, any links to local files wont work, but the remote links will.
+"""
+
+import os
+
+from flexx import app, ui, event
+
+from tornado.web import StaticFileHandler
+
+
+# The directory to load video's from
+dirname = os.path.expanduser('~/Videos')
+
+# Collect videos that look like they can be read in html5
+videos = {}
+for fname in os.listdir(dirname):
+    if fname.endswith('.mp4'):
+        videos[fname] = '/videos/' + fname
+
+# Add some online videos too, for fun
+videos['bbb.mp4 (online)'] = 'http://www.w3schools.com/tags/mov_bbb.mp4'
+videos['ice-age.mp4 (online)'] = ('https://dl.dropboxusercontent.com/u/1463853/'
+                                  'ice%20age%204%20trailer.mp4')
+
+# Make use of Tornado's static file handler
+tornado_app = app.create_server().app
+tornado_app.add_handlers(r".*", [
+    (r"/videos/(.*)", StaticFileHandler, {"path": dirname}),
+    ])
+
+
+class VideoViewer(ui.Widget):
+    """ A simple videoviewer that displays a list of videos found on the
+    server's computer, plus a few online videos. Note that not all videos
+    may be playable in HTML5.
+    """
+    
+    def init(self):
+        
+        with ui.BoxPanel():
+            with ui.TreeWidget(max_selected=1, flex=1) as self.videolist:
+                for name in sorted(videos):
+                    ui.TreeItem(text=name)
+            
+            self.player = ui.VideoWidget(flex=5)
+    
+    class JS:
+        
+        @event.connect('videolist.items*.selected')
+        def on_select(self, *events):
+            for ev in events:
+                if ev.source.selected:
+                    fname = ev.source.text
+                    self.player.source = videos[fname]
+
+
+if __name__ == '__main__':
+    m = app.launch(VideoViewer, 'firefox')
+    app.run()
diff --git a/flexx/ui/widgets/_media.py b/flexx/ui/widgets/_media.py
index 7e03712..6e6c7c2 100644
--- a/flexx/ui/widgets/_media.py
+++ b/flexx/ui/widgets/_media.py
@@ -38,10 +38,8 @@ Video example:
                     self.vid2 = ui.YoutubeWidget(source='dhRUe-gz690')
 """
 
-import os
-
 from ... import event
-from ...pyscript import window, this_is_js
+from ...pyscript import window
 from . import Widget
 
 
@@ -56,16 +54,8 @@ class ImageWidget(Widget):
         @event.prop
         def source(self, v=''):
             """ The source of the image, This can be anything that an HTML
-            img element supports. Also supported are images on the server,
-            these will be added as data assets so the client can request them.
+            img element supports.
             """
-            if not this_is_js():
-                # Is it an image on the server that we need to serve up?
-                if v.endswith(('.png', '.jpg', '.svg', '.gif')):
-                    if v.startswith('file://') or os.path.isfile(v):
-                        ImageWidget._sequence += 1
-                        fname = 'im' + str(ImageWidget._sequence)
-                        return self.session.add_data(fname, 'file://' + v)
             return str(v)
         
         @event.prop


Test patch:
diff --git a/flexx/app/tests/test_asset.py b/flexx/app/tests/test_asset.py
index 58d33df..e4d58f7 100644
--- a/flexx/app/tests/test_asset.py
+++ b/flexx/app/tests/test_asset.py
@@ -96,12 +96,11 @@ def test_asset():
     assert '\n' not in code  # becasue its a link
     
     # Test asset via uri
-    with open(test_filename, 'wb') as f:
-        f.write('var blablabla=7;'.encode())
-    asset = app.Asset('bar.css', 'file://' + test_filename)
-    assert 'blablabla=7' in asset.to_string()
-    asset = app.Asset('bar.css', 'http://code.jquery.com/jquery-3.1.1.slim.min.js')
-    assert 'jQuery v3.1.1' in asset.to_string()
+    fname = 'file:///home/xx/foobar.css'
+    with raises(TypeError):
+        app.Asset('bar.css', fname)
+    with raises(TypeError):
+        app.Asset(fname)
 
 
 def test_remote_asset():
@@ -147,16 +146,9 @@ def test_remote_asset():
     assert 'http://' not in asset.to_html('{}', 2)
     assert 'http://' in asset.to_html('{}', 3)
     
-    # JS from file
-    asset = app.Asset('file://' + test_filename + '.js')
-    assert asset.remote
-    assert test_filename in asset.source
-    assert 'blablabla=7' in asset.to_string()
-    assert 'blablabla=7' in asset.to_html('{}', 0)
-    assert 'blablabla=7' not in asset.to_html('{}', 1)
-    assert 'blablabla=7' not in asset.to_html('{}', 2)
-    assert 'blablabla=7' not in asset.to_html('{}', 3)
-    
+    # Falis
+    with raises(TypeError):  # JS from file - not allowed
+        app.Asset('file://' + test_filename + '.js')
     with raises(TypeError):
          app.Asset(jquery_url, 'foo=3')  # no sources for remote asset
     with raises(TypeError):
diff --git a/flexx/app/tests/test_assetstore.py b/flexx/app/tests/test_assetstore.py
index 029afec..f28f558 100644
--- a/flexx/app/tests/test_assetstore.py
+++ b/flexx/app/tests/test_assetstore.py
@@ -115,9 +115,10 @@ def test_asset_store_data():
     with raises(ValueError):
         s.add_shared_data('xx', b'zzzz')
     
-    # Add url data
-    s.add_shared_data('readme', 'https://github.com/zoofIO/flexx/blob/master/README.md')
-    assert 'Flexx is' in s.get_data('readme').decode()
+    # # Add url data
+    # s.add_shared_data('readme', 'https://github.com/zoofIO/flexx/blob/master/README.md')
+    # # assert 'Flexx is' in s.get_data('readme').decode()
+    # assert s.get_data('readme').startswith('https://github')
     
     # Add BS data
     with raises(TypeError):
@@ -133,6 +134,26 @@ def test_asset_store_data():
         s.add_shared_data(4, b'zzzz')  # name not a str
 
 
+def test_not_allowing_local_files():
+    """ At some point, flexx allowed adding local files as data, but
+    this was removed for its potential security whole. This test
+    is a remnant to ensure its gone.
+    """
+    
+    s = AssetStore()
+    
+    # Add shared data from local file, dont allow!
+    filename = __file__
+    assert os.path.isfile(filename)
+    with raises(TypeError):
+        s.add_shared_data('testfile3', 'file://' + filename)
+    
+    # Add local file without "file://" prefix
+    if sys.version_info > (3, ):
+        with raises(TypeError):
+            s.add_shared_data('testfile4', filename)
+
+
 def test_asset_store_export():
     
     from flexx import ui
diff --git a/flexx/app/tests/test_session.py b/flexx/app/tests/test_session.py
index bbc8284..4a3c647 100644
--- a/flexx/app/tests/test_session.py
+++ b/flexx/app/tests/test_session.py
@@ -59,9 +59,10 @@ def test_session_assets_data():
     assert s.get_data('zz') is None
     assert s.get_data('ww') is b'wwww'
     
-    # Add url data
-    s.add_data('readme', 'https://github.com/zoofIO/flexx/blob/master/README.md')
-    assert 'Flexx is' in s.get_data('readme').decode()
+    # # Add url data
+    # s.add_data('readme', 'https://github.com/zoofIO/flexx/blob/master/README.md')
+    # #assert 'Flexx is' in s.get_data('readme').decode()
+    # assert s.get_data('readme').startswith('https://github')
     
     # Add data with same name
     with raises(ValueError):