fix: preserve WeKnora embed proxy prefix
This commit is contained in:
1 parent
3aeb6ac5ce
commit
6dbaf40f99
3 files changed
+180
-17
No files matched your search
+10
-5
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user