diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index 919cb543..ddbff9ac 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -87,11 +87,16 @@ export function WeKnoraKnowledgeDetailPage() { void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab) .then((session) => { if (cancelled) return; - const base = getBackendBaseURL().replace(/\/+$/, ""); - const path = session.frame_path.startsWith("/") - ? session.frame_path - : `/${session.frame_path}`; - const nextUrl = new URL(`${base}${path}`); + const framePath = session.frame_path || ""; + const backendBase = getBackendBaseURL().replace(/\/+$/, ""); + const backendOrigin = backendBase + ? new URL(backendBase, window.location.origin).origin + : window.location.origin; + const nextUrl = /^https?:\/\//i.test(framePath) + ? new URL(framePath) + : framePath.startsWith("/") + ? new URL(framePath, backendOrigin) + : new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`); nextUrl.searchParams.set("theme", collectThemePayload().theme); setFrameSrc(nextUrl.toString()); }) diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index a9b542f2..5e0800e1 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -9,7 +9,7 @@ import re import time from dataclasses import replace from typing import Any, Literal -from urllib.parse import quote, urlencode +from urllib.parse import quote, urlencode, urlsplit import httpx from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile @@ -501,12 +501,67 @@ def _escape_script_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace(" str: + raw = (value or "").strip() + if not raw: + return "" + if "://" in raw: + try: + raw = urlsplit(raw).path + except ValueError: + return "" + if not raw.startswith("/"): + raw = f"/{raw}" + raw = raw.rstrip("/") + return "" if raw == "/" else raw + + +def _request_public_prefix(request: Request) -> str: + headers = request.headers + for key in ("x-forwarded-prefix", "x-script-name"): + prefix = _normalize_public_prefix(headers.get(key)) + if prefix: + return prefix + return _normalize_public_prefix(str(request.scope.get("root_path") or "")) + + +def _with_public_prefix(prefix: str, path: str) -> str: + if not path.startswith("/"): + path = f"/{path}" + return f"{prefix}{path}" if prefix else path + + +def _rewrite_weknora_html_public_paths(html: str, public_prefix: str) -> str: + if not public_prefix: + return html + for path in ( + "/platform", + "/assets", + "/locales", + "/tdesign-icons", + "/api/v1", + "/favicon.ico", + "/favicon.svg", + "/config.js", + "/logo.svg", + "/manifest.webmanifest", + ): + prefixed = f"{public_prefix}{path}" + html = re.sub( + rf'(?P["\'(=]){re.escape(path)}(?P[/?"\'\)])', + rf"\g{prefixed}\g", + html, + ) + return html + + def _weknora_embed_injection( *, allowed_path: str, tab: str, session: dict[str, Any], ctx: WeKnoraEmbedContext, + public_prefix: str = "", ) -> str: tenant_id = _tenant_id_from_session(session) user = session.get("user") if isinstance(session.get("user"), dict) else {} @@ -520,7 +575,8 @@ def _weknora_embed_injection( "tenantId": tenant_id, "user": user, "tenant": tenant, - "apiPrefix": _WEKNORA_EMBED_API_PREFIX, + "publicPrefix": public_prefix, + "apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX), } data = _escape_script_json(payload) return f""" @@ -658,8 +714,19 @@ html.deerflow-weknora-embed [class*=" guide__"] {{ function rewriteApiUrl(value) {{ try {{ const next = new URL(value, location.origin); - if (next.origin !== location.origin || !next.pathname.startsWith("/api/v1/")) return value; - next.pathname = cfg.apiPrefix + next.pathname; + if (next.origin !== location.origin) return value; + const publicPrefix = cfg.publicPrefix || ""; + const apiPath = "/api/v1/"; + const publicApiPath = publicPrefix + apiPath; + let upstreamPath = ""; + if (next.pathname.startsWith(publicApiPath)) {{ + upstreamPath = next.pathname.slice(publicPrefix.length); + }} else if (next.pathname.startsWith(apiPath)) {{ + upstreamPath = next.pathname; + }} else {{ + return value; + }} + next.pathname = cfg.apiPrefix + upstreamPath; return next.href; }} catch (_) {{ return value; @@ -911,9 +978,24 @@ html.deerflow-weknora-embed [class*=" guide__"] {{ """ -def _inject_weknora_embed_html(html: bytes, *, allowed_path: str, tab: str, session: dict[str, Any], ctx: WeKnoraEmbedContext) -> bytes: +def _inject_weknora_embed_html( + html: bytes, + *, + allowed_path: str, + tab: str, + session: dict[str, Any], + ctx: WeKnoraEmbedContext, + public_prefix: str = "", +) -> bytes: text = html.decode("utf-8", errors="replace") - injection = _weknora_embed_injection(allowed_path=allowed_path, tab=tab, session=session, ctx=ctx) + text = _rewrite_weknora_html_public_paths(text, public_prefix) + injection = _weknora_embed_injection( + allowed_path=allowed_path, + tab=tab, + session=session, + ctx=ctx, + public_prefix=public_prefix, + ) if "" in text: text = text.replace("", f"{injection}", 1) else: @@ -986,8 +1068,19 @@ async def _proxy_weknora_request( separators=(",", ":"), ).encode("utf-8") if not is_api and "text/html" in content_type.lower(): - allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}" - content = _inject_weknora_embed_html(content, allowed_path=allowed_path, tab=tab, session=session, ctx=ctx) + public_prefix = _request_public_prefix(request) + allowed_path = _with_public_prefix( + public_prefix, + f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}", + ) + content = _inject_weknora_embed_html( + content, + allowed_path=allowed_path, + tab=tab, + session=session, + ctx=ctx, + public_prefix=public_prefix, + ) content_type = "text/html; charset=utf-8" headers_out = _safe_response_headers(response.headers) @@ -1355,7 +1448,10 @@ async def create_weknora_detail_session( """Create a signed iframe session using the normal DeerFlow API auth path.""" weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id) - target = _weknora_embed_target_path(weknora_id, tab) + target = _with_public_prefix( + _request_public_prefix(request), + _weknora_embed_target_path(weknora_id, tab), + ) response = JSONResponse( { "frame_path": target, @@ -1375,7 +1471,10 @@ async def open_weknora_detail_frame( """Start a short-lived, DeerFlow-authorized WeKnora detail iframe session.""" weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id) - target = _weknora_embed_target_path(weknora_id, tab) + target = _with_public_prefix( + _request_public_prefix(request), + _weknora_embed_target_path(weknora_id, tab), + ) response = RedirectResponse(target, status_code=302) _set_weknora_embed_cookie(request, response, payload) return response @@ -1389,7 +1488,8 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "") allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}" if requested != allowed_path: target_query = urlencode({"tab": request.query_params.get("tab") or "wiki"}) - return RedirectResponse(f"{allowed_path}?{target_query}", status_code=302) + public_target = _with_public_prefix(_request_public_prefix(request), allowed_path) + return RedirectResponse(f"{public_target}?{target_query}", status_code=302) runtime = _runtime_for_iframe_proxy(request) return await _proxy_weknora_request( request, @@ -1438,7 +1538,7 @@ async def proxy_weknora_root_asset(request: Request) -> Response: return await _proxy_weknora_request( request, target_base_url=runtime.web_base_url, - upstream_path=request.url.path, + upstream_path=str(request.scope.get("path") or request.url.path), ctx=ctx, is_api=False, ) diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index 67806c8b..474c8cb1 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -29,6 +29,7 @@ def _request( query: str = "", cookies: dict[str, str] | None = None, request_headers: dict[str, str] | None = None, + root_path: str = "", ) -> Request: headers: list[tuple[bytes, bytes]] = [ (key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items() @@ -40,6 +41,7 @@ def _request( "type": "http", "method": "GET", "path": path, + "root_path": root_path, "query_string": query.encode(), "headers": headers, "scheme": "http", @@ -229,6 +231,34 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo assert ctx.can_write is True +@pytest.mark.asyncio +async def test_weknora_session_endpoint_preserves_reverse_proxy_prefix(monkeypatch: pytest.MonkeyPatch) -> None: + from app.gateway.routers import llmwiki as llmwiki_router + + async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]: + return {"id": "local-kb", "weknora_id": "remote-kb", "name": "KB"}, "user-a", False + + class Store: + async def get_authorized(self, *_: object, **__: object) -> dict[str, str]: + return {"id": "local-kb", "weknora_id": "remote-kb"} + + monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object()) + monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping) + monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store()) + + response = await llmwiki_router.create_weknora_detail_session( + _request( + "/api/llmwiki/knowledge-bases/local-kb/weknora/session", + request_headers={"X-Forwarded-Prefix": "/deerflow"}, + ), + "local-kb", + tab="wiki", + ) + + payload = json.loads(response.body) + assert payload["frame_path"] == "/deerflow/platform/knowledge-bases/remote-kb?tab=wiki" + + @pytest.mark.asyncio async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None: requests: list[httpx.Request] = [] @@ -996,6 +1026,34 @@ def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None: assert '".main > .aside_box"' in injection +def test_weknora_iframe_html_rewrites_public_prefix_paths() -> None: + from app.gateway.routers import llmwiki as llmwiki_router + + ctx = llmwiki_router.WeKnoraEmbedContext( + mapping_id="local-kb", + weknora_id="remote-kb", + user_id="user-a", + is_admin=False, + can_write=False, + exp=4_102_444_800, + ) + html = b'fetch("/api/v1/auth/me")' + + injected = llmwiki_router._inject_weknora_embed_html( + html, + allowed_path="/deerflow/platform/knowledge-bases/remote-kb", + tab="wiki", + session={}, + ctx=ctx, + public_prefix="/deerflow", + ).decode() + + assert 'src="/deerflow/assets/index.js"' in injected + assert 'fetch("/deerflow/api/v1/auth/me")' in injected + assert '"apiPrefix":"/deerflow/api/llmwiki/weknora-embed"' in injected + assert '"allowedPath":"/deerflow/platform/knowledge-bases/remote-kb"' in injected + + def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None: from app.gateway.routers import llmwiki as llmwiki_router