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)
|
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
||||||
.then((session) => {
|
.then((session) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const base = getBackendBaseURL().replace(/\/+$/, "");
|
const framePath = session.frame_path || "";
|
||||||
const path = session.frame_path.startsWith("/")
|
const backendBase = getBackendBaseURL().replace(/\/+$/, "");
|
||||||
? session.frame_path
|
const backendOrigin = backendBase
|
||||||
: `/${session.frame_path}`;
|
? new URL(backendBase, window.location.origin).origin
|
||||||
const nextUrl = new URL(`${base}${path}`);
|
: 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);
|
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
||||||
setFrameSrc(nextUrl.toString());
|
setFrameSrc(nextUrl.toString());
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from urllib.parse import quote, urlencode
|
from urllib.parse import quote, urlencode, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
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("</", "<\\/")
|
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(
|
def _weknora_embed_injection(
|
||||||
*,
|
*,
|
||||||
allowed_path: str,
|
allowed_path: str,
|
||||||
tab: str,
|
tab: str,
|
||||||
session: dict[str, Any],
|
session: dict[str, Any],
|
||||||
ctx: WeKnoraEmbedContext,
|
ctx: WeKnoraEmbedContext,
|
||||||
|
public_prefix: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
tenant_id = _tenant_id_from_session(session)
|
tenant_id = _tenant_id_from_session(session)
|
||||||
user = session.get("user") if isinstance(session.get("user"), dict) else {}
|
user = session.get("user") if isinstance(session.get("user"), dict) else {}
|
||||||
@@ -520,7 +575,8 @@ def _weknora_embed_injection(
|
|||||||
"tenantId": tenant_id,
|
"tenantId": tenant_id,
|
||||||
"user": user,
|
"user": user,
|
||||||
"tenant": tenant,
|
"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)
|
data = _escape_script_json(payload)
|
||||||
return f"""
|
return f"""
|
||||||
@@ -658,8 +714,19 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
|||||||
function rewriteApiUrl(value) {{
|
function rewriteApiUrl(value) {{
|
||||||
try {{
|
try {{
|
||||||
const next = new URL(value, location.origin);
|
const next = new URL(value, location.origin);
|
||||||
if (next.origin !== location.origin || !next.pathname.startsWith("/api/v1/")) return value;
|
if (next.origin !== location.origin) return value;
|
||||||
next.pathname = cfg.apiPrefix + next.pathname;
|
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;
|
return next.href;
|
||||||
}} catch (_) {{
|
}} catch (_) {{
|
||||||
return value;
|
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")
|
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:
|
if "</head>" in text:
|
||||||
text = text.replace("</head>", f"{injection}</head>", 1)
|
text = text.replace("</head>", f"{injection}</head>", 1)
|
||||||
else:
|
else:
|
||||||
@@ -986,8 +1068,19 @@ async def _proxy_weknora_request(
|
|||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
if not is_api and "text/html" in content_type.lower():
|
if not is_api and "text/html" in content_type.lower():
|
||||||
allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}"
|
public_prefix = _request_public_prefix(request)
|
||||||
content = _inject_weknora_embed_html(content, allowed_path=allowed_path, tab=tab, session=session, ctx=ctx)
|
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"
|
content_type = "text/html; charset=utf-8"
|
||||||
|
|
||||||
headers_out = _safe_response_headers(response.headers)
|
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."""
|
"""Create a signed iframe session using the normal DeerFlow API auth path."""
|
||||||
|
|
||||||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
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(
|
response = JSONResponse(
|
||||||
{
|
{
|
||||||
"frame_path": target,
|
"frame_path": target,
|
||||||
@@ -1375,7 +1471,10 @@ async def open_weknora_detail_frame(
|
|||||||
"""Start a short-lived, DeerFlow-authorized WeKnora detail iframe session."""
|
"""Start a short-lived, DeerFlow-authorized WeKnora detail iframe session."""
|
||||||
|
|
||||||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
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)
|
response = RedirectResponse(target, status_code=302)
|
||||||
_set_weknora_embed_cookie(request, response, payload)
|
_set_weknora_embed_cookie(request, response, payload)
|
||||||
return response
|
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}"
|
allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}"
|
||||||
if requested != allowed_path:
|
if requested != allowed_path:
|
||||||
target_query = urlencode({"tab": request.query_params.get("tab") or "wiki"})
|
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)
|
runtime = _runtime_for_iframe_proxy(request)
|
||||||
return await _proxy_weknora_request(
|
return await _proxy_weknora_request(
|
||||||
request,
|
request,
|
||||||
@@ -1438,7 +1538,7 @@ async def proxy_weknora_root_asset(request: Request) -> Response:
|
|||||||
return await _proxy_weknora_request(
|
return await _proxy_weknora_request(
|
||||||
request,
|
request,
|
||||||
target_base_url=runtime.web_base_url,
|
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,
|
ctx=ctx,
|
||||||
is_api=False,
|
is_api=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def _request(
|
|||||||
query: str = "",
|
query: str = "",
|
||||||
cookies: dict[str, str] | None = None,
|
cookies: dict[str, str] | None = None,
|
||||||
request_headers: dict[str, str] | None = None,
|
request_headers: dict[str, str] | None = None,
|
||||||
|
root_path: str = "",
|
||||||
) -> Request:
|
) -> Request:
|
||||||
headers: list[tuple[bytes, bytes]] = [
|
headers: list[tuple[bytes, bytes]] = [
|
||||||
(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()
|
(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()
|
||||||
@@ -40,6 +41,7 @@ def _request(
|
|||||||
"type": "http",
|
"type": "http",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": path,
|
"path": path,
|
||||||
|
"root_path": root_path,
|
||||||
"query_string": query.encode(),
|
"query_string": query.encode(),
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
"scheme": "http",
|
"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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None:
|
async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None:
|
||||||
requests: list[httpx.Request] = []
|
requests: list[httpx.Request] = []
|
||||||
@@ -996,6 +1026,34 @@ def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
|||||||
assert '".main > .aside_box"' in injection
|
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:
|
def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||||
from app.gateway.routers import llmwiki as llmwiki_router
|
from app.gateway.routers import llmwiki as llmwiki_router
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user