diff --git a/lib/cherrypy_cors/__init__.py b/lib/cherrypy_cors/__init__.py new file mode 100644 index 00000000..54003451 --- /dev/null +++ b/lib/cherrypy_cors/__init__.py @@ -0,0 +1,255 @@ +import re + +import cherrypy +from cherrypy.lib import set_vary_header +import httpagentparser + + +CORS_ALLOW_METHODS = 'Access-Control-Allow-Methods' +CORS_ALLOW_ORIGIN = 'Access-Control-Allow-Origin' +CORS_ALLOW_CREDENTIALS = 'Access-Control-Allow-Credentials' +CORS_EXPOSE_HEADERS = 'Access-Control-Expose-Headers' +CORS_REQUEST_METHOD = 'Access-Control-Request-Method' +CORS_REQUEST_HEADERS = 'Access-Control-Request-Headers' +CORS_MAX_AGE = 'Access-Control-Max-Age' +CORS_ALLOW_HEADERS = 'Access-Control-Allow-Headers' +PUBLIC_ORIGIN = '*' + + +def expose(allow_credentials=False, expose_headers=None, origins=None): + """Adds CORS support to the resource. + + If the resource is allowed to be exposed, the value of the + `Access-Control-Allow-Origin`_ header in the response will echo + the `Origin`_ request header, and `Origin` will be + appended to the `Vary`_ response header. + + :param allow_credentials: Use credentials to make cookies work + (see `Access-Control-Allow-Credentials`_). + :type allow_credentials: bool + :param expose_headers: List of headers clients will be able to access + (see `Access-Control-Expose-Headers`_). + :type expose_headers: list or None + :param origins: List of allowed origins clients must reference. + :type origins: list or None + + :returns: Whether the resource is being exposed. + :rtype: bool + + - Configuration example: + + .. code-block:: python + + config = { + '/static': { + 'tools.staticdir.on': True, + 'cors.expose.on': True, + } + } + - Decorator example: + + .. code-block:: python + + @cherrypy_cors.tools.expose() + def DELETE(self): + self._delete() + + """ + if _get_cors().expose(allow_credentials, expose_headers, origins): + _safe_caching_headers() + return True + return False + + +def expose_public(expose_headers=None): + """Adds CORS support to the resource from any origin. + + If the resource is allowed to be exposed, the value of the + `Access-Control-Allow-Origin`_ header in the response will be `*`. + + :param expose_headers: List of headers clients will be able to access + (see `Access-Control-Expose-Headers`_). + :type expose_headers: list or None + + :rtype: None + """ + _get_cors().expose_public(expose_headers) + + +def preflight( + allowed_methods, + allowed_headers=None, + allow_credentials=False, + max_age=None, + origins=None, +): + """Adds CORS `preflight`_ support to a `HTTP OPTIONS` request. + + :param allowed_methods: List of supported `HTTP` methods + (see `Access-Control-Allow-Methods`_). + :type allowed_methods: list or None + :param allowed_headers: List of supported `HTTP` headers + (see `Access-Control-Allow-Headers`_). + :type allowed_headers: list or None + :param allow_credentials: Use credentials to make cookies work + (see `Access-Control-Allow-Credentials`_). + :type allow_credentials: bool + :param max_age: Seconds to cache the preflight request + (see `Access-Control-Max-Age`_). + :type max_age: int + :param origins: List of allowed origins clients must reference. + :type origins: list or None + + :returns: Whether the preflight is allowed. + :rtype: bool + + - Used as a decorator with the `Method Dispatcher`_ + + .. code-block:: python + + @cherrypy_cors.tools.preflight( + allowed_methods=["GET", "DELETE", "PUT"]) + def OPTIONS(self): + pass + + - Function call with the `Object Dispatcher`_ + + .. code-block:: python + + @cherrypy.expose + @cherrypy.tools.allow( + methods=["GET", "DELETE", "PUT", "OPTIONS"]) + def thing(self): + if cherrypy.request.method == "OPTIONS": + cherrypy_cors.preflight( + allowed_methods=["GET", "DELETE", "PUT"]) + else: + self._do_other_things() + + """ + if _get_cors().preflight( + allowed_methods, allowed_headers, allow_credentials, max_age, origins + ): + _safe_caching_headers() + return True + return False + + +def install(): + """Install the toolbox such that it's available in all applications.""" + cherrypy._cptree.Application.toolboxes.update(cors=tools) + + +class CORS: + """A generic CORS handler.""" + + def __init__(self, req_headers, resp_headers): + self.req_headers = req_headers + self.resp_headers = resp_headers + + def expose(self, allow_credentials, expose_headers, origins): + if self._is_valid_origin(origins): + self._add_origin_and_credentials_headers(allow_credentials) + self._add_expose_headers(expose_headers) + return True + return False + + def expose_public(self, expose_headers): + self._add_public_origin() + self._add_expose_headers(expose_headers) + + def preflight( + self, allowed_methods, allowed_headers, allow_credentials, max_age, origins + ): + if self._is_valid_preflight_request(allowed_headers, allowed_methods, origins): + self._add_origin_and_credentials_headers(allow_credentials) + self._add_prefligt_headers(allowed_methods, max_age) + return True + return False + + @property + def origin(self): + return self.req_headers.get('Origin') + + def _is_valid_origin(self, origins): + if origins is None: + origins = [self.origin] + origins = map(self._make_regex, origins) + return self.origin is not None and any( + origin.match(self.origin) for origin in origins + ) + + @staticmethod + def _make_regex(pattern): + if isinstance(pattern, str): + pattern = re.compile(re.escape(pattern) + '$') + return pattern + + def _add_origin_and_credentials_headers(self, allow_credentials): + self.resp_headers[CORS_ALLOW_ORIGIN] = self.origin + if allow_credentials: + self.resp_headers[CORS_ALLOW_CREDENTIALS] = 'true' + + def _add_public_origin(self): + self.resp_headers[CORS_ALLOW_ORIGIN] = PUBLIC_ORIGIN + + def _add_expose_headers(self, expose_headers): + if expose_headers: + self.resp_headers[CORS_EXPOSE_HEADERS] = expose_headers + + @property + def requested_method(self): + return self.req_headers.get(CORS_REQUEST_METHOD) + + @property + def requested_headers(self): + return self.req_headers.get(CORS_REQUEST_HEADERS) + + def _has_valid_method(self, allowed_methods): + return self.requested_method and self.requested_method in allowed_methods + + def _valid_headers(self, allowed_headers): + if self.requested_headers and allowed_headers: + for header in self.requested_headers.split(','): + if header.strip() not in allowed_headers: + return False + return True + + def _is_valid_preflight_request(self, allowed_headers, allowed_methods, origins): + return ( + self._is_valid_origin(origins) + and self._has_valid_method(allowed_methods) + and self._valid_headers(allowed_headers) + ) + + def _add_prefligt_headers(self, allowed_methods, max_age): + rh = self.resp_headers + rh[CORS_ALLOW_METHODS] = ', '.join(allowed_methods) + if max_age: + rh[CORS_MAX_AGE] = max_age + if self.requested_headers: + rh[CORS_ALLOW_HEADERS] = self.requested_headers + + +def _get_cors(): + return CORS(cherrypy.serving.request.headers, cherrypy.serving.response.headers) + + +def _safe_caching_headers(): + """Adds `Origin`_ to the `Vary`_ header to ensure caching works properly. + + Except in IE because it will disable caching completely. The caching + strategy in that case is out of the scope of this library. + https://blogs.msdn.microsoft.com/ieinternals/2009/06/17/vary-with-care/ + """ + uah = cherrypy.serving.request.headers.get('User-Agent', '') + ua = httpagentparser.detect(uah) + IE = 'Microsoft Internet Explorer' + if ua.get('browser', {}).get('name') != IE: + set_vary_header(cherrypy.serving.response, "Origin") + + +tools = cherrypy._cptools.Toolbox("cors") +tools.expose = cherrypy.Tool('before_handler', expose) +tools.expose_public = cherrypy.Tool('before_handler', expose_public) +tools.preflight = cherrypy.Tool('before_handler', preflight) diff --git a/plexpy/webstart.py b/plexpy/webstart.py index 1f3fb7ed..323c7e8e 100644 --- a/plexpy/webstart.py +++ b/plexpy/webstart.py @@ -21,6 +21,7 @@ import sys import cheroot.errors import cherrypy +import cherrypy_cors import plexpy from plexpy import logger @@ -62,6 +63,7 @@ def restart(): def initialize(options): + cherrypy_cors.install() # HTTPS stuff stolen from sickbeard enable_https = options['enable_https'] @@ -91,7 +93,8 @@ def initialize(options): 'server.socket_timeout': 60, 'tools.encode.on': True, 'tools.encode.encoding': 'utf-8', - 'tools.decode.on': True + 'tools.decode.on': True, + 'cors.expose.on': True, } if plexpy.DEV: diff --git a/requirements.txt b/requirements.txt index 0bd14208..120b01ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ bleach==6.2.0 certifi==2024.8.30 cheroot==10.0.1 cherrypy==18.10.0 +cherrypy-cors==1.7.0 cloudinary==1.41.0 distro==1.9.0 dnspython==2.7.0