diff --git a/datasette/app.py b/datasette/app.py index df6b6d60..011002ee 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -135,7 +135,9 @@ CONFIG_OPTIONS = ( False, "Allow display of template debug information with ?_context=1", ), + ConfigOption("base_url", "/", "Datasette URLs should use this base"), ) + DEFAULT_CONFIG = {option.name: option.default for option in CONFIG_OPTIONS} @@ -573,6 +575,7 @@ class Datasette: "format_bytes": format_bytes, "extra_css_urls": self._asset_urls("extra_css_urls", template, context), "extra_js_urls": self._asset_urls("extra_js_urls", template, context), + "base_url": self.config("base_url"), }, **extra_template_vars, } @@ -736,6 +739,13 @@ class DatasetteRouter(AsgiRouter): self.ds = datasette super().__init__(routes) + async def route_path(self, scope, receive, send, path): + # Strip off base_url if present before routing + base_url = self.ds.config("base_url") + if base_url != "/" and path.startswith(base_url): + path = "/" + path[len(base_url) :] + return await super().route_path(scope, receive, send, path) + async def handle_404(self, scope, receive, send): # If URL has a trailing slash, redirect to URL without it path = scope.get("raw_path", scope["path"].encode("utf8")) diff --git a/datasette/cli.py b/datasette/cli.py index 77ab3542..94da6ee4 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -27,7 +27,7 @@ class Config(click.ParamType): if ":" not in config: self.fail('"{}" should be name:value'.format(config), param, ctx) return - name, value = config.split(":") + name, value = config.split(":", 1) if name not in DEFAULT_CONFIG: self.fail( "{} is not a valid option (--help-config to see all)".format(name), @@ -50,6 +50,8 @@ class Config(click.ParamType): self.fail('"{}" should be an integer'.format(name), param, ctx) return return name, int(value) + elif isinstance(default, str): + return name, value else: # Should never happen: self.fail("Invalid option") diff --git a/datasette/templates/database.html b/datasette/templates/database.html index a0d0fcf6..7d98f0e5 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -11,7 +11,7 @@ {% block nav %}

- home + home

{{ super() }} {% endblock %} diff --git a/datasette/templates/row.html b/datasette/templates/row.html index 5703900d..a92dcc6f 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -17,7 +17,7 @@ {% block nav %}

- home / + home / {{ database }} / {{ table }}

diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 1841300b..fa6766a8 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -18,7 +18,7 @@ {% block nav %}

- home / + home / {{ database }}

{{ super() }} diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index a6c77225..817c7dd8 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -110,6 +110,9 @@ class AsgiRouter: raw_path = scope.get("raw_path") if raw_path: path = raw_path.decode("ascii") + return await self.route_path(scope, receive, send, path) + + async def route_path(self, scope, receive, send, path): for regex, view in self.routes: match = regex.match(path) if match is not None: diff --git a/datasette/views/base.py b/datasette/views/base.py index 3a958d88..0a3045ab 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -64,10 +64,11 @@ class BaseView(AsgiView): def database_url(self, database): db = self.ds.databases[database] + base_url = self.ds.config("base_url") if self.ds.config("hash_urls") and db.hash: - return "/{}-{}".format(database, db.hash[:HASH_LENGTH]) + return "{}{}-{}".format(base_url, database, db.hash[:HASH_LENGTH]) else: - return "/{}".format(database) + return "{}{}".format(base_url, database) def database_color(self, database): return "ff0000" diff --git a/datasette/views/table.py b/datasette/views/table.py index 7267c1db..12bc8572 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -28,9 +28,9 @@ from datasette.filters import Filters from .base import DataView, DatasetteError, ureg LINK_WITH_LABEL = ( - '{label} {id}' + '{label} {id}' ) -LINK_WITH_VALUE = '{id}' +LINK_WITH_VALUE = '{id}' class Row: @@ -100,6 +100,7 @@ class RowTableShared(DataView): } cell_rows = [] + base_url = self.ds.config("base_url") for row in rows: cells = [] # Unless we are a view, the first column is a link - either to the rowid @@ -113,7 +114,8 @@ class RowTableShared(DataView): "is_special_link_column": is_special_link_column, "raw": pk_path, "value": jinja2.Markup( - '{flat_pks}'.format( + '{flat_pks}'.format( + base_url=base_url, database=database, table=urllib.parse.quote_plus(table), flat_pks=str(jinja2.escape(pk_path)), @@ -159,6 +161,7 @@ class RowTableShared(DataView): display_value = jinja2.Markup( link_template.format( database=database, + base_url=base_url, table=urllib.parse.quote_plus(other_table), link_id=urllib.parse.quote_plus(str(value)), id=str(jinja2.escape(value)), diff --git a/docs/config.rst b/docs/config.rst index 199d9455..de2d64ae 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -228,3 +228,16 @@ Some examples: * https://latest.datasette.io/?_context=1 * https://latest.datasette.io/fixtures?_context=1 * https://latest.datasette.io/fixtures/roadside_attractions?_context=1 + +.. _config_base_url: + +base_url +-------- + +If you are running Datasette behind a proxy, it may be useful to change the root URL used for the Datasette instance. + +For example, if you are sending traffic from `https://www.example.com/tools/datasette/` through to a proxied Datasette instance you may wish Datasette to use `/tools/datasette/` as its root URL. + +You can do that like so:: + + datasette mydatabase.db --config base_url:/tools/datasette/ diff --git a/tests/test_api.py b/tests/test_api.py index 5227c52b..7edd7ee6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1307,6 +1307,7 @@ def test_config_json(app_client): "force_https_urls": False, "hash_urls": False, "template_debug": False, + "base_url": "/", } == response.json diff --git a/tests/test_html.py b/tests/test_html.py index d54446c7..5e50f672 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1157,3 +1157,36 @@ def test_metadata_sort_desc(app_client): table = Soup(response.body, "html.parser").find("table") rows = [[str(td) for td in tr.select("td")] for tr in table.select("tbody tr")] assert list(reversed(expected)) == rows + + +@pytest.mark.parametrize("base_url", ["/prefix/", "https://example.com/"]) +@pytest.mark.parametrize( + "path", + [ + "/", + "/fixtures", + "/fixtures/compound_three_primary_keys", + "/fixtures/compound_three_primary_keys/a,a,a", + "/fixtures/paginated_view", + ], +) +def test_base_url_config(base_url, path): + for client in make_app_client(config={"base_url": base_url}): + response = client.get(base_url + path.lstrip("/")) + soup = Soup(response.body, "html.parser") + for a in soup.findAll("a"): + href = a["href"] + if not href.startswith("#") and href not in { + "https://github.com/simonw/datasette", + "https://github.com/simonw/datasette/blob/master/LICENSE", + "https://github.com/simonw/datasette/blob/master/tests/fixtures.py", + }: + # If this has been made absolute it may start http://localhost/ + if href.startswith("http://localhost/"): + href = href[len("http://localost/") :] + assert href.startswith(base_url), { + "base_url": base_url, + "path": path, + "href_in_document": href, + "link_parent": str(a.parent), + }