fix: harden WeKnora proxy and configurable search
This commit is contained in:
1 parent
b527bc94bb
commit
11e54550ed
10 files changed
+721
-72
No files matched your search
@@ -107,6 +107,10 @@ frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
/offline-backend-20260512/backend/backend*.zip
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
|
||||
|
||||
+28
-28
@@ -5,22 +5,22 @@ import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
|
||||
|
||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
// Tailwind 4 emits CSS cascade layers, which require Chrome 99+. Chrome 100
|
||||
// is therefore the practical lower bound for this Win7 legacy build.
|
||||
const LEGACY_CHROME = 100 << 16;
|
||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
// Tailwind 4 emits CSS cascade layers, which require Chrome 99+. Chrome 100
|
||||
// is therefore the practical lower bound for this Win7 legacy build.
|
||||
const LEGACY_CHROME = 100 << 16;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sourceIdentifierPlugin(), react()],
|
||||
css: {
|
||||
// Also used by the production CSS minifier. Development CSS is handled
|
||||
// by the matching PostCSS compatibility pass in postcss.config.js.
|
||||
lightningcss: {
|
||||
targets: {
|
||||
chrome: LEGACY_CHROME,
|
||||
},
|
||||
},
|
||||
},
|
||||
export default defineConfig({
|
||||
plugins: [sourceIdentifierPlugin(), react()],
|
||||
css: {
|
||||
// Also used by the production CSS minifier. Development CSS is handled
|
||||
// by the matching PostCSS compatibility pass in postcss.config.js.
|
||||
lightningcss: {
|
||||
targets: {
|
||||
chrome: LEGACY_CHROME,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
@@ -45,19 +45,19 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
// Keep the current portable-file default, while allowing container builds to
|
||||
// emit absolute asset URLs. The latter is required for BrowserRouter pages
|
||||
// behind Nginx: a request to /page/... must still load /assets/... rather
|
||||
// than /page/assets/....
|
||||
base: process.env.VITE_ASSET_BASE ?? "./",
|
||||
build: {
|
||||
cssMinify: "lightningcss",
|
||||
target: "chrome100",
|
||||
},
|
||||
// Keep the current portable-file default, while allowing container builds to
|
||||
// emit absolute asset URLs. The latter is required for BrowserRouter pages
|
||||
// behind Nginx: a request to /page/... must still load /assets/... rather
|
||||
// than /page/assets/....
|
||||
base: process.env.VITE_ASSET_BASE ?? "./",
|
||||
build: {
|
||||
cssMinify: "lightningcss",
|
||||
target: "chrome100",
|
||||
},
|
||||
server: {
|
||||
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
|
||||
port: 5173,
|
||||
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
|
||||
cors: true,
|
||||
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
|
||||
port: 5173,
|
||||
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
|
||||
cors: true,
|
||||
},
|
||||
});
|
||||
@@ -1153,6 +1153,19 @@ Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vi
|
||||
|
||||
## Documentation
|
||||
|
||||
### WeKnora iframe behind `/deerflow`
|
||||
|
||||
The LLMWiki WeKnora detail iframe is publicly served through the Gateway at
|
||||
`/deerflow`, independently of the frontend deployment prefix (for example
|
||||
`/magentweb`). Register `llmwiki.proxy_router` with the `/deerflow` prefix
|
||||
before its unprefixed fallback router, otherwise the unprefixed catch-all
|
||||
captures `/deerflow/*`. HTML root-relative assets, API strings, and Vite's
|
||||
`__vite__mapDeps` table are rewritten for `/deerflow`; generic JavaScript
|
||||
regular-expression literals must not be rewritten. Auth and CSRF bypass only
|
||||
applies when the short-lived signed WeKnora embed cookie is valid. Keep static
|
||||
path recognition in `app/gateway/weknora_embed.py` synchronized with the
|
||||
proxy's static fallback paths in `app/gateway/routers/llmwiki.py`.
|
||||
|
||||
### Position-roundtable role directory
|
||||
|
||||
`position_roles` is the global shared directory for position-roundtable work
|
||||
|
||||
@@ -82,6 +82,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel.
|
||||
- **Prefixed WeKnora deployment**: the frontend may be published under a separate prefix such as `/magentweb`, while embedded WeKnora detail traffic is served by the Gateway under `/deerflow`. The Gateway rewrites HTML root assets, WeKnora API URLs, redirects, and Vite dynamic dependency maps so page assets stay under `/deerflow`; the prefixed proxy routes must be registered before the unprefixed fallback. The signed embed cookie covers `/deerflow`, and auth/CSRF recognition includes all supported static directories.
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
|
||||
@@ -1646,8 +1646,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# WeKnora's embedded frontend uses root-relative routes such as /platform,
|
||||
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
|
||||
# true fallback and cannot intercept DeerFlow APIs.
|
||||
app.include_router(llmwiki.proxy_router)
|
||||
# Register the public-prefix routes before the unprefixed static fallback.
|
||||
# Otherwise proxy_router's /{proxied_path:path} route consumes every
|
||||
# /deerflow/* request before the prefixed platform/assets routes can match.
|
||||
app.include_router(llmwiki.proxy_router, prefix="/deerflow")
|
||||
app.include_router(llmwiki.proxy_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -49,6 +49,83 @@ proxy_router = APIRouter(tags=["llmwiki-proxy"])
|
||||
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
||||
_WEKNORA_ADMIN_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_WEKNORA_PUBLIC_PREFIX = "/deerflow"
|
||||
_WEKNORA_PUBLIC_ROOT_PATHS = (
|
||||
"/platform",
|
||||
"/assets",
|
||||
"/css",
|
||||
"/fonts",
|
||||
"/img",
|
||||
"/images",
|
||||
"/js",
|
||||
"/locales",
|
||||
"/media",
|
||||
"/static",
|
||||
"/tdesign-icons",
|
||||
"/api/v1",
|
||||
"/favicon.ico",
|
||||
"/favicon.svg",
|
||||
"/config.js",
|
||||
"/logo.svg",
|
||||
"/manifest.webmanifest",
|
||||
)
|
||||
_WEKNORA_PUBLIC_RELATIVE_ROOT_PATHS = (
|
||||
"assets",
|
||||
"css",
|
||||
"fonts",
|
||||
"img",
|
||||
"images",
|
||||
"js",
|
||||
"locales",
|
||||
"media",
|
||||
"static",
|
||||
"tdesign-icons",
|
||||
)
|
||||
_WEKNORA_PUBLIC_RELATIVE_FILE_PATHS = (
|
||||
"config.js",
|
||||
"favicon.ico",
|
||||
"favicon.svg",
|
||||
"logo.svg",
|
||||
"manifest.webmanifest",
|
||||
)
|
||||
_WEKNORA_STATIC_FILE_SUFFIXES = (
|
||||
".avif",
|
||||
".css",
|
||||
".gif",
|
||||
".ico",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".js",
|
||||
".json",
|
||||
".map",
|
||||
".mjs",
|
||||
".png",
|
||||
".svg",
|
||||
".ttf",
|
||||
".txt",
|
||||
".webmanifest",
|
||||
".webp",
|
||||
".woff",
|
||||
".woff2",
|
||||
)
|
||||
_WEKNORA_STATIC_ROOT_PREFIXES = (
|
||||
"/assets/",
|
||||
"/css/",
|
||||
"/fonts/",
|
||||
"/img/",
|
||||
"/images/",
|
||||
"/js/",
|
||||
"/locales/",
|
||||
"/media/",
|
||||
"/static/",
|
||||
"/tdesign-icons/",
|
||||
)
|
||||
_WEKNORA_ROOT_ASSET_FILES = (
|
||||
"/config.js",
|
||||
"/favicon.ico",
|
||||
"/favicon.svg",
|
||||
"/logo.svg",
|
||||
"/manifest.webmanifest",
|
||||
)
|
||||
_HOP_BY_HOP_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
"content-encoding",
|
||||
@@ -399,6 +476,9 @@ async def _authorize_weknora_api_proxy(
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Only WeKnora session introspection is allowed")
|
||||
|
||||
if read_method and tail == ["me", "invitations", "pending-count"]:
|
||||
return
|
||||
|
||||
if tail[:1] == ["knowledge-bases"]:
|
||||
if len(tail) == 1:
|
||||
if method == "GET":
|
||||
@@ -535,30 +615,151 @@ def _without_public_prefix(prefix: str, path: str) -> str:
|
||||
return path
|
||||
|
||||
|
||||
def _rewrite_weknora_html_public_paths(html: str, public_prefix: str) -> str:
|
||||
def _rewrite_weknora_html_public_paths(
|
||||
html: str,
|
||||
public_prefix: str,
|
||||
*,
|
||||
rewrite_relative: bool = True,
|
||||
) -> 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",
|
||||
):
|
||||
for path in _WEKNORA_PUBLIC_ROOT_PATHS:
|
||||
prefixed = f"{public_prefix}{path}"
|
||||
html = re.sub(
|
||||
rf'(?P<q>["\'(=]){re.escape(path)}(?P<next>[/?"\'\)])',
|
||||
rf"\g<q>{prefixed}\g<next>",
|
||||
rf"(?P<prefix>[\"'`(=:\s]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s>]|$)",
|
||||
rf"\g<prefix>{prefixed}",
|
||||
html,
|
||||
)
|
||||
escaped_path = rf"\\/{re.escape(path.lstrip('/'))}"
|
||||
escaped_prefixed = f"\\/{prefixed.lstrip('/')}"
|
||||
html = re.sub(
|
||||
rf"(?<!\\/{re.escape(public_prefix.lstrip('/'))}){escaped_path}(?=[\\/?#\"'`),;\]\}}\s>]|$)",
|
||||
escaped_prefixed,
|
||||
html,
|
||||
)
|
||||
if rewrite_relative:
|
||||
for path in _WEKNORA_PUBLIC_RELATIVE_ROOT_PATHS:
|
||||
prefixed = f"{public_prefix}/{path}"
|
||||
html = re.sub(
|
||||
rf"(?P<prefix>[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=/)",
|
||||
rf"\g<prefix>{prefixed}",
|
||||
html,
|
||||
)
|
||||
for path in _WEKNORA_PUBLIC_RELATIVE_FILE_PATHS:
|
||||
prefixed = f"{public_prefix}/{path}"
|
||||
html = re.sub(
|
||||
rf"(?P<prefix>[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=[?#\"'`),;\]\}}\s>]|$)",
|
||||
rf"\g<prefix>{prefixed}",
|
||||
html,
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
def _rewrite_weknora_script_public_paths(text: str, public_prefix: str) -> str:
|
||||
if not public_prefix:
|
||||
return text
|
||||
for path in _WEKNORA_PUBLIC_ROOT_PATHS:
|
||||
prefixed = f"{public_prefix}{path}"
|
||||
text = re.sub(
|
||||
rf"(?P<quote>[\"'`]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s]|$)",
|
||||
rf"\g<quote>{prefixed}",
|
||||
text,
|
||||
)
|
||||
return _rewrite_weknora_vite_dependency_map(text, public_prefix)
|
||||
|
||||
|
||||
def _should_rewrite_weknora_static_response(content_type: str, upstream_path: str) -> bool:
|
||||
lower_type = content_type.lower()
|
||||
if any(
|
||||
marker in lower_type
|
||||
for marker in (
|
||||
"javascript",
|
||||
"text/css",
|
||||
"application/json",
|
||||
"manifest",
|
||||
"image/svg+xml",
|
||||
)
|
||||
):
|
||||
return True
|
||||
lower_path = upstream_path.lower()
|
||||
return lower_path.endswith((".js", ".css", ".json", ".webmanifest", ".svg"))
|
||||
|
||||
|
||||
def _rewrite_weknora_vite_dependency_map(text: str, public_prefix: str) -> str:
|
||||
if not public_prefix or "__vite__mapDeps" not in text:
|
||||
return text
|
||||
marker = "m.f||(m.f=["
|
||||
start = text.find(marker)
|
||||
if start < 0:
|
||||
return text
|
||||
end = text.find("]))", start + len(marker))
|
||||
if end < 0:
|
||||
return text
|
||||
prefix = public_prefix.strip("/")
|
||||
dependency_map = text[start:end]
|
||||
dependency_map = re.sub(
|
||||
r'(?P<quote>["\'])assets/',
|
||||
rf"\g<quote>{prefix}/assets/",
|
||||
dependency_map,
|
||||
)
|
||||
return f"{text[:start]}{dependency_map}{text[end:]}"
|
||||
|
||||
|
||||
def _weknora_static_fallback_upstream_path(request: Request) -> str | None:
|
||||
path = _without_public_prefix(
|
||||
_request_public_prefix(request),
|
||||
str(request.scope.get("path") or request.url.path),
|
||||
)
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
lower_path = path.lower()
|
||||
if lower_path.startswith("/api/"):
|
||||
return None
|
||||
if lower_path.startswith(_WEKNORA_STATIC_ROOT_PREFIXES):
|
||||
return path
|
||||
for root_prefix in _WEKNORA_STATIC_ROOT_PREFIXES:
|
||||
index = lower_path.find(root_prefix)
|
||||
if index > 0:
|
||||
return path[index:]
|
||||
for asset_file in _WEKNORA_ROOT_ASSET_FILES:
|
||||
if lower_path.endswith(asset_file):
|
||||
return asset_file
|
||||
if lower_path.endswith(_WEKNORA_STATIC_FILE_SUFFIXES):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _rewrite_weknora_location_header(location: str, public_prefix: str, target_base_url: str) -> str:
|
||||
if not location or not public_prefix:
|
||||
return location
|
||||
try:
|
||||
current = urlsplit(location)
|
||||
upstream = urlsplit(target_base_url)
|
||||
except ValueError:
|
||||
return location
|
||||
|
||||
def proxied_path(path: str) -> str:
|
||||
upstream_base_path = upstream.path.rstrip("/")
|
||||
if upstream_base_path and (path == upstream_base_path or path.startswith(f"{upstream_base_path}/")):
|
||||
path = path[len(upstream_base_path) :] or "/"
|
||||
return _without_public_prefix(public_prefix, path)
|
||||
|
||||
suffix = ""
|
||||
if current.query:
|
||||
suffix += f"?{current.query}"
|
||||
if current.fragment:
|
||||
suffix += f"#{current.fragment}"
|
||||
|
||||
if not current.scheme and location.startswith("/"):
|
||||
path = proxied_path(current.path or "/")
|
||||
return f"{public_prefix}{path}{suffix}"
|
||||
|
||||
if current.scheme and current.netloc and current.netloc == upstream.netloc:
|
||||
path = proxied_path(current.path or "/")
|
||||
return f"{public_prefix}{path}{suffix}"
|
||||
return location
|
||||
|
||||
|
||||
def _weknora_embed_injection(
|
||||
*,
|
||||
allowed_path: str,
|
||||
@@ -1058,6 +1259,7 @@ async def _proxy_weknora_request(
|
||||
|
||||
content = response.content
|
||||
content_type = response.headers.get("content-type", "")
|
||||
public_prefix = _request_public_prefix(request)
|
||||
if is_api and request.method.upper() == "GET" and upstream_path.rstrip("/") == "/api/v1/knowledge-bases":
|
||||
content = _filter_knowledge_base_list_response(response, ctx) or content
|
||||
if is_api and ctx.is_conversation_deposit and "application/json" in content_type.lower():
|
||||
@@ -1072,7 +1274,6 @@ async def _proxy_weknora_request(
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
if not is_api and "text/html" in content_type.lower():
|
||||
public_prefix = _request_public_prefix(request)
|
||||
allowed_path = _with_public_prefix(
|
||||
public_prefix,
|
||||
f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}",
|
||||
@@ -1086,8 +1287,35 @@ async def _proxy_weknora_request(
|
||||
public_prefix=public_prefix,
|
||||
)
|
||||
content_type = "text/html; charset=utf-8"
|
||||
elif not is_api and _should_rewrite_weknora_static_response(content_type, upstream_path):
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
lower_type = content_type.lower()
|
||||
if "javascript" in lower_type or upstream_path.lower().endswith((".js", ".mjs")):
|
||||
text = _rewrite_weknora_script_public_paths(text, public_prefix)
|
||||
else:
|
||||
text = _rewrite_weknora_html_public_paths(
|
||||
text,
|
||||
public_prefix,
|
||||
rewrite_relative=False,
|
||||
)
|
||||
content = text.encode("utf-8")
|
||||
|
||||
headers_out = _safe_response_headers(response.headers)
|
||||
if "location" in headers_out:
|
||||
headers_out["location"] = _rewrite_weknora_location_header(
|
||||
headers_out["location"],
|
||||
public_prefix,
|
||||
target_base_url,
|
||||
)
|
||||
if "Location" in headers_out:
|
||||
headers_out["Location"] = _rewrite_weknora_location_header(
|
||||
headers_out["Location"],
|
||||
public_prefix,
|
||||
target_base_url,
|
||||
)
|
||||
if not is_api:
|
||||
headers_out.pop("etag", None)
|
||||
headers_out.pop("ETag", None)
|
||||
headers_out["Cache-Control"] = "no-store" if is_api or "text/html" in content_type.lower() else "private, max-age=3600"
|
||||
return Response(
|
||||
content=content,
|
||||
@@ -1493,6 +1721,16 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
|
||||
requested = f"/platform/{proxied_path}".rstrip("/")
|
||||
allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}"
|
||||
if requested != allowed_path:
|
||||
static_upstream_path = _weknora_static_fallback_upstream_path(request)
|
||||
if static_upstream_path is not None:
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
return await _proxy_weknora_request(
|
||||
request,
|
||||
target_base_url=runtime.web_base_url,
|
||||
upstream_path=static_upstream_path,
|
||||
ctx=ctx,
|
||||
is_api=False,
|
||||
)
|
||||
target_query = urlencode({"tab": request.query_params.get("tab") or "wiki"})
|
||||
public_target = _with_public_prefix(_request_public_prefix(request), allowed_path)
|
||||
return RedirectResponse(f"{public_target}?{target_query}", status_code=302)
|
||||
@@ -1588,6 +1826,10 @@ async def proxy_weknora_api(request: Request, proxied_path: str) -> Response:
|
||||
"/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
|
||||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
)
|
||||
@router.api_route(
|
||||
"/deerflow/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
|
||||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
)
|
||||
async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Response:
|
||||
"""Proxy iframe API calls under a collision-free cmzs namespace."""
|
||||
|
||||
@@ -1604,6 +1846,22 @@ async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Respon
|
||||
)
|
||||
|
||||
|
||||
@proxy_router.api_route("/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
async def proxy_weknora_static_fallback(request: Request, proxied_path: str) -> Response:
|
||||
upstream_path = _weknora_static_fallback_upstream_path(request)
|
||||
if upstream_path is None:
|
||||
raise HTTPException(status_code=404, detail="WeKnora proxy path not found")
|
||||
ctx = await _read_embed_context(request)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
return await _proxy_weknora_request(
|
||||
request,
|
||||
target_base_url=runtime.web_base_url,
|
||||
upstream_path=upstream_path,
|
||||
ctx=ctx,
|
||||
is_api=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/llmwiki/runtime")
|
||||
async def get_llmwiki_runtime() -> dict[str, Any]:
|
||||
return _runtime_or_legacy()
|
||||
|
||||
@@ -28,6 +28,18 @@ WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset(
|
||||
"/manifest.webmanifest",
|
||||
}
|
||||
)
|
||||
WEKNORA_STATIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/assets/",
|
||||
"/css/",
|
||||
"/fonts/",
|
||||
"/img/",
|
||||
"/images/",
|
||||
"/js/",
|
||||
"/locales/",
|
||||
"/media/",
|
||||
"/static/",
|
||||
"/tdesign-icons/",
|
||||
)
|
||||
|
||||
|
||||
def _load_weknora_proxy_secret() -> str:
|
||||
@@ -71,9 +83,7 @@ def is_weknora_embed_proxy_path(path: str) -> bool:
|
||||
return (
|
||||
path == "/platform"
|
||||
or path.startswith("/platform/")
|
||||
or path.startswith("/assets/")
|
||||
or path.startswith("/locales/")
|
||||
or path.startswith("/tdesign-icons/")
|
||||
or path.startswith(WEKNORA_STATIC_PATH_PREFIXES)
|
||||
or path in WEKNORA_ROOT_ASSET_PATHS
|
||||
or path.startswith(f"{WEKNORA_EMBED_API_PREFIX}/api/v1/")
|
||||
or (path.startswith("/api/v1/") and not path.startswith("/api/v1/auth/"))
|
||||
|
||||
+116
-15
@@ -1,12 +1,13 @@
|
||||
"""Configurable JSON-over-HTTP implementation of the ``web_search`` tool."""
|
||||
"""Configurable JSON-over-HTTP implementation of the ``web_search`` tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -19,11 +20,10 @@ from deerflow.config.tool_config import ToolConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _current_time_hint() -> str:
|
||||
now = datetime.now(_BEIJING_TZ)
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S, %A, Asia/Shanghai")
|
||||
_ISO_DATE_RE = re.compile(r"(?P<year>\d{4})(?:[-/.]|\u5e74)(?P<month>\d{1,2})(?:[-/.]|\u6708)(?P<day>\d{1,2})(?:\u65e5)?")
|
||||
_RECENT_DAYS_RE = re.compile(r"(\u8fd1\u51e0\u5929|\u6700\u8fd1\u51e0\u5929|\u8fd9\u51e0\u5929|\u8fd1\u4e00\u5468|\u6700\u8fd1\u4e00\u5468|\u8fd17\u5929|\u6700\u8fd17\u5929)")
|
||||
_RECENT_MONTHS_RE = re.compile(r"(\u8fd1\u671f|\u6700\u8fd1|\u8fd1\u4e09\u4e2a\u6708|\u6700\u8fd1\u4e09\u4e2a\u6708|\u8fd13\u4e2a\u6708|\u6700\u8fd13\u4e2a\u6708)")
|
||||
_TOP_K_RE = re.compile(r"(?:(?:\u8fd4\u56de|\u67e5|\u68c0\u7d22|\u53d6|\u524d)\s*)?(?P<count>\d{1,3})\s*(?:\u6761|\u6761\u6570|\u7bc7|\u4efd)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -112,6 +112,86 @@ def _looks_like_url(value: str) -> bool:
|
||||
return value.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.now(_BEIJING_TZ).date()
|
||||
|
||||
|
||||
def _format_date(value: date) -> str:
|
||||
return value.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _subtract_months(value: date, months: int) -> date:
|
||||
month_index = value.month - 1 - months
|
||||
year = value.year + month_index // 12
|
||||
month = month_index % 12 + 1
|
||||
leap_year = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
||||
month_lengths = [31, 29 if leap_year else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
return value.replace(year=year, month=month, day=min(value.day, month_lengths[month - 1]))
|
||||
|
||||
|
||||
def _normalize_date(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
match = _ISO_DATE_RE.search(text)
|
||||
if not match:
|
||||
return ""
|
||||
try:
|
||||
return _format_date(
|
||||
date(
|
||||
int(match.group("year")),
|
||||
int(match.group("month")),
|
||||
int(match.group("day")),
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_query_dates(query: str) -> tuple[str, str]:
|
||||
dates = [_normalize_date(match.group(0)) for match in _ISO_DATE_RE.finditer(query)]
|
||||
dates = [item for item in dates if item]
|
||||
if len(dates) >= 2:
|
||||
return min(dates[0], dates[1]), max(dates[0], dates[1])
|
||||
if len(dates) == 1:
|
||||
return dates[0], dates[0]
|
||||
return "", ""
|
||||
|
||||
|
||||
def _resolve_date_range(
|
||||
query: str,
|
||||
*,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
today: date | None = None,
|
||||
) -> tuple[str, str]:
|
||||
normalized_start = _normalize_date(start_date)
|
||||
normalized_end = _normalize_date(end_date)
|
||||
if normalized_start or normalized_end:
|
||||
return normalized_start, normalized_end
|
||||
|
||||
query_start, query_end = _extract_query_dates(query)
|
||||
if query_start or query_end:
|
||||
return query_start, query_end
|
||||
|
||||
current = today or _today()
|
||||
if _RECENT_DAYS_RE.search(query):
|
||||
return _format_date(current - timedelta(days=7)), _format_date(current)
|
||||
if _RECENT_MONTHS_RE.search(query):
|
||||
return _format_date(_subtract_months(current, 3)), _format_date(current)
|
||||
return "", ""
|
||||
|
||||
|
||||
def _resolve_top_k(query: str, top_k: str | int = "") -> str:
|
||||
explicit = str(top_k).strip() if top_k is not None else ""
|
||||
if explicit:
|
||||
return explicit
|
||||
match = _TOP_K_RE.search(query)
|
||||
if not match:
|
||||
return ""
|
||||
return match.group("count")
|
||||
|
||||
|
||||
def _error_result(query: str, message: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -183,6 +263,10 @@ async def execute_search(
|
||||
query: str,
|
||||
settings: ConfigurableSearchSettings,
|
||||
*,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
top_k: str | int = "",
|
||||
today: date | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> str:
|
||||
"""Execute a search request; ``transport`` exists for network-free tests."""
|
||||
@@ -194,7 +278,16 @@ async def execute_search(
|
||||
return _error_result(normalized_query, "web_search is disabled")
|
||||
|
||||
payload = deepcopy(settings.payload)
|
||||
resolved_start, resolved_end = _resolve_date_range(
|
||||
normalized_query,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
today=today,
|
||||
)
|
||||
payload["query"] = normalized_query
|
||||
payload["start_date"] = resolved_start
|
||||
payload["end_date"] = resolved_end
|
||||
payload["top_k"] = _resolve_top_k(normalized_query, top_k)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
@@ -234,19 +327,27 @@ async def execute_search(
|
||||
|
||||
|
||||
@tool("web_search", parse_docstring=True)
|
||||
async def web_search_tool(query: str) -> str:
|
||||
"""Search the configured information service for current facts and source passages.
|
||||
async def web_search_tool(query: str, start_date: str = "", end_date: str = "", top_k: str = "") -> str:
|
||||
"""Search the internal knowledge base and return source passages.
|
||||
|
||||
Args:
|
||||
query: Focused search keywords or a natural-language retrieval question.
|
||||
Interpret relative time expressions against the current time in
|
||||
Asia/Shanghai, and include concrete dates/date ranges in the query
|
||||
when the user asks about recent periods.
|
||||
query: Knowledge-base retrieval query. Keep the user's subject and time
|
||||
requirement in this text. If the user says recent/近期/最近, this tool
|
||||
will search the latest three months. If the user says near days/近几天/
|
||||
最近几天/这几天/近一周, this tool will search the latest seven days.
|
||||
start_date: Optional start date in YYYY-MM-DD. Leave empty by default.
|
||||
Pass this only when the user explicitly asks for a concrete date or
|
||||
date range.
|
||||
end_date: Optional end date in YYYY-MM-DD. Leave empty by default.
|
||||
Pass this only when the user explicitly asks for a concrete date or
|
||||
date range.
|
||||
top_k: Optional maximum number of records to return, such as "15".
|
||||
Leave empty by default. Pass this when the user asks for a specific
|
||||
number of results.
|
||||
"""
|
||||
|
||||
try:
|
||||
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
|
||||
except ValueError as exc:
|
||||
return _error_result(query, str(exc))
|
||||
grounded_query = f"{query.strip()}\n当前时间: {_current_time_hint()}" if query.strip() else query
|
||||
return await execute_search(grounded_query, settings)
|
||||
return await execute_search(query, settings, start_date=start_date, end_date=end_date, top_k=top_k)
|
||||
+78
-8
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -35,15 +36,20 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
assert request.method == "POST"
|
||||
assert json.loads(request.content) == {"query": "测试检索词", "xxx": "xxxx"}
|
||||
payload = json.loads(request.content)
|
||||
assert payload["query"].strip()
|
||||
assert payload["xxx"] == "xxxx"
|
||||
assert payload["start_date"] == ""
|
||||
assert payload["end_date"] == ""
|
||||
assert payload["top_k"] == ""
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"results": [
|
||||
{
|
||||
"recUuid": "record/123",
|
||||
"content": "检索内容",
|
||||
"title": "标题",
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "https://original.example.test/one",
|
||||
},
|
||||
{
|
||||
@@ -60,7 +66,7 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
|
||||
settings = _settings()
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
" 测试检索词 ",
|
||||
" test query ",
|
||||
settings,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
@@ -69,13 +75,13 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
|
||||
assert len(requests) == 1
|
||||
assert settings.payload == {"query": "configured value", "xxx": "xxxx"}
|
||||
assert result == {
|
||||
"query": "测试检索词",
|
||||
"query": "test query",
|
||||
"total_results": 2,
|
||||
"results": [
|
||||
{
|
||||
"recUuid": "record/123",
|
||||
"content": "检索内容",
|
||||
"title": "标题",
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "https://search.example.test/mindId/xxx?recUuid=record%2F123",
|
||||
},
|
||||
{
|
||||
@@ -104,6 +110,70 @@ async def test_configurable_search_handles_empty_results() -> None:
|
||||
assert result == {"query": "nothing", "total_results": 0, "results": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("query", "expected_start", "expected_end"),
|
||||
[
|
||||
("近期政策", "2026-05-13", "2026-08-13"),
|
||||
("近几天政策", "2026-08-06", "2026-08-13"),
|
||||
("查询 2026年8月10日 到 2026-08-13 的政策", "2026-08-10", "2026-08-13"),
|
||||
],
|
||||
)
|
||||
async def test_configurable_search_adds_date_payload(
|
||||
query: str,
|
||||
expected_start: str,
|
||||
expected_end: str,
|
||||
) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content)
|
||||
assert payload["start_date"] == expected_start
|
||||
assert payload["end_date"] == expected_end
|
||||
assert payload["top_k"] == ""
|
||||
return httpx.Response(200, json={"results": []})
|
||||
|
||||
await execute_search(
|
||||
query,
|
||||
_settings(),
|
||||
today=date(2026, 8, 13),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_explicit_date_args_and_top_k_win() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content)
|
||||
assert payload["start_date"] == "2026-08-10"
|
||||
assert payload["end_date"] == "2026-08-13"
|
||||
assert payload["top_k"] == "15"
|
||||
return httpx.Response(200, json={"results": []})
|
||||
|
||||
await execute_search(
|
||||
"近期政策",
|
||||
_settings(),
|
||||
start_date="2026/8/10",
|
||||
end_date="2026年8月13日",
|
||||
top_k=15,
|
||||
today=date(2026, 8, 13),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_infers_top_k_from_query() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content)
|
||||
assert payload["top_k"] == "15"
|
||||
return httpx.Response(200, json={"results": []})
|
||||
|
||||
await execute_search(
|
||||
"近期政策 返回15条",
|
||||
_settings(),
|
||||
today=date(2026, 8, 13),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_accepts_root_array_results() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
|
||||
@@ -5,7 +5,7 @@ from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from starlette.datastructures import Headers, QueryParams
|
||||
from starlette.requests import Request
|
||||
|
||||
@@ -178,6 +178,18 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None:
|
||||
)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/assets/index.js", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/assets/index.js", cookies=cookies))
|
||||
for static_path in (
|
||||
"/deerflow/css/app.css",
|
||||
"/deerflow/fonts/app.woff2",
|
||||
"/deerflow/img/logo.png",
|
||||
"/deerflow/images/empty.svg",
|
||||
"/deerflow/js/app.js",
|
||||
"/deerflow/media/intro.mp4",
|
||||
"/deerflow/static/chunk.js",
|
||||
):
|
||||
request = _request(static_path, cookies=cookies)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(request)
|
||||
assert csrf_middleware.is_weknora_embed_proxy_request(request)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies)
|
||||
@@ -1054,7 +1066,175 @@ def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None:
|
||||
== "/deerflow/platform/{proxied_path:path}"
|
||||
)
|
||||
assert _first_gateway_match("GET", "/deerflow/assets/index.js") == "/deerflow/assets/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/deerflow/js/index.js") == "/deerflow/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/deerflow/css/index.css") == "/deerflow/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/deerflow/fonts/index.woff2") == "/deerflow/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/deerflow/api/v1/knowledge-search") == "/deerflow/api/v1/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/js/index.js") == "/{proxied_path:path}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("location", "target_base_url", "expected"),
|
||||
[
|
||||
("/platform/knowledge-bases/kb?tab=wiki", "http://weknora-web:8080", "/deerflow/platform/knowledge-bases/kb?tab=wiki"),
|
||||
("/deerflow/platform/knowledge-bases/kb", "http://weknora-web:8080", "/deerflow/platform/knowledge-bases/kb"),
|
||||
("/deerflow-api/api/v1/auth/me", "http://gateway/deerflow-api", "/deerflow/api/v1/auth/me"),
|
||||
(
|
||||
"http://gateway/deerflow-api/api/v1/auth/me?next=1#top",
|
||||
"http://gateway/deerflow-api",
|
||||
"/deerflow/api/v1/auth/me?next=1#top",
|
||||
),
|
||||
("https://external.test/login", "http://gateway/deerflow-api", "https://external.test/login"),
|
||||
],
|
||||
)
|
||||
def test_weknora_redirect_location_is_normalized_for_public_prefix(
|
||||
location: str,
|
||||
target_base_url: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
assert llmwiki_router._rewrite_weknora_location_header(location, "/deerflow", target_base_url) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_auth_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
from app.gateway.weknora_embed import WEKNORA_EMBED_COOKIE, sign_weknora_embed_payload
|
||||
|
||||
class Store:
|
||||
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
||||
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
||||
|
||||
runtime = SimpleNamespace(
|
||||
web_base_url="http://weknora-web.test",
|
||||
api_base_url="http://weknora-api.test",
|
||||
)
|
||||
upstream_requests: list[tuple[str, str, dict[str, str]]] = []
|
||||
|
||||
async def fake_session(_: object, *, force_refresh: bool = False) -> dict[str, object]:
|
||||
return {"token": "admin-token", "tenant": {"id": "tenant-1"}}
|
||||
|
||||
class FakeUpstreamClient:
|
||||
def __init__(self, **_: object) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> FakeUpstreamClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, str],
|
||||
**__: object,
|
||||
) -> httpx.Response:
|
||||
upstream_requests.append((method, url, headers))
|
||||
path = httpx.URL(url).path
|
||||
if url.startswith(runtime.api_base_url):
|
||||
return httpx.Response(200, json={"success": True, "data": {"id": "admin"}})
|
||||
if path == "/platform/knowledge-bases/remote-kb":
|
||||
return httpx.Response(
|
||||
200,
|
||||
text=(
|
||||
'<html><head><script src="/assets/main.js"></script>'
|
||||
'<link rel="stylesheet" href="css/app.css">'
|
||||
'<script src="/config.js"></script></head><body></body></html>'
|
||||
),
|
||||
headers={"content-type": "text/html; charset=utf-8"},
|
||||
)
|
||||
if path == "/assets/main.js":
|
||||
return httpx.Response(
|
||||
200,
|
||||
text=(
|
||||
'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=['
|
||||
'"assets/lazy.css","assets/lazy.js"])))=>i.map(i=>d[i]);'
|
||||
'const chunk="/static/chunk.js",matcher=/platform\\//;fetch("/api/v1/auth/me")'
|
||||
),
|
||||
headers={"content-type": "application/javascript"},
|
||||
)
|
||||
if path == "/css/app.css":
|
||||
return httpx.Response(
|
||||
200,
|
||||
text='@font-face{src:url("/fonts/app.woff2")}',
|
||||
headers={"content-type": "text/css"},
|
||||
)
|
||||
if path in {"/config.js", "/static/chunk.js", "/assets/deep.js"}:
|
||||
return httpx.Response(200, text=f'window.loaded="{path}"', headers={"content-type": "application/javascript"})
|
||||
if path == "/fonts/app.woff2":
|
||||
return httpx.Response(200, content=b"font", headers={"content-type": "font/woff2"})
|
||||
return httpx.Response(404, text=f"missing upstream path: {path}")
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: runtime)
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||
monkeypatch.setattr(llmwiki_router, "_get_weknora_admin_session", fake_session)
|
||||
|
||||
real_async_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(llmwiki_router.httpx, "AsyncClient", FakeUpstreamClient)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.add_middleware(AuthMiddleware)
|
||||
test_app.add_middleware(CSRFMiddleware)
|
||||
test_app.include_router(llmwiki_router.router)
|
||||
test_app.include_router(llmwiki_router.proxy_router, prefix="/deerflow")
|
||||
test_app.include_router(llmwiki_router.proxy_router)
|
||||
|
||||
cookie = sign_weknora_embed_payload(
|
||||
{
|
||||
"mapping_id": "local-kb",
|
||||
"weknora_id": "remote-kb",
|
||||
"user_id": "user-a",
|
||||
"is_admin": False,
|
||||
"can_write": True,
|
||||
"exp": 4_102_444_800,
|
||||
}
|
||||
)
|
||||
transport = httpx.ASGITransport(app=test_app)
|
||||
async with real_async_client(transport=transport, base_url="http://deerflow.test") as client:
|
||||
client.cookies.set(WEKNORA_EMBED_COOKIE, cookie)
|
||||
|
||||
page = await client.get("/deerflow/platform/knowledge-bases/remote-kb?tab=wiki")
|
||||
assert page.status_code == 200
|
||||
assert 'src="/deerflow/assets/main.js"' in page.text
|
||||
assert 'href="/deerflow/css/app.css"' in page.text
|
||||
assert 'src="/deerflow/config.js"' in page.text
|
||||
|
||||
script = await client.get("/deerflow/assets/main.js")
|
||||
assert script.status_code == 200
|
||||
assert '"/deerflow/static/chunk.js"' in script.text
|
||||
assert '"deerflow/assets/lazy.css"' in script.text
|
||||
assert '"deerflow/assets/lazy.js"' in script.text
|
||||
assert '"/deerflow/assets/lazy.css"' not in script.text
|
||||
assert 'matcher=/platform\\//' in script.text
|
||||
assert 'fetch("/deerflow/api/v1/auth/me")' in script.text
|
||||
|
||||
stylesheet = await client.get("/deerflow/css/app.css")
|
||||
assert stylesheet.status_code == 200
|
||||
assert 'url("/deerflow/fonts/app.woff2")' in stylesheet.text
|
||||
|
||||
for path in (
|
||||
"/deerflow/config.js",
|
||||
"/deerflow/static/chunk.js",
|
||||
"/deerflow/fonts/app.woff2",
|
||||
"/deerflow/platform/knowledge-bases/remote-kb/assets/deep.js",
|
||||
):
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 200, f"{path}: {response.status_code} {response.text}"
|
||||
|
||||
api = await client.get("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me")
|
||||
assert api.status_code == 200
|
||||
|
||||
api_request = next(request for request in upstream_requests if request[1].startswith(runtime.api_base_url))
|
||||
assert api_request[2]["Authorization"] == "Bearer admin-token"
|
||||
assert api_request[2]["X-Tenant-ID"] == "tenant-1"
|
||||
|
||||
|
||||
def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
||||
@@ -1225,6 +1405,15 @@ async def test_weknora_iframe_proxy_blocks_other_knowledge_bases() -> None:
|
||||
"token",
|
||||
)
|
||||
|
||||
await llmwiki_router._authorize_weknora_api_proxy(
|
||||
FakeRequest(),
|
||||
ctx,
|
||||
object(),
|
||||
"/api/v1/me/invitations/pending-count",
|
||||
b"",
|
||||
"token",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await llmwiki_router._authorize_weknora_api_proxy(
|
||||
FakeRequest(),
|
||||
|
||||
Reference in new issue
Block a user