Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/_toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ subtrees:
- file: user-guide/client-logger
- file: user-guide/docker
- file: user-guide/helm
- file: user-guide/serve-behind-reverse-proxy
- file: user-guide/configuration
- file: user-guide/read-custom-formats
- file: user-guide/custom-export-formats
Expand Down
63 changes: 63 additions & 0 deletions docs/source/user-guide/serve-behind-reverse-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Serve Tiled Behind a Reverse Proxy

Tiled can be served on a URL path prefix, such as
`https://example.com/tiled/`, rather than at the root of a domain. Three
pieces have to agree.

## 1. The proxy strips the prefix

Tiled's routes are always registered at the root (`/api/v1/...`, `/ui/...`).
Setting `root_path` does not mount them under the prefix: it tells Tiled what
prefix the *client* sees, so that the URLs it generates are correct. The proxy
must therefore remove the prefix before forwarding, or every request 404s.

```nginx
location /tiled/ {
proxy_pass http://127.0.0.1:8000/; # the trailing slash strips /tiled/

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;

# Required for the streaming API (/api/v1/stream/...), which uses
# websockets. Without these the handshake fails.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```

`location /tiled/` does not match a bare `https://example.com/tiled`. Add a
redirect if you want that to work:

```nginx
location = /tiled {
return 301 /tiled/;
}
```

## 2. The proxy sets `X-Forwarded-Host` and `X-Forwarded-Proto`

Tiled builds the links in its responses from these headers. Without them, the
links point at the internal hostname and scheme (for example
`http://127.0.0.1:8000/...`) instead of the address the client used. See also
the `uvicorn.proxy_headers` and `uvicorn.forwarded_allow_ips` settings in
{doc}`../reference/service-configuration`.

## 3. Tiled is configured with a matching `root_path`

```yaml
uvicorn:
root_path: /tiled
```

This tells Tiled which prefix the client sees. Without it the web UI loads its
assets from `/ui/` and every asset 404s.

## Migrating from `TILED_BUILD_PUBLIC_PATH`

The web UI used to be built with its base path baked in at build time via the
`TILED_BUILD_PUBLIC_PATH` environment variable, which meant a given build could
only be served under one prefix. That variable is now ignored: the base path is
injected per request from `root_path`, so one build serves any prefix. Remove
`TILED_BUILD_PUBLIC_PATH` from your build and set `uvicorn.root_path` instead.
27 changes: 17 additions & 10 deletions hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

from hatchling.builders.hooks.plugin.interface import BuildHookInterface

# Must match tiled.server.app.UI_BASE_TAG, which the server rewrites per request.
# Duplicated because the build hook cannot import tiled.
UI_BASE_TAG = '<base href="/ui/" />'


class CustomHook(BuildHookInterface):
"""
Expand All @@ -31,6 +35,13 @@ def initialize(self, version, build_data):
file=sys.stderr,
)
return
if os.getenv("TILED_BUILD_PUBLIC_PATH"):
print(
"TILED_BUILD_PUBLIC_PATH is ignored. The web UI's base path is "
"now resolved at runtime from the server's root_path; set "
"uvicorn.root_path in the server configuration instead.",
file=sys.stderr,
)
npm_path = shutil.which("npm")
if npm_path is None:
print(
Expand All @@ -44,16 +55,12 @@ def initialize(self, version, build_data):
)
try:
subprocess.check_call([npm_path, "install"], cwd="web-frontend")
subprocess.check_call(
[
npm_path,
"run",
"build",
"--",
f"--base={os.environ.get('TILED_BUILD_PUBLIC_PATH', '/ui/')}",
],
cwd="web-frontend",
)
subprocess.check_call([npm_path, "run", "build"], cwd="web-frontend")
if UI_BASE_TAG not in Path("web-frontend/dist/index.html").read_text():
raise RuntimeError(
f"web-frontend/dist/index.html lacks {UI_BASE_TAG}, so the UI "
"would not work under a URL prefix."
)
if Path(artifact_path).exists():
shutil.rmtree(artifact_path)
shutil.copytree("web-frontend/dist", artifact_path)
Expand Down
2 changes: 1 addition & 1 deletion share/tiled/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ <h1 class="title">Explore from your web browser</h1>
<h2 class="subtitle">
Use a basic, data-oriented web interface to navigate Tiled.
</h2>
<a href="ui/browse/" target="_blank" rel="noreferrer">
<a href="{{ root_url }}/ui/browse/" target="_blank" rel="noreferrer">
<button class="button is-large is-responsive is-link">
Try it
</button>
Expand Down
53 changes: 29 additions & 24 deletions tests/test_authenticators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import logging
import os
import time
from typing import Any, Tuple
from typing import Any, Optional, Tuple
from urllib.parse import parse_qs, urlencode

import httpx
import pytest
Expand All @@ -12,7 +13,6 @@
from jose import ExpiredSignatureError, jwt
from jose.backends import RSAKey
from respx import MockRouter
from starlette.datastructures import URL, QueryParams
from starlette.requests import Request
from starlette.status import HTTP_401_UNAUTHORIZED

Expand Down Expand Up @@ -232,36 +232,34 @@ async def test_proxied_oidc_token_retrieval(well_known_url: str, mock_oidc_serve
assert "FOO" == await authenticator.oauth2_schema(test_request)


def create_mock_OIDC_request(query_params=None):
def create_mock_OIDC_request(
query_params: Optional[dict[str, str]] = None, root_path: str = ""
) -> Request:
"""Helper function to create a realistic request object for testing."""
if query_params is None:
query_params = {}

class MockRequest:
def __init__(self, query_params):
self.query_params = QueryParams(query_params)
self.scope = {
"type": "http",
"scheme": "http",
"server": ("localhost", 8000),
"path": "/api/v1/auth/provider/orcid/code",
"headers": []
}
self.headers = {"host": "localhost:8000"}
self.url = URL("http://localhost:8000/api/v1/auth/provider/orcid/code")

return MockRequest(query_params)
# The ASGI server prepends root_path to path, so the request URL contains it.
return Request({
"type": "http",
"scheme": "http",
"server": ("localhost", 8000),
"path": f"{root_path}/api/v1/auth/provider/orcid/code",
"root_path": root_path,
"headers": [(b"host", b"localhost:8000")],
"query_string": urlencode(query_params or {}).encode(),
})


@pytest.mark.asyncio
@pytest.mark.parametrize("root_path", ["", "/tiled"])
async def test_OIDCAuthenticator_mock(
mock_oidc_server: MockRouter,
well_known_url: str,
well_known_response: dict[str, Any],
monkeypatch
monkeypatch,
root_path: str
):
"""
Test OIDCAuthenticator with mocked external dependencies using respx.
Test OIDCAuthenticator with mocked external dependencies using respx
and the impact of root path on the redirect_uri used in the token exchange.
"""
# Mock JWT token payload
mock_jwt_payload = {
Expand All @@ -274,7 +272,7 @@ async def test_OIDCAuthenticator_mock(
}

# Add token exchange endpoint to existing mock_oidc_server
mock_oidc_server.post(well_known_response["token_endpoint"]).mock(
token_route = mock_oidc_server.post(well_known_response["token_endpoint"]).mock(
return_value=httpx.Response(200, json={
"access_token": "mock-access-token",
"id_token": "mock-id-token",
Expand All @@ -289,7 +287,9 @@ async def test_OIDCAuthenticator_mock(
well_known_uri=well_known_url # Use the fixture
)

mock_request = create_mock_OIDC_request({"code": "test-auth-code"})
mock_request = create_mock_OIDC_request(
{"code": "test-auth-code"}, root_path=root_path
)

def mock_jwt_decode(*args, **kwargs):
return mock_jwt_payload
Expand All @@ -308,6 +308,11 @@ class MockJWK:
assert user_session is not None
assert user_session.user_name == "0009-0008-8698-7745"

form = parse_qs(token_route.calls.last.request.content.decode())
assert form["redirect_uri"] == [
f"http://localhost:8000{root_path}/api/v1/auth/provider/orcid/code"
]


@pytest.mark.asyncio
async def test_OIDCAuthenticator_missing_code_parameter(well_known_url: str):
Expand Down
21 changes: 21 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,24 @@ def test_include_routers(sqlite_or_postgres_uri):
response = client.context.http_client.get(endpoint)
assert response.status_code == 200
assert response.json()["message"] == expected_message


@pytest.mark.parametrize("configured,expected", [("tiled", "/tiled"), ("/", "")])
def test_uvicorn_root_path_is_normalized(configured: str, expected: str):
config = Config.model_validate(
{
"trees": [{"path": "/", "tree": "tiled.examples.generated_minimal:tree"}],
"uvicorn": {"root_path": configured},
}
)
# Both the property and the dict handed to uvicorn must be normalized.
assert config.root_path == expected
assert config.uvicorn["root_path"] == expected


def test_uvicorn_root_path_absent_is_left_alone():
config = Config.model_validate(
{"trees": [{"path": "/", "tree": "tiled.examples.generated_minimal:tree"}]}
)
assert config.root_path == ""
assert "root_path" not in config.uvicorn
97 changes: 97 additions & 0 deletions tests/test_serve_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import re
from pathlib import Path

import pytest
from httpx import ASGITransport, AsyncClient
from starlette.status import HTTP_200_OK

from tiled.server.app import UI_BASE_TAG, build_app

FRONTEND_INDEX_HTML = Path(__file__).parents[1] / "web-frontend" / "index.html"

TILED_INDEX_HTML = (
'<html><head><base href="/ui/" /></head>'
'<body><script src="./assets/app.js"></script></body></html>'
)
VENDORED_INDEX_HTML = (
'<html><head></head><body><script src="./assets/app.js"></script></body></html>'
)


@pytest.fixture
def serve_ui(tmp_path, monkeypatch):
"""Serve `index_html` as the UI distribution and report what a browser gets."""
(tmp_path / "ui").mkdir()
(tmp_path / "templates").mkdir()
(tmp_path / "static").mkdir()
(tmp_path / "static" / "default_ui_settings.yml").write_text(
"api_url: /api/v1\nspecs: []\nstructure_families: {}\n"
)
monkeypatch.setattr("tiled.server.app.SHARE_TILED_PATH", tmp_path)
monkeypatch.delenv("TILED_UI_SETTINGS", raising=False)

async def serve(
index_html=TILED_INDEX_HTML,
*,
root_path="",
path="/ui/browse/deep/path",
):
(tmp_path / "ui" / "index.html").write_text(index_html)
app = build_app({})
transport = ASGITransport(app=app, root_path=root_path)
async with AsyncClient(transport=transport, base_url="http://test") as client:
index = await client.get(path)
settings = await client.get("/tiled-ui-settings")
assert index.status_code == HTTP_200_OK
assert settings.status_code == HTTP_200_OK
return index.text, settings.json()

return serve


def base_href(html):
(href,) = re.findall(r'<base href="([^"]*)"', html)
return href


@pytest.mark.parametrize(
"root_path,expected",
[
pytest.param("", "", id="unmounted"),
pytest.param("/", "", id="trailing-slash-stripped"),
pytest.param("/tenant/ui/tiled", "/tenant/ui/tiled", id="mounted"),
],
)
@pytest.mark.asyncio
async def test_ui_uses_runtime_root_path(serve_ui, root_path, expected):
html, settings = await serve_ui(root_path=root_path)

assert base_href(html) == f"{expected}/ui/"
assert settings["api_url"] == f"{expected}/api/v1"
# Asset URLs stay relative, to be resolved against <base>.
assert 'src="./assets/app.js"' in html


@pytest.mark.parametrize(
"index_html,warns",
[
pytest.param(TILED_INDEX_HTML, False, id="tiled"),
pytest.param(VENDORED_INDEX_HTML, True, id="vendored"),
],
)
@pytest.mark.asyncio
async def test_warns_when_base_tag_missing(serve_ui, caplog, index_html, warns):
await serve_ui(index_html)

assert (UI_BASE_TAG in caplog.text) == warns


@pytest.mark.asyncio
async def test_vendored_ui_is_served_verbatim(serve_ui):
html, _ = await serve_ui(VENDORED_INDEX_HTML, root_path="/tiled", path="/ui/")

assert html == VENDORED_INDEX_HTML


def test_frontend_declares_base_tag_the_server_rewrites():
assert UI_BASE_TAG in FRONTEND_INDEX_HTML.read_text()
14 changes: 14 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,17 @@ def test_internal_authentication_mode_with_password_clients(multiuser_server):
response = httpx.get(multiuser_server + "/api/v1/", headers={})
actual_mode = response.json()["authentication"]["providers"][0]["mode"]
assert actual_mode == "internal"


@pytest.mark.parametrize("root_path", ["", "/tiled"])
def test_about_reports_api_root_path(tmpdir, root_path):
catalog = in_memory(writable_storage=str(tmpdir))
app = build_app(catalog, Authentication(single_user_api_key=API_KEY))
# uvicorn prepends root_path to the request path, as behind a proxy.
config = uvicorn.Config(
app, port=0, loop="asyncio", log_config=LOGGING_CONFIG, root_path=root_path
)
with Server(config).run_in_thread() as url:
response = httpx.get(url + "/api/v1/")

assert response.json()["meta"]["root_path"] == f"{root_path}/api"
Loading
Loading