diff --git a/bazarr/app/app.py b/bazarr/app/app.py
index a303a2bf7..f70d1d895 100644
--- a/bazarr/app/app.py
+++ b/bazarr/app/app.py
@@ -1,6 +1,9 @@
# coding=utf-8
from flask import Flask, redirect
+import os
+
+from flask_cors import CORS
from flask_socketio import SocketIO
from .get_args import args
@@ -18,6 +21,9 @@ def create_app():
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True
app.config['JSON_AS_ASCII'] = False
+ if settings.get('cors', 'enabled'):
+ CORS(app)
+
if args.dev:
app.config["DEBUG"] = True
else:
diff --git a/bazarr/app/config.py b/bazarr/app/config.py
index 85abf7762..b7565e1e1 100644
--- a/bazarr/app/config.py
+++ b/bazarr/app/config.py
@@ -85,6 +85,9 @@ defaults = {
'username': '',
'password': ''
},
+ 'cors': {
+ 'enabled': 'False'
+ },
'backup': {
'folder': os.path.join(args.config_dir, 'backup'),
'retention': '31',
diff --git a/frontend/src/pages/Settings/General/index.tsx b/frontend/src/pages/Settings/General/index.tsx
index fe101bd2f..306dce56d 100644
--- a/frontend/src/pages/Settings/General/index.tsx
+++ b/frontend/src/pages/Settings/General/index.tsx
@@ -115,6 +115,14 @@ const SettingsGeneralView: FunctionComponent = () => {
}
settingKey={settingApiKey}
>
+
+
+ Allow third parties to make requests towards your Bazarr installation.
+ Requires a restart of Bazarr when changed
+
1 or
+ len(origins_to_set) > 1 or
+ any(map(probably_regex, options.get('origins')))):
+ headers.add('Vary', 'Origin')
+
+ return MultiDict((k, v) for k, v in headers.items() if v)
+
+
+def set_cors_headers(resp, options):
+ """
+ Performs the actual evaluation of Flas-CORS options and actually
+ modifies the response object.
+
+ This function is used both in the decorator and the after_request
+ callback
+ """
+
+ # If CORS has already been evaluated via the decorator, skip
+ if hasattr(resp, FLASK_CORS_EVALUATED):
+ LOG.debug('CORS have been already evaluated, skipping')
+ return resp
+
+ # Some libraries, like OAuthlib, set resp.headers to non Multidict
+ # objects (Werkzeug Headers work as well). This is a problem because
+ # headers allow repeated values.
+ if (not isinstance(resp.headers, Headers)
+ and not isinstance(resp.headers, MultiDict)):
+ resp.headers = MultiDict(resp.headers)
+
+ headers_to_set = get_cors_headers(options, request.headers, request.method)
+
+ LOG.debug('Settings CORS headers: %s', str(headers_to_set))
+
+ for k, v in headers_to_set.items():
+ resp.headers.add(k, v)
+
+ return resp
+
+def probably_regex(maybe_regex):
+ if isinstance(maybe_regex, RegexObject):
+ return True
+ else:
+ common_regex_chars = ['*', '\\', ']', '?', '$', '^', '[', ']', '(', ')']
+ # Use common characters used in regular expressions as a proxy
+ # for if this string is in fact a regex.
+ return any((c in maybe_regex for c in common_regex_chars))
+
+def re_fix(reg):
+ """
+ Replace the invalid regex r'*' with the valid, wildcard regex r'/.*' to
+ enable the CORS app extension to have a more user friendly api.
+ """
+ return r'.*' if reg == r'*' else reg
+
+
+def try_match_any(inst, patterns):
+ return any(try_match(inst, pattern) for pattern in patterns)
+
+
+def try_match(request_origin, maybe_regex):
+ """Safely attempts to match a pattern or string to a request origin."""
+ if isinstance(maybe_regex, RegexObject):
+ return re.match(maybe_regex, request_origin)
+ elif probably_regex(maybe_regex):
+ return re.match(maybe_regex, request_origin, flags=re.IGNORECASE)
+ else:
+ try:
+ return request_origin.lower() == maybe_regex.lower()
+ except AttributeError:
+ return request_origin == maybe_regex
+
+
+def get_cors_options(appInstance, *dicts):
+ """
+ Compute CORS options for an application by combining the DEFAULT_OPTIONS,
+ the app's configuration-specified options and any dictionaries passed. The
+ last specified option wins.
+ """
+ options = DEFAULT_OPTIONS.copy()
+ options.update(get_app_kwarg_dict(appInstance))
+ if dicts:
+ for d in dicts:
+ options.update(d)
+
+ return serialize_options(options)
+
+
+def get_app_kwarg_dict(appInstance=None):
+ """Returns the dictionary of CORS specific app configurations."""
+ app = (appInstance or current_app)
+
+ # In order to support blueprints which do not have a config attribute
+ app_config = getattr(app, 'config', {})
+
+ return {
+ k.lower().replace('cors_', ''): app_config.get(k)
+ for k in CONFIG_OPTIONS
+ if app_config.get(k) is not None
+ }
+
+
+def flexible_str(obj):
+ """
+ A more flexible str function which intelligently handles stringifying
+ strings, lists and other iterables. The results are lexographically sorted
+ to ensure generated responses are consistent when iterables such as Set
+ are used.
+ """
+ if obj is None:
+ return None
+ elif(not isinstance(obj, string_types)
+ and isinstance(obj, Iterable)):
+ return ', '.join(str(item) for item in sorted(obj))
+ else:
+ return str(obj)
+
+
+def serialize_option(options_dict, key, upper=False):
+ if key in options_dict:
+ value = flexible_str(options_dict[key])
+ options_dict[key] = value.upper() if upper else value
+
+
+def ensure_iterable(inst):
+ """
+ Wraps scalars or string types as a list, or returns the iterable instance.
+ """
+ if isinstance(inst, string_types):
+ return [inst]
+ elif not isinstance(inst, Iterable):
+ return [inst]
+ else:
+ return inst
+
+def sanitize_regex_param(param):
+ return [re_fix(x) for x in ensure_iterable(param)]
+
+
+def serialize_options(opts):
+ """
+ A helper method to serialize and processes the options dictionary.
+ """
+ options = (opts or {}).copy()
+
+ for key in opts.keys():
+ if key not in DEFAULT_OPTIONS:
+ LOG.warning("Unknown option passed to Flask-CORS: %s", key)
+
+ # Ensure origins is a list of allowed origins with at least one entry.
+ options['origins'] = sanitize_regex_param(options.get('origins'))
+ options['allow_headers'] = sanitize_regex_param(options.get('allow_headers'))
+
+ # This is expressly forbidden by the spec. Raise a value error so people
+ # don't get burned in production.
+ if r'.*' in options['origins'] and options['supports_credentials'] and options['send_wildcard']:
+ raise ValueError("Cannot use supports_credentials in conjunction with"
+ "an origin string of '*'. See: "
+ "http://www.w3.org/TR/cors/#resource-requests")
+
+
+
+ serialize_option(options, 'expose_headers')
+ serialize_option(options, 'methods', upper=True)
+
+ if isinstance(options.get('max_age'), timedelta):
+ options['max_age'] = str(int(options['max_age'].total_seconds()))
+
+ return options
diff --git a/libs/flask_cors/decorator.py b/libs/flask_cors/decorator.py
new file mode 100644
index 000000000..4a529e7be
--- /dev/null
+++ b/libs/flask_cors/decorator.py
@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+"""
+ decorator
+ ~~~~
+ This unit exposes a single decorator which should be used to wrap a
+ Flask route with. It accepts all parameters and options as
+ the CORS extension.
+
+ :copyright: (c) 2016 by Cory Dolphin.
+ :license: MIT, see LICENSE for more details.
+"""
+from functools import update_wrapper
+from flask import make_response, request, current_app
+from .core import *
+
+LOG = logging.getLogger(__name__)
+
+def cross_origin(*args, **kwargs):
+ """
+ This function is the decorator which is used to wrap a Flask route with.
+ In the simplest case, simply use the default parameters to allow all
+ origins in what is the most permissive configuration. If this method
+ modifies state or performs authentication which may be brute-forced, you
+ should add some degree of protection, such as Cross Site Forgery
+ Request protection.
+
+ :param origins:
+ The origin, or list of origins to allow requests from.
+ The origin(s) may be regular expressions, case-sensitive strings,
+ or else an asterisk
+
+ Default : '*'
+ :type origins: list, string or regex
+
+ :param methods:
+ The method or list of methods which the allowed origins are allowed to
+ access for non-simple requests.
+
+ Default : [GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE]
+ :type methods: list or string
+
+ :param expose_headers:
+ The header or list which are safe to expose to the API of a CORS API
+ specification.
+
+ Default : None
+ :type expose_headers: list or string
+
+ :param allow_headers:
+ The header or list of header field names which can be used when this
+ resource is accessed by allowed origins. The header(s) may be regular
+ expressions, case-sensitive strings, or else an asterisk.
+
+ Default : '*', allow all headers
+ :type allow_headers: list, string or regex
+
+ :param supports_credentials:
+ Allows users to make authenticated requests. If true, injects the
+ `Access-Control-Allow-Credentials` header in responses. This allows
+ cookies and credentials to be submitted across domains.
+
+ :note: This option cannot be used in conjuction with a '*' origin
+
+ Default : False
+ :type supports_credentials: bool
+
+ :param max_age:
+ The maximum time for which this CORS request maybe cached. This value
+ is set as the `Access-Control-Max-Age` header.
+
+ Default : None
+ :type max_age: timedelta, integer, string or None
+
+ :param send_wildcard: If True, and the origins parameter is `*`, a wildcard
+ `Access-Control-Allow-Origin` header is sent, rather than the
+ request's `Origin` header.
+
+ Default : False
+ :type send_wildcard: bool
+
+ :param vary_header:
+ If True, the header Vary: Origin will be returned as per the W3
+ implementation guidelines.
+
+ Setting this header when the `Access-Control-Allow-Origin` is
+ dynamically generated (e.g. when there is more than one allowed
+ origin, and an Origin than '*' is returned) informs CDNs and other
+ caches that the CORS headers are dynamic, and cannot be cached.
+
+ If False, the Vary header will never be injected or altered.
+
+ Default : True
+ :type vary_header: bool
+
+ :param automatic_options:
+ Only applies to the `cross_origin` decorator. If True, Flask-CORS will
+ override Flask's default OPTIONS handling to return CORS headers for
+ OPTIONS requests.
+
+ Default : True
+ :type automatic_options: bool
+
+ """
+ _options = kwargs
+
+ def decorator(f):
+ LOG.debug("Enabling %s for cross_origin using options:%s", f, _options)
+
+ # If True, intercept OPTIONS requests by modifying the view function,
+ # replicating Flask's default behavior, and wrapping the response with
+ # CORS headers.
+ #
+ # If f.provide_automatic_options is unset or True, Flask's route
+ # decorator (which is actually wraps the function object we return)
+ # intercepts OPTIONS handling, and requests will not have CORS headers
+ if _options.get('automatic_options', True):
+ f.required_methods = getattr(f, 'required_methods', set())
+ f.required_methods.add('OPTIONS')
+ f.provide_automatic_options = False
+
+ def wrapped_function(*args, **kwargs):
+ # Handle setting of Flask-Cors parameters
+ options = get_cors_options(current_app, _options)
+
+ if options.get('automatic_options') and request.method == 'OPTIONS':
+ resp = current_app.make_default_options_response()
+ else:
+ resp = make_response(f(*args, **kwargs))
+
+ set_cors_headers(resp, options)
+ setattr(resp, FLASK_CORS_EVALUATED, True)
+ return resp
+
+ return update_wrapper(wrapped_function, f)
+ return decorator
diff --git a/libs/flask_cors/extension.py b/libs/flask_cors/extension.py
new file mode 100644
index 000000000..cfeca7320
--- /dev/null
+++ b/libs/flask_cors/extension.py
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+"""
+ extension
+ ~~~~
+ Flask-CORS is a simple extension to Flask allowing you to support cross
+ origin resource sharing (CORS) using a simple decorator.
+
+ :copyright: (c) 2016 by Cory Dolphin.
+ :license: MIT, see LICENSE for more details.
+"""
+from flask import request
+from .core import *
+try:
+ from urllib.parse import unquote_plus
+except ImportError:
+ from urllib import unquote_plus
+
+LOG = logging.getLogger(__name__)
+
+class CORS(object):
+ """
+ Initializes Cross Origin Resource sharing for the application. The
+ arguments are identical to :py:func:`cross_origin`, with the addition of a
+ `resources` parameter. The resources parameter defines a series of regular
+ expressions for resource paths to match and optionally, the associated
+ options to be applied to the particular resource. These options are
+ identical to the arguments to :py:func:`cross_origin`.
+
+ The settings for CORS are determined in the following order
+
+ 1. Resource level settings (e.g when passed as a dictionary)
+ 2. Keyword argument settings
+ 3. App level configuration settings (e.g. CORS_*)
+ 4. Default settings
+
+ Note: as it is possible for multiple regular expressions to match a
+ resource path, the regular expressions are first sorted by length,
+ from longest to shortest, in order to attempt to match the most
+ specific regular expression. This allows the definition of a
+ number of specific resource options, with a wildcard fallback
+ for all other resources.
+
+ :param resources:
+ The series of regular expression and (optionally) associated CORS
+ options to be applied to the given resource path.
+
+ If the argument is a dictionary, it's keys must be regular expressions,
+ and the values must be a dictionary of kwargs, identical to the kwargs
+ of this function.
+
+ If the argument is a list, it is expected to be a list of regular
+ expressions, for which the app-wide configured options are applied.
+
+ If the argument is a string, it is expected to be a regular expression
+ for which the app-wide configured options are applied.
+
+ Default : Match all and apply app-level configuration
+
+ :type resources: dict, iterable or string
+
+ :param origins:
+ The origin, or list of origins to allow requests from.
+ The origin(s) may be regular expressions, case-sensitive strings,
+ or else an asterisk
+
+ Default : '*'
+ :type origins: list, string or regex
+
+ :param methods:
+ The method or list of methods which the allowed origins are allowed to
+ access for non-simple requests.
+
+ Default : [GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE]
+ :type methods: list or string
+
+ :param expose_headers:
+ The header or list which are safe to expose to the API of a CORS API
+ specification.
+
+ Default : None
+ :type expose_headers: list or string
+
+ :param allow_headers:
+ The header or list of header field names which can be used when this
+ resource is accessed by allowed origins. The header(s) may be regular
+ expressions, case-sensitive strings, or else an asterisk.
+
+ Default : '*', allow all headers
+ :type allow_headers: list, string or regex
+
+ :param supports_credentials:
+ Allows users to make authenticated requests. If true, injects the
+ `Access-Control-Allow-Credentials` header in responses. This allows
+ cookies and credentials to be submitted across domains.
+
+ :note: This option cannot be used in conjunction with a '*' origin
+
+ Default : False
+ :type supports_credentials: bool
+
+ :param max_age:
+ The maximum time for which this CORS request maybe cached. This value
+ is set as the `Access-Control-Max-Age` header.
+
+ Default : None
+ :type max_age: timedelta, integer, string or None
+
+ :param send_wildcard: If True, and the origins parameter is `*`, a wildcard
+ `Access-Control-Allow-Origin` header is sent, rather than the
+ request's `Origin` header.
+
+ Default : False
+ :type send_wildcard: bool
+
+ :param vary_header:
+ If True, the header Vary: Origin will be returned as per the W3
+ implementation guidelines.
+
+ Setting this header when the `Access-Control-Allow-Origin` is
+ dynamically generated (e.g. when there is more than one allowed
+ origin, and an Origin than '*' is returned) informs CDNs and other
+ caches that the CORS headers are dynamic, and cannot be cached.
+
+ If False, the Vary header will never be injected or altered.
+
+ Default : True
+ :type vary_header: bool
+ """
+
+ def __init__(self, app=None, **kwargs):
+ self._options = kwargs
+ if app is not None:
+ self.init_app(app, **kwargs)
+
+ def init_app(self, app, **kwargs):
+ # The resources and options may be specified in the App Config, the CORS constructor
+ # or the kwargs to the call to init_app.
+ options = get_cors_options(app, self._options, kwargs)
+
+ # Flatten our resources into a list of the form
+ # (pattern_or_regexp, dictionary_of_options)
+ resources = parse_resources(options.get('resources'))
+
+ # Compute the options for each resource by combining the options from
+ # the app's configuration, the constructor, the kwargs to init_app, and
+ # finally the options specified in the resources dictionary.
+ resources = [
+ (pattern, get_cors_options(app, options, opts))
+ for (pattern, opts) in resources
+ ]
+
+ # Create a human readable form of these resources by converting the compiled
+ # regular expressions into strings.
+ resources_human = {get_regexp_pattern(pattern): opts for (pattern,opts) in resources}
+ LOG.debug("Configuring CORS with resources: %s", resources_human)
+
+ cors_after_request = make_after_request_function(resources)
+ app.after_request(cors_after_request)
+
+ # Wrap exception handlers with cross_origin
+ # These error handlers will still respect the behavior of the route
+ if options.get('intercept_exceptions', True):
+ def _after_request_decorator(f):
+ def wrapped_function(*args, **kwargs):
+ return cors_after_request(app.make_response(f(*args, **kwargs)))
+ return wrapped_function
+
+ if hasattr(app, 'handle_exception'):
+ app.handle_exception = _after_request_decorator(
+ app.handle_exception)
+ app.handle_user_exception = _after_request_decorator(
+ app.handle_user_exception)
+
+def make_after_request_function(resources):
+ def cors_after_request(resp):
+ # If CORS headers are set in a view decorator, pass
+ if resp.headers is not None and resp.headers.get(ACL_ORIGIN):
+ LOG.debug('CORS have been already evaluated, skipping')
+ return resp
+ normalized_path = unquote_plus(request.path)
+ for res_regex, res_options in resources:
+ if try_match(normalized_path, res_regex):
+ LOG.debug("Request to '%s' matches CORS resource '%s'. Using options: %s",
+ request.path, get_regexp_pattern(res_regex), res_options)
+ set_cors_headers(resp, res_options)
+ break
+ else:
+ LOG.debug('No CORS rule matches')
+ return resp
+ return cors_after_request
diff --git a/libs/flask_cors/version.py b/libs/flask_cors/version.py
new file mode 100644
index 000000000..2bbea71d5
--- /dev/null
+++ b/libs/flask_cors/version.py
@@ -0,0 +1 @@
+__version__ = '3.0.10'
diff --git a/libs/version.txt b/libs/version.txt
index 7e5183e01..9118b2512 100644
--- a/libs/version.txt
+++ b/libs/version.txt
@@ -8,6 +8,7 @@ dogpile.cache==1.1.5
enzyme==0.4.1
fese==0.1.2
ffsubsync==0.4.20
+flask-cors==3.0.10
flask-restful==0.3.9
Flask-SocketIO==5.1.1
Flask==2.0.2