diff --git a/etc/storyboard.conf.sample b/etc/storyboard.conf.sample index 427037df..b4ddeb4e 100644 --- a/etc/storyboard.conf.sample +++ b/etc/storyboard.conf.sample @@ -37,6 +37,12 @@ lock_path = $state_path/lock # Port the bind the API server to # bind_port = 8080 +# The default web client. This is the URL to which a client, presenting an +# Accepts: text/html header, will be redirected to when browsing the API. It +# is also used for email URL resolution, so we highly recommend that you set +# this to the host and protocol of your own storyboard server. +# default_client_url = https://storyboard.openstack.org/#! + # Enable notifications. This feature drives deferred processing, reporting, # and subscriptions. # enable_notifications = True diff --git a/storyboard/api/app.py b/storyboard/api/app.py index 8609831c..35a30399 100644 --- a/storyboard/api/app.py +++ b/storyboard/api/app.py @@ -4,7 +4,7 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, @@ -23,6 +23,8 @@ from wsgiref import simple_server from storyboard.api import config as api_config from storyboard.api.middleware.cors_middleware import CORSMiddleware + +from storyboard.api.middleware import redirect_middleware from storyboard.api.middleware import session_hook from storyboard.api.middleware import token_middleware from storyboard.api.middleware import user_id_hook @@ -51,8 +53,11 @@ API_OPTS = [ default=8080, help='API port'), cfg.BoolOpt('enable_notifications', - default=False, - help='Enable Notifications') + default=False, + help='Enable Notifications'), + cfg.StrOpt('default_client_url', + default='https://storyboard.openstack.org/#!', + help='The URL of the default web client.') ] CORS_OPTS = [ cfg.ListOpt('allowed_origins', @@ -115,6 +120,8 @@ def setup_app(pecan_config=None): ) app = token_middleware.AuthTokenMiddleware(app) + app = redirect_middleware. \ + BrowserRedirectMiddleware(app, client_root_url=CONF.default_client_url) # Setup CORS if CONF.cors.allowed_origins: @@ -150,7 +157,7 @@ def start(): % ({'port': port})) else: LOG.info(_LI("serving on http://%(host)s:%(port)s") % ( - {'host': host, 'port': port})) + {'host': host, 'port': port})) srv.serve_forever() diff --git a/storyboard/api/middleware/redirect_middleware.py b/storyboard/api/middleware/redirect_middleware.py new file mode 100644 index 00000000..0bd6ebf2 --- /dev/null +++ b/storyboard/api/middleware/redirect_middleware.py @@ -0,0 +1,70 @@ +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re + +from webob.acceptparse import Accept + + +class BrowserRedirectMiddleware(object): + # A list of HTML Headers that may come from browsers. + html_headers = [ + 'text/html', + 'application/xhtml+xml' + ] + + def __init__(self, app, client_root_url='/'): + """Build an HTTP redirector, with the initial assumption that the + client is installed on the same host as this wsgi app. + + :param app The WSGI app to wrap. + :param client_root_url The root URL of the redirect target's path. + """ + self.app = app + self.client_root_url = client_root_url + + def __call__(self, env, start_response): + # We only care about GET methods. + if env['REQUEST_METHOD'] == 'GET' and 'HTTP_ACCEPT' in env: + # Iterate over the headers. + for type, quality in Accept.parse(env['HTTP_ACCEPT']): + # Only accept quality 1 headers, anything less + # implies that the client prefers something else. + if quality == 1 and type in self.html_headers: + # Build the redirect URL and redirect if successful + redirect_to = self._build_redirect_url(env['PATH_INFO']) + if redirect_to: + start_response("303 See Other", + [('Location', redirect_to)]) + return [] + + # Otherwise, break out of the whole loop and let the + # default handler deal with it. + break + + return self.app(env, start_response) + + def _build_redirect_url(self, path): + # To map to the client, we are assuming that the API adheres to a URL + # pattern of "/superfluous_prefix/v1/other_things. We strip out + # anything up to and including /v1, and use the rest as our redirect + # fragment. Note that this middleware makes no assumption about #! + # navigation, as it is feasible that true HTML5 history support is + # available on the client. + match = re.search('\/v1(\/.*$)', path) + if match: + return self.client_root_url + match.group(1) + else: + return None diff --git a/storyboard/tests/api/middleware/test_redirect_middleware.py b/storyboard/tests/api/middleware/test_redirect_middleware.py new file mode 100644 index 00000000..6c418329 --- /dev/null +++ b/storyboard/tests/api/middleware/test_redirect_middleware.py @@ -0,0 +1,120 @@ +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import six + +from oslo.config import cfg + +from storyboard.tests import base + + +CONF = cfg.CONF + + +class TestRedirectMiddleware(base.FunctionalTest): + # Map of API -> Client urls that we're expecting. + uri_mappings = { + '/v1/projects': 'https://storyboard.openstack.org/#!/projects', + '/v1/stories/22': 'https://storyboard.openstack.org/#!/stories/22', + '/v1/project_groups/2': 'https://storyboard.openstack.org/' + '#!/project_groups/2' + } + + def test_valid_results(self): + """Assert that the expected URI mappings are returned.""" + headers = { + 'Accept': 'text/html;q=1' + } + + for request_uri, redirect_uri in six.iteritems(self.uri_mappings): + response = self.app.get(request_uri, + headers=headers, + expect_errors=True) + + self.assertEqual(303, response.status_code) + self.assertEqual(redirect_uri, response.headers['Location']) + + def test_valid_results_as_post_put_delete(self): + """Assert that POST, PUT, and DELETE methods are passed through to + the API. + """ + headers = { + 'Accept': 'text/html;q=1' + } + + for request_uri, redirect_uri in six.iteritems(self.uri_mappings): + response = self.app.post(request_uri, headers=headers, + expect_errors=True) + self.assertNotEqual(303, response.status_code) + self.assertNotIn('Location', response.headers) + + response = self.app.put(request_uri, headers=headers, + expect_errors=True) + self.assertNotEqual(303, response.status_code) + self.assertNotIn('Location', response.headers) + + response = self.app.delete(request_uri, headers=headers, + expect_errors=True) + self.assertNotEqual(303, response.status_code) + self.assertNotIn('Location', response.headers) + + def test_graceful_accepts_header(self): + """If the client prefers some other content type, make sure we + respect that. + """ + headers = { + 'Accept': 'text/html;q=.9,application/json;q=1' + } + + for request_uri, redirect_uri in six.iteritems(self.uri_mappings): + response = self.app.get(request_uri, + headers=headers, + expect_errors=True) + + self.assertNotEqual(303, response.status_code) + self.assertNotIn('Location', response.headers) + + def test_with_browser_useragent(self): + """Future protection test. Make sure that no other code accidentally + gets in the way of browsers being redirected (such as search engine + bot response handling). This is intended to be a canary for + unexpected changes, rather than a comprehensive test for all possible + browsers. + """ + user_agents = [ + # Chrome 41 + 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML,' + ' like Gecko) Chrome/41.0.2228.0 Safari/537.36', + # Firefox 36 + 'Mozilla/5.0 (Windows NT 6.3; rv:36.0) Gecko/20100101' + ' Firefox/36.0', + # IE10 + 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0)' + ' like Gecko' + ] + + for request_uri, redirect_uri in six.iteritems(self.uri_mappings): + + for user_agent in user_agents: + headers = { + 'User-Agent': user_agent, + 'Accept': 'text/html;q=1' + } + + response = self.app.get(request_uri, + headers=headers, + expect_errors=True) + + self.assertEqual(303, response.status_code) + self.assertEqual(redirect_uri, response.headers['Location'])