Repository: matrix-org/synapse

Issue:
frontend_proxy worker should be able to handle OPTIONS for /_matrix/media requests
**Description:**

The frontend proxy worker is able to handle HTTP OPTIONS requests for everything except the media repo. If it could also handle these requests (and any future api endpoints), we would be able to route all http options requests to the worker in config, without having to keep a seperate map for OPTIONS requests, compared to GET or PUT.

Currently requests return a 404.


Discussion:
Hm, I see the frontend_proxy proxies all CS API endpoints and does some special handling of key upload and presence endpoints, but nowhere do I see it specifically handle OPTIONS requests.
I don't believe it specifically has code to handle OPTIONS; but it does handle everything except media_repo, and there must be something causing it to 404 those media_repo requests - though this might be configuration or something rather than explicit code changes in that worker.


@anoadragon453: OPTIONS is handled at https://github.com/matrix-org/synapse/blob/release-v1.12.4/synapse/http/server.py#L353. Note that it will only apply to paths where a `JsonResource` has been configured.

`JsonResource`s for the worker apps are registered at https://github.com/matrix-org/synapse/blob/032e5a2acaa26511a00801eedd71e35d21c791fa/synapse/app/generic_worker.py#L484. Note that it will not register a JsonResource for `/media` unless the `media` listener is configured. 

ISTR there is a problem with configuring two workers to both have a media listener (like they both end up trying to do background jobs) so I don't think we can just add `media` to the list of enabled listeners.
So, is the fix to tell people to only run one media worker? Or can something be done to eliminate the bg job conflict?

Could this the background job you're referring to?

https://github.com/matrix-org/synapse/blob/caec7d4fa0041697b7714e638477772f0a827ff6/synapse/rest/media/v1/media_repository.py#L107-L109
> So, is the fix to tell people to only run one media worker? 

we do already do that, I think. The fix to this issue is, well, to make frontend_proxy workers accept OPTIONS requests for media repo endpoints.

> Could this the background job you're referring to?

Possibly. I think it might have been a cleanup job. I wouldn't propose trying to change it now.



Resolving patch:
diff --git a/synapse/app/generic_worker.py b/synapse/app/generic_worker.py
--- a/synapse/app/generic_worker.py
+++ b/synapse/app/generic_worker.py
@@ -22,7 +22,6 @@
 from typing_extensions import ContextManager
 
 from twisted.internet import defer, reactor
-from twisted.web.resource import NoResource
 
 import synapse
 import synapse.events
@@ -41,7 +40,7 @@
 from synapse.federation import send_queue
 from synapse.federation.transport.server import TransportLayerServer
 from synapse.handlers.presence import BasePresenceHandler, get_interested_parties
-from synapse.http.server import JsonResource
+from synapse.http.server import JsonResource, OptionsResource
 from synapse.http.servlet import RestServlet, parse_json_object_from_request
 from synapse.http.site import SynapseSite
 from synapse.logging.context import LoggingContext
@@ -574,7 +573,7 @@ def _listen_http(self, listener_config):
                 if name == "replication":
                     resources[REPLICATION_PREFIX] = ReplicationRestResource(self)
 
-        root_resource = create_resource_tree(resources, NoResource())
+        root_resource = create_resource_tree(resources, OptionsResource())
 
         _base.listen_tcp(
             bind_addresses,
diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py
--- a/synapse/app/homeserver.py
+++ b/synapse/app/homeserver.py
@@ -31,7 +31,7 @@
 from twisted.application import service
 from twisted.internet import defer, reactor
 from twisted.python.failure import Failure
-from twisted.web.resource import EncodingResourceWrapper, IResource, NoResource
+from twisted.web.resource import EncodingResourceWrapper, IResource
 from twisted.web.server import GzipEncoderFactory
 from twisted.web.static import File
 
@@ -52,7 +52,11 @@
 from synapse.config.homeserver import HomeServerConfig
 from synapse.federation.transport.server import TransportLayerServer
 from synapse.http.additional_resource import AdditionalResource
-from synapse.http.server import RootRedirect
+from synapse.http.server import (
+    OptionsResource,
+    RootOptionsRedirectResource,
+    RootRedirect,
+)
 from synapse.http.site import SynapseSite
 from synapse.logging.context import LoggingContext
 from synapse.metrics import METRICS_PREFIX, MetricsResource, RegistryProxy
@@ -121,11 +125,11 @@ def _listener_http(self, config, listener_config):
 
         # try to find something useful to redirect '/' to
         if WEB_CLIENT_PREFIX in resources:
-            root_resource = RootRedirect(WEB_CLIENT_PREFIX)
+            root_resource = RootOptionsRedirectResource(WEB_CLIENT_PREFIX)
         elif STATIC_PREFIX in resources:
-            root_resource = RootRedirect(STATIC_PREFIX)
+            root_resource = RootOptionsRedirectResource(STATIC_PREFIX)
         else:
-            root_resource = NoResource()
+            root_resource = OptionsResource()
 
         root_resource = create_resource_tree(resources, root_resource)
 
diff --git a/synapse/http/server.py b/synapse/http/server.py
--- a/synapse/http/server.py
+++ b/synapse/http/server.py
@@ -350,9 +350,6 @@ def _get_handler_for_request(self, request):
                 register_paths, so will return (possibly via Deferred) either
                 None, or a tuple of (http code, response body).
         """
-        if request.method == b"OPTIONS":
-            return _options_handler, "options_request_handler", {}
-
         request_path = request.path.decode("ascii")
 
         # Loop through all the registered callbacks to check if the method
@@ -448,6 +445,26 @@ def getChild(self, name, request):
         return resource.Resource.getChild(self, name, request)
 
 
+class OptionsResource(resource.Resource):
+    """Responds to OPTION requests for itself and all children."""
+
+    def render_OPTIONS(self, request):
+        code, response_json_object = _options_handler(request)
+
+        return respond_with_json(
+            request, code, response_json_object, send_cors=False, canonical_json=False,
+        )
+
+    def getChildWithDefault(self, path, request):
+        if request.method == b"OPTIONS":
+            return self  # select ourselves as the child to render
+        return resource.Resource.getChildWithDefault(self, path, request)
+
+
+class RootOptionsRedirectResource(OptionsResource, RootRedirect):
+    pass
+
+
 def respond_with_json(
     request,
     code,


Test patch:
diff --git a/tests/test_server.py b/tests/test_server.py
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -27,6 +27,7 @@
 from synapse.http.server import (
     DirectServeResource,
     JsonResource,
+    OptionsResource,
     wrap_html_request_handler,
 )
 from synapse.http.site import SynapseSite, logger
@@ -168,6 +169,58 @@ def _callback(request, **kwargs):
         self.assertEqual(channel.json_body["errcode"], "M_UNRECOGNIZED")
 
 
+class OptionsResourceTests(unittest.TestCase):
+    def setUp(self):
+        self.reactor = ThreadedMemoryReactorClock()
+
+        class DummyResource(Resource):
+            isLeaf = True
+
+            def render(self, request):
+                return request.path
+
+        # Setup a resource with some children.
+        self.resource = OptionsResource()
+        self.resource.putChild(b"res", DummyResource())
+
+    def _make_request(self, method, path):
+        """Create a request from the method/path and return a channel with the response."""
+        request, channel = make_request(self.reactor, method, path, shorthand=False)
+        request.prepath = []  # This doesn't get set properly by make_request.
+
+        # Create a site and query for the resource.
+        site = SynapseSite("test", "site_tag", {}, self.resource, "1.0")
+        request.site = site
+        resource = site.getResourceFor(request)
+
+        # Finally, render the resource and return the channel.
+        render(request, resource, self.reactor)
+        return channel
+
+    def test_unknown_options_request(self):
+        """An OPTIONS requests to an unknown URL still returns 200 OK."""
+        channel = self._make_request(b"OPTIONS", b"/foo/")
+        self.assertEqual(channel.result["code"], b"200")
+        self.assertEqual(channel.result["body"], b"{}")
+
+    def test_known_options_request(self):
+        """An OPTIONS requests to an known URL still returns 200 OK."""
+        channel = self._make_request(b"OPTIONS", b"/res/")
+        self.assertEqual(channel.result["code"], b"200")
+        self.assertEqual(channel.result["body"], b"{}")
+
+    def test_unknown_request(self):
+        """A non-OPTIONS request to an unknown URL should 404."""
+        channel = self._make_request(b"GET", b"/foo/")
+        self.assertEqual(channel.result["code"], b"404")
+
+    def test_known_request(self):
+        """A non-OPTIONS request to an known URL should query the proper resource."""
+        channel = self._make_request(b"GET", b"/res/")
+        self.assertEqual(channel.result["code"], b"200")
+        self.assertEqual(channel.result["body"], b"/res/")
+
+
 class WrapHtmlRequestHandlerTests(unittest.TestCase):
     class TestResource(DirectServeResource):
         callback = None