fix: preserve WeKnora embed proxy prefix

This commit is contained in:
Administrator committed 2026-08-13 19:17:01 +08:00
1 parent 3aeb6ac5ce
commit 6dbaf40f99
3 files changed
+180 -17

No files matched your search

@@ -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());
})
@@ -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("</", "<\\/")
def _normalize_public_prefix(value: str | None) -> 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<q>["\'(=]){re.escape(path)}(?P<next>[/?"\'\)])',
rf"\g<q>{prefixed}\g<next>",
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 "</head>" in text:
text = text.replace("</head>", f"{injection}</head>", 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,
)
@@ -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'<html><head><script src="/assets/index.js"></script></head><body>fetch("/api/v1/auth/me")</body></html>'
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