From 08ab83e9cc304d295cce58e7c81fb4bbe82a56c6 Mon Sep 17 00:00:00 2001 From: benjamsf Date: Sat, 29 Aug 2026 14:28:57 +0300 Subject: [PATCH] fix: interop forward built a double slash, so the product never registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/product/interop/{tgt} answered 200 while the target product never saw the request. In a real deployment that left BattleLog permanently unregistered with matrixrmapi: every /interop/authz came back 403, and the product's room list, invite acceptance and Space search all failed with it. views.py passed "/api/v1/interop/add" — a LEADING slash — into post_to_product, and _method_to_product joins it as f"{productconf['api']}{url_suffix}". Every manifest "api" value ends in "/" (miniwerk manifests.py), so the result was https://matrix.:4626//api/v1/interop/add yarl preserves the doubled slash and Starlette does not route it. Verified directly: yarl 1.24.5 keeps the path as "//api/v1/interop/add", and a mounted FastAPI app answers 200 for the single-slash path and 404 for the doubled one. raise_for_status then turns that 404 into an aiohttp.ClientError, which _method_to_product swallows into None, and add_interop maps None to HTTP 200 with success:false — so the caller is told nothing useful. That call site was the only one in the codebase with a leading slash; every other product call (descriptions, instructions, user CRUD, healthcheck) passes a relative suffix, which is exactly why interop alone failed while everything else worked over the same nginx, port and client certificate. Two changes: the suffix is now relative like all the others, and the join normalises both sides so no future caller can reintroduce it. The second is behaviour-preserving for the existing relative call sites. --- .bumpversion.toml | 2 +- pyproject.toml | 2 +- src/rasenmaeher_api/__init__.py | 2 +- src/rasenmaeher_api/productapihelpers.py | 6 +++++- src/rasenmaeher_api/web/api/product/views.py | 2 +- tests/test_rasenmaeher_api.py | 2 +- uv.lock | 2 +- 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index b5d3fc21..fcac6615 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "1.19.0+260822" +current_version = "1.19.0+260829" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)\\+(?P\\d+)" serialize = ["{major}.{minor}.{patch}+{build}"] search = "{current_version}" diff --git a/pyproject.toml b/pyproject.toml index 08a87cd3..14ab0ad3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rasenmaeher_api" -version = "1.19.0+260822" +version = "1.19.0+260829" description = "python-rasenmaeher-api" authors = [ { name = "Aciid", email = "703382+Aciid@users.noreply.github.com" }, diff --git a/src/rasenmaeher_api/__init__.py b/src/rasenmaeher_api/__init__.py index 184d5e2c..68ae0b0e 100644 --- a/src/rasenmaeher_api/__init__.py +++ b/src/rasenmaeher_api/__init__.py @@ -1,3 +1,3 @@ """python-rasenmaeher-api""" -__version__ = "1.19.0+260822" # NOTE Use `bump-my-version` to bump versions correctly +__version__ = "1.19.0+260829" # NOTE Use `bump-my-version` to bump versions correctly diff --git a/src/rasenmaeher_api/productapihelpers.py b/src/rasenmaeher_api/productapihelpers.py index de8b2133..b1852021 100644 --- a/src/rasenmaeher_api/productapihelpers.py +++ b/src/rasenmaeher_api/productapihelpers.py @@ -122,7 +122,11 @@ async def _method_to_product( session = await get_session_winit() async with session as client: try: - url = f"{productconf['api']}{url_suffix}" + # Normalise the join: every manifest "api" value ends in "/" (see + # miniwerk manifests), so a suffix that also starts with one gave + # "https://host:4626//api/v1/..." — which Starlette does not route, + # so the product 404s and the caller sees only a swallowed None. + url = f"{productconf['api'].rstrip('/')}/{url_suffix.lstrip('/')}" LOGGER.debug(f"calling {methodname}({url})") if data is None: resp = await getattr(client, methodname)(url, timeout=rmconf.integration_api_timeout) diff --git a/src/rasenmaeher_api/web/api/product/views.py b/src/rasenmaeher_api/web/api/product/views.py index 9323d5fd..72bc8a9c 100644 --- a/src/rasenmaeher_api/web/api/product/views.py +++ b/src/rasenmaeher_api/web/api/product/views.py @@ -168,7 +168,7 @@ async def add_interop( raise HTTPException(status_code=500, detail="Manifest does not have products key") if tgtproduct not in manifest["products"]: raise HTTPException(status_code=404, detail=f"Unknown product {tgtproduct}") - resp = await post_to_product(tgtproduct, "/api/v1/interop/add", srcproduct.model_dump(), OperationResultResponse) + resp = await post_to_product(tgtproduct, "api/v1/interop/add", srcproduct.model_dump(), OperationResultResponse) if resp is None: return OperationResultResponse(success=False, error="post_to_product returned None") resp = cast(OperationResultResponse, resp) diff --git a/tests/test_rasenmaeher_api.py b/tests/test_rasenmaeher_api.py index 3882e13f..062a3464 100644 --- a/tests/test_rasenmaeher_api.py +++ b/tests/test_rasenmaeher_api.py @@ -15,7 +15,7 @@ def test_version() -> None: """Make sure version matches expected""" - assert __version__ == "1.19.0+260822" + assert __version__ == "1.19.0+260829" @pytest.mark.asyncio(loop_scope="session") diff --git a/uv.lock b/uv.lock index 922ecf51..f25792a9 100644 --- a/uv.lock +++ b/uv.lock @@ -2110,7 +2110,7 @@ wheels = [ [[package]] name = "rasenmaeher-api" -version = "1.19.0+260822" +version = "1.19.0+260829" source = { editable = "." } dependencies = [ { name = "aiodns" },