From 9fe2bc1a381b27a958b6ddc8d0b67258d290ecb6 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 18 Jan 2015 10:23:49 +0200 Subject: [PATCH 1/7] Support for CORS --- beetsplug/web/__init__.py | 11 ++++++ beetsplug/web/crossdomaindec.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 beetsplug/web/crossdomaindec.py diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index a604616894..24bbf271e5 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -22,6 +22,7 @@ from werkzeug.routing import BaseConverter, PathConverter import os import json +from crossdomaindec import crossdomain # Utilities. @@ -164,6 +165,7 @@ def before_request(): # Items. @app.route('/item/') +@crossdomain(origin='*') @resource('items') def get_item(id): return g.lib.get_item(id) @@ -171,12 +173,14 @@ def get_item(id): @app.route('/item/') @app.route('/item/query/') +@crossdomain(origin='*') @resource_list('items') def all_items(): return g.lib.items() @app.route('/item//file') +@crossdomain(origin='*') def item_file(item_id): item = g.lib.get_item(item_id) response = flask.send_file(item.path, as_attachment=True, @@ -186,6 +190,7 @@ def item_file(item_id): @app.route('/item/query/') +@crossdomain(origin='*') @resource_query('items') def item_query(queries): return g.lib.items(queries) @@ -194,6 +199,7 @@ def item_query(queries): # Albums. @app.route('/album/') +@crossdomain(origin='*') @resource('albums') def get_album(id): return g.lib.get_album(id) @@ -201,18 +207,21 @@ def get_album(id): @app.route('/album/') @app.route('/album/query/') +@crossdomain(origin='*') @resource_list('albums') def all_albums(): return g.lib.albums() @app.route('/album/query/') +@crossdomain(origin='*') @resource_query('albums') def album_query(queries): return g.lib.albums(queries) @app.route('/album//art') +@crossdomain(origin='*') def album_art(album_id): album = g.lib.get_album(album_id) return flask.send_file(album.artpath) @@ -221,6 +230,7 @@ def album_art(album_id): # Artists. @app.route('/artist/') +@crossdomain(origin='*') def all_artists(): with g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT albumartist FROM albums") @@ -231,6 +241,7 @@ def all_artists(): # Library information. @app.route('/stats') +@crossdomain(origin='*') def stats(): with g.lib.transaction() as tx: item_rows = tx.query("SELECT COUNT(*) FROM items") diff --git a/beetsplug/web/crossdomaindec.py b/beetsplug/web/crossdomaindec.py new file mode 100644 index 0000000000..622b0455cc --- /dev/null +++ b/beetsplug/web/crossdomaindec.py @@ -0,0 +1,60 @@ +# Decorator for the HTTP Access Control +# By Armin Ronacher +# http://flask.pocoo.org/snippets/56/ +# +# Cross-site HTTP requests are HTTP requests for resources from a different +# domain than the domain of the resource making the request. +# For instance, a resource loaded from Domain A makes a request for a resource +# on Domain B. The way this is implemented in modern browsers is by using +# HTTP Access Control headers +# +# https://developer.mozilla.org/en/HTTP_access_control +# +# The following view decorator implements this +# + +from datetime import timedelta +from flask import make_response, request, current_app +from functools import update_wrapper + + +def crossdomain(origin=None, methods=None, headers=None, + max_age=21600, attach_to_all=True, + automatic_options=True): + if methods is not None: + methods = ', '.join(sorted(x.upper() for x in methods)) + if headers is not None and not isinstance(headers, basestring): + headers = ', '.join(x.upper() for x in headers) + if not isinstance(origin, basestring): + origin = ', '.join(origin) + if isinstance(max_age, timedelta): + max_age = max_age.total_seconds() + + def get_methods(): + if methods is not None: + return methods + + options_resp = current_app.make_default_options_response() + return options_resp.headers['allow'] + + def decorator(f): + def wrapped_function(*args, **kwargs): + if automatic_options and request.method == 'OPTIONS': + resp = current_app.make_default_options_response() + else: + resp = make_response(f(*args, **kwargs)) + if not attach_to_all and request.method != 'OPTIONS': + return resp + + h = resp.headers + + h['Access-Control-Allow-Origin'] = origin + h['Access-Control-Allow-Methods'] = get_methods() + h['Access-Control-Max-Age'] = str(max_age) + if headers is not None: + h['Access-Control-Allow-Headers'] = headers + return resp + + f.provide_automatic_options = False + return update_wrapper(wrapped_function, f) + return decorator From bd63e1e38662191db9d41afbf4e847c2dfa203ba Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Tue, 20 Jan 2015 00:20:26 +0200 Subject: [PATCH 2/7] Made CORS configurable and changed host default to 127.0.0.1 --- beetsplug/web/__init__.py | 28 +++++++++++++++------------- beetsplug/web/crossdomaindec.py | 18 ++++++++++++++---- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 24bbf271e5..34d33e01ea 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -22,8 +22,7 @@ from werkzeug.routing import BaseConverter, PathConverter import os import json -from crossdomaindec import crossdomain - +from crossdomaindec import crossdomain, set_cors_origin # Utilities. @@ -165,7 +164,7 @@ def before_request(): # Items. @app.route('/item/') -@crossdomain(origin='*') +@crossdomain() @resource('items') def get_item(id): return g.lib.get_item(id) @@ -173,14 +172,14 @@ def get_item(id): @app.route('/item/') @app.route('/item/query/') -@crossdomain(origin='*') +@crossdomain() @resource_list('items') def all_items(): return g.lib.items() @app.route('/item//file') -@crossdomain(origin='*') +@crossdomain() def item_file(item_id): item = g.lib.get_item(item_id) response = flask.send_file(item.path, as_attachment=True, @@ -190,7 +189,7 @@ def item_file(item_id): @app.route('/item/query/') -@crossdomain(origin='*') +@crossdomain() @resource_query('items') def item_query(queries): return g.lib.items(queries) @@ -199,7 +198,7 @@ def item_query(queries): # Albums. @app.route('/album/') -@crossdomain(origin='*') +@crossdomain() @resource('albums') def get_album(id): return g.lib.get_album(id) @@ -207,21 +206,21 @@ def get_album(id): @app.route('/album/') @app.route('/album/query/') -@crossdomain(origin='*') +@crossdomain() @resource_list('albums') def all_albums(): return g.lib.albums() @app.route('/album/query/') -@crossdomain(origin='*') +@crossdomain() @resource_query('albums') def album_query(queries): return g.lib.albums(queries) @app.route('/album//art') -@crossdomain(origin='*') +@crossdomain() def album_art(album_id): album = g.lib.get_album(album_id) return flask.send_file(album.artpath) @@ -230,7 +229,7 @@ def album_art(album_id): # Artists. @app.route('/artist/') -@crossdomain(origin='*') +@crossdomain() def all_artists(): with g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT albumartist FROM albums") @@ -241,7 +240,7 @@ def all_artists(): # Library information. @app.route('/stats') -@crossdomain(origin='*') +@crossdomain() def stats(): with g.lib.transaction() as tx: item_rows = tx.query("SELECT COUNT(*) FROM items") @@ -265,8 +264,9 @@ class WebPlugin(BeetsPlugin): def __init__(self): super(WebPlugin, self).__init__() self.config.add({ - 'host': u'', + 'host': u'127.0.0.1', 'port': 8337, + 'cors_origin': 'http://127.0.0.1', }) def commands(self): @@ -281,6 +281,8 @@ def func(lib, opts, args): if args: self.config['port'] = int(args.pop(0)) + set_cors_origin(self.config['cors_origin']) + app.config['lib'] = lib app.run(host=self.config['host'].get(unicode), port=self.config['port'].get(int), diff --git a/beetsplug/web/crossdomaindec.py b/beetsplug/web/crossdomaindec.py index 622b0455cc..2fde1eae8c 100644 --- a/beetsplug/web/crossdomaindec.py +++ b/beetsplug/web/crossdomaindec.py @@ -12,21 +12,31 @@ # # The following view decorator implements this # +# Note that some changes have been made to the original snippet +# to allow changing the CORS origin after the decorator has been attached +# This was done because the flask routing functions are defined before the +# beetsplug hook is called. from datetime import timedelta from flask import make_response, request, current_app from functools import update_wrapper +cors_origin = 'http://127.0.0.1' -def crossdomain(origin=None, methods=None, headers=None, +def set_cors_origin(origin): + global cors_origin + cors_origin = origin + +def get_cors_origin(): + return cors_origin + +def crossdomain(methods=None, headers=None, max_age=21600, attach_to_all=True, automatic_options=True): if methods is not None: methods = ', '.join(sorted(x.upper() for x in methods)) if headers is not None and not isinstance(headers, basestring): headers = ', '.join(x.upper() for x in headers) - if not isinstance(origin, basestring): - origin = ', '.join(origin) if isinstance(max_age, timedelta): max_age = max_age.total_seconds() @@ -48,7 +58,7 @@ def wrapped_function(*args, **kwargs): h = resp.headers - h['Access-Control-Allow-Origin'] = origin + h['Access-Control-Allow-Origin'] = get_cors_origin() h['Access-Control-Allow-Methods'] = get_methods() h['Access-Control-Max-Age'] = str(max_age) if headers is not None: From c8880de52ce021f6cea5e9ec535c3528d599f6c7 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Tue, 20 Jan 2015 00:38:26 +0200 Subject: [PATCH 3/7] Fixes for flake8 validation --- beetsplug/web/__init__.py | 1 + beetsplug/web/crossdomaindec.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 34d33e01ea..b3114dbab7 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -26,6 +26,7 @@ # Utilities. + def _rep(obj, expand=False): """Get a flat -- i.e., JSON-ish -- representation of a beets Item or Album object. For Albums, `expand` dictates whether tracks are diff --git a/beetsplug/web/crossdomaindec.py b/beetsplug/web/crossdomaindec.py index 2fde1eae8c..8411c6fe85 100644 --- a/beetsplug/web/crossdomaindec.py +++ b/beetsplug/web/crossdomaindec.py @@ -21,15 +21,18 @@ from flask import make_response, request, current_app from functools import update_wrapper -cors_origin = 'http://127.0.0.1' +cors_origin = 'http://127.0.0.1' + def set_cors_origin(origin): global cors_origin cors_origin = origin + def get_cors_origin(): return cors_origin + def crossdomain(methods=None, headers=None, max_age=21600, attach_to_all=True, automatic_options=True): From f47be23658534e4b28d2e7cec3b368a5881c87b3 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Tue, 20 Jan 2015 15:33:33 +0200 Subject: [PATCH 4/7] CORS support now uses flask-cor extension --- beetsplug/web/__init__.py | 25 +++++------ beetsplug/web/crossdomaindec.py | 73 --------------------------------- setup.py | 2 +- 3 files changed, 12 insertions(+), 88 deletions(-) delete mode 100644 beetsplug/web/crossdomaindec.py diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index b3114dbab7..9ab7619e01 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -22,7 +22,6 @@ from werkzeug.routing import BaseConverter, PathConverter import os import json -from crossdomaindec import crossdomain, set_cors_origin # Utilities. @@ -165,7 +164,6 @@ def before_request(): # Items. @app.route('/item/') -@crossdomain() @resource('items') def get_item(id): return g.lib.get_item(id) @@ -173,14 +171,12 @@ def get_item(id): @app.route('/item/') @app.route('/item/query/') -@crossdomain() @resource_list('items') def all_items(): return g.lib.items() @app.route('/item//file') -@crossdomain() def item_file(item_id): item = g.lib.get_item(item_id) response = flask.send_file(item.path, as_attachment=True, @@ -190,7 +186,6 @@ def item_file(item_id): @app.route('/item/query/') -@crossdomain() @resource_query('items') def item_query(queries): return g.lib.items(queries) @@ -199,7 +194,6 @@ def item_query(queries): # Albums. @app.route('/album/') -@crossdomain() @resource('albums') def get_album(id): return g.lib.get_album(id) @@ -207,21 +201,18 @@ def get_album(id): @app.route('/album/') @app.route('/album/query/') -@crossdomain() @resource_list('albums') def all_albums(): return g.lib.albums() @app.route('/album/query/') -@crossdomain() @resource_query('albums') def album_query(queries): return g.lib.albums(queries) @app.route('/album//art') -@crossdomain() def album_art(album_id): album = g.lib.get_album(album_id) return flask.send_file(album.artpath) @@ -230,7 +221,6 @@ def album_art(album_id): # Artists. @app.route('/artist/') -@crossdomain() def all_artists(): with g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT albumartist FROM albums") @@ -241,7 +231,6 @@ def all_artists(): # Library information. @app.route('/stats') -@crossdomain() def stats(): with g.lib.transaction() as tx: item_rows = tx.query("SELECT COUNT(*) FROM items") @@ -267,7 +256,8 @@ def __init__(self): self.config.add({ 'host': u'127.0.0.1', 'port': 8337, - 'cors_origin': 'http://127.0.0.1', + 'cors': False, + 'cors_origin': '*', }) def commands(self): @@ -282,9 +272,16 @@ def func(lib, opts, args): if args: self.config['port'] = int(args.pop(0)) - set_cors_origin(self.config['cors_origin']) - app.config['lib'] = lib + + ## Enable CORS if required + if self.config['cors']: + from flask.ext.cors import CORS + app.config['CORS_ALLOW_HEADERS'] = "Content-Type" + app.config['CORS_RESOURCES'] = { + r"/*": {"origins": self.config['cors_origin'].get(str)} + } + cors = CORS(app) app.run(host=self.config['host'].get(unicode), port=self.config['port'].get(int), debug=opts.debug, threaded=True) diff --git a/beetsplug/web/crossdomaindec.py b/beetsplug/web/crossdomaindec.py deleted file mode 100644 index 8411c6fe85..0000000000 --- a/beetsplug/web/crossdomaindec.py +++ /dev/null @@ -1,73 +0,0 @@ -# Decorator for the HTTP Access Control -# By Armin Ronacher -# http://flask.pocoo.org/snippets/56/ -# -# Cross-site HTTP requests are HTTP requests for resources from a different -# domain than the domain of the resource making the request. -# For instance, a resource loaded from Domain A makes a request for a resource -# on Domain B. The way this is implemented in modern browsers is by using -# HTTP Access Control headers -# -# https://developer.mozilla.org/en/HTTP_access_control -# -# The following view decorator implements this -# -# Note that some changes have been made to the original snippet -# to allow changing the CORS origin after the decorator has been attached -# This was done because the flask routing functions are defined before the -# beetsplug hook is called. - -from datetime import timedelta -from flask import make_response, request, current_app -from functools import update_wrapper - -cors_origin = 'http://127.0.0.1' - - -def set_cors_origin(origin): - global cors_origin - cors_origin = origin - - -def get_cors_origin(): - return cors_origin - - -def crossdomain(methods=None, headers=None, - max_age=21600, attach_to_all=True, - automatic_options=True): - if methods is not None: - methods = ', '.join(sorted(x.upper() for x in methods)) - if headers is not None and not isinstance(headers, basestring): - headers = ', '.join(x.upper() for x in headers) - if isinstance(max_age, timedelta): - max_age = max_age.total_seconds() - - def get_methods(): - if methods is not None: - return methods - - options_resp = current_app.make_default_options_response() - return options_resp.headers['allow'] - - def decorator(f): - def wrapped_function(*args, **kwargs): - if automatic_options and request.method == 'OPTIONS': - resp = current_app.make_default_options_response() - else: - resp = make_response(f(*args, **kwargs)) - if not attach_to_all and request.method != 'OPTIONS': - return resp - - h = resp.headers - - h['Access-Control-Allow-Origin'] = get_cors_origin() - h['Access-Control-Allow-Methods'] = get_methods() - h['Access-Control-Max-Age'] = str(max_age) - if headers is not None: - h['Access-Control-Allow-Headers'] = headers - return resp - - f.provide_automatic_options = False - return update_wrapper(wrapped_function, f) - return decorator diff --git a/setup.py b/setup.py index 36998d39aa..a4d35c6bf8 100755 --- a/setup.py +++ b/setup.py @@ -102,7 +102,7 @@ def _read(fn): 'echonest': ['pyechonest'], 'lastgenre': ['pylast'], 'mpdstats': ['python-mpd'], - 'web': ['flask'], + 'web': ['flask', 'flask-cors'], 'import': ['rarfile'], }, # Non-Python/non-PyPI plugin dependencies: From 47ea4b7d8b835bb2b61d316e9e8925290f66b6b0 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Tue, 20 Jan 2015 16:06:16 +0200 Subject: [PATCH 5/7] flake8 formatting updates --- beetsplug/web/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 9ab7619e01..5a78b50429 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -274,14 +274,14 @@ def func(lib, opts, args): app.config['lib'] = lib - ## Enable CORS if required + # Enable CORS if required if self.config['cors']: from flask.ext.cors import CORS app.config['CORS_ALLOW_HEADERS'] = "Content-Type" app.config['CORS_RESOURCES'] = { r"/*": {"origins": self.config['cors_origin'].get(str)} } - cors = CORS(app) + CORS(app) app.run(host=self.config['host'].get(unicode), port=self.config['port'].get(int), debug=opts.debug, threaded=True) From f8151387587dedc2d8bd70b0e5f1fd32ad00a03e Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Tue, 20 Jan 2015 19:53:04 +0200 Subject: [PATCH 6/7] Combined cors and cors_origin config options into one --- beetsplug/web/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 5a78b50429..cc1666d387 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -23,8 +23,8 @@ import os import json -# Utilities. +# Utilities. def _rep(obj, expand=False): """Get a flat -- i.e., JSON-ish -- representation of a beets Item or @@ -256,8 +256,7 @@ def __init__(self): self.config.add({ 'host': u'127.0.0.1', 'port': 8337, - 'cors': False, - 'cors_origin': '*', + 'cors': '', }) def commands(self): @@ -273,15 +272,16 @@ def func(lib, opts, args): self.config['port'] = int(args.pop(0)) app.config['lib'] = lib - - # Enable CORS if required + # Enable CORS if required. if self.config['cors']: + print "Enabling cors" from flask.ext.cors import CORS app.config['CORS_ALLOW_HEADERS'] = "Content-Type" app.config['CORS_RESOURCES'] = { - r"/*": {"origins": self.config['cors_origin'].get(str)} + r"/*": {"origins": self.config['cors'].get(str)} } CORS(app) + # Start the web application. app.run(host=self.config['host'].get(unicode), port=self.config['port'].get(int), debug=opts.debug, threaded=True) From 5cf869e0f8d3a4809827baaeffc300a1bd11d214 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Tue, 20 Jan 2015 20:52:24 +0200 Subject: [PATCH 7/7] Updated web documentation for CORS --- docs/plugins/web.rst | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst index 63c679e2b5..66b7f110b2 100644 --- a/docs/plugins/web.rst +++ b/docs/plugins/web.rst @@ -20,6 +20,12 @@ flask``. .. _Flask: http://flask.pocoo.org/ +If you require `CORS`_ (Cross-origin resource sharing), then you also +need `flask-cors`_. This can be installed by running ``pip install flask-cors``. + +.. _flask-cors: https://github.com/CoryDolphin/flask-cors +.. _CORS: http://en.wikipedia.org/wiki/Cross-origin_resource_sharing + Finally, enable the ``web`` plugin in your configuration (see :ref:`using-plugins`). @@ -52,10 +58,12 @@ Configuration To configure the plugin, make a ``web:`` section in your configuration file. The available options are: -- **host**: The server hostname. - Default: Bind to all interfaces. +- **host**: The server hostname. Set this to 0.0.0.0 to bind to all interfaces. + Default: Bind to 127.0.0.1. - **port**: The server port. Default: 8337. +- **cors**: The CORS origin. See below. + Default: CORS is disabled. Implementation -------------- @@ -78,6 +86,28 @@ for unsupported formats/browsers. There are a number of options for this: .. _html5media: http://html5media.info/ .. _MediaElement.js: http://mediaelementjs.com/ +Cross-origin resource sharing (CORS) +------------------------------------ + +This is only required if you intend to access the API from a browser using JavaScript and +the JavaScript is not hosted by the beets web server. + +The browser will check if the resources the JavaScript is trying to access is coming from the +same source as the the Script and give an error similar to the following: + +``XMLHttpRequest cannot load http://beets:8337/item/xx. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://webserver' is therefore not allowed access.`` + +To prevent this, `CORS`_ is used. To enable CORS, set the ``cors`` configuration option to the origin +of your JavaScript or set it to ``'*'`` to enable access from all origins. Note that there are +security implications if you set the origin to ``'*'``, please research this before enabling it. + +For example:: + + web: + host: 0.0.0.0 + cors: 'http://webserver' + + JSON API --------