(null)
+ const [failed, setFailed] = useState(false)
+ useEffect(() => {
+ let cancelled = false
+ let objectUrl: string | null = null
+ setUrl(null)
+ setFailed(false)
+ fetchAsBlobUrl(apiPath)
+ .then((u) => {
+ if (cancelled) {
+ URL.revokeObjectURL(u)
+ return
+ }
+ objectUrl = u
+ setUrl(u)
+ })
+ .catch(() => {
+ if (!cancelled) setFailed(true)
+ })
+ return () => {
+ cancelled = true
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
+ }
+ }, [apiPath])
+ // Surface a failed load (missing file, network, expired token) with a muted
+ // dashed placeholder showing the alt, rather than silently rendering nothing.
+ if (failed)
+ return (
+
+ {alt}
+
+ )
+ if (!url) return
+ return (
+
+ )
+}
+
export default function MarkdownView({
source,
onWikiLink,
+ resolveImageSrc,
}: {
source: string
/** Navigate to a `[[target]]` wikilink's page. Omit to render plain,
* non-interactive tokens (no `cursor-pointer` implying a dead click). */
onWikiLink?: (target: string) => void
+ /** Map a Markdown image's raw src to an authed API path (or null to leave it
+ * as text). Only the document reader passes this; without it, `` is
+ * not tokenized as an image, so chat/wiki rendering is unchanged. */
+ resolveImageSrc?: (rawSrc: string) => string | null
}) {
const lines = source.split('\n')
const out: React.ReactNode[] = []
@@ -240,7 +319,7 @@ export default function MarkdownView({
{list.map((li, i) => (
- {inline(li, onWikiLink)}
+ {inline(li, onWikiLink, resolveImageSrc)}
))}
,
@@ -254,7 +333,7 @@ export default function MarkdownView({
{olist.map((li, i) => (
-
- {inline(li, onWikiLink)}
+ {inline(li, onWikiLink, resolveImageSrc)}
))}
,
@@ -382,7 +461,7 @@ export default function MarkdownView({
key={c}
className={`border border-[hsl(var(--glass-border))] bg-muted/50 px-3 py-1.5 font-semibold text-foreground ${alignOf(c)}`}
>
- {inline(h, onWikiLink)}
+ {inline(h, onWikiLink, resolveImageSrc)}
))}
@@ -395,7 +474,7 @@ export default function MarkdownView({
key={c}
className={`border border-[hsl(var(--glass-border))] px-3 py-1.5 text-muted-foreground ${alignOf(c)}`}
>
- {inline(r[c] ?? '', onWikiLink)}
+ {inline(r[c] ?? '', onWikiLink, resolveImageSrc)}
))}
@@ -422,11 +501,11 @@ export default function MarkdownView({
}
flushBlocks()
if (!line.trim()) { out.push(); continue }
- if (line.startsWith('### ')) out.push({inline(line.slice(4), onWikiLink)}
)
- else if (line.startsWith('## ')) out.push({inline(line.slice(3), onWikiLink)}
)
- else if (line.startsWith('# ')) out.push({inline(line.slice(2), onWikiLink)}
)
- else if (line.startsWith('> ')) out.push({inline(line.slice(2), onWikiLink)}
)
- else out.push({inline(line, onWikiLink)}
)
+ if (line.startsWith('### ')) out.push({inline(line.slice(4), onWikiLink, resolveImageSrc)}
)
+ else if (line.startsWith('## ')) out.push({inline(line.slice(3), onWikiLink, resolveImageSrc)}
)
+ else if (line.startsWith('# ')) out.push({inline(line.slice(2), onWikiLink, resolveImageSrc)}
)
+ else if (line.startsWith('> ')) out.push({inline(line.slice(2), onWikiLink, resolveImageSrc)}
)
+ else out.push({inline(line, onWikiLink, resolveImageSrc)}
)
}
flushBlocks()
return {out}
diff --git a/frontend/src/pages/KbDetail.tsx b/frontend/src/pages/KbDetail.tsx
index c4c5935e..4c667a88 100644
--- a/frontend/src/pages/KbDetail.tsx
+++ b/frontend/src/pages/KbDetail.tsx
@@ -1081,11 +1081,27 @@ function DocumentsPane({
}
}, [kb, openHash, docReloadSeq])
+ // Map a source image ref to the authed image endpoint. Long-doc JSON stores
+ // wiki-root-relative `sources/images/...`; short-doc MD uses note-relative
+ // `images/...` — normalize both to a wiki-relative path. Non-matching refs
+ // (external / data URLs) return null and render as plain text.
+ const resolveDocImageSrc = useCallback(
+ (rawSrc: string): string | null => {
+ let rel: string | null = null
+ if (rawSrc.startsWith('sources/images/')) rel = rawSrc
+ else if (rawSrc.startsWith('images/')) rel = `sources/${rawSrc}`
+ if (!rel) return null
+ return `/api/v1/document/image?kb=${encodeURIComponent(kb)}&path=${encodeURIComponent(rel)}`
+ },
+ [kb],
+ )
// Parse Markdown once per fetched source (stable cache ref → no re-parse).
const readerBody = useMemo(
() =>
- docSource && docSource.content.trim() ? : null,
- [docSource],
+ docSource && docSource.content.trim() ? (
+
+ ) : null,
+ [docSource, resolveDocImageSrc],
)
const readerEmpty = docSource != null && docSource.content.trim().length === 0
diff --git a/openkb/api_documents_router.py b/openkb/api_documents_router.py
index d8c84cd1..a34a62ed 100644
--- a/openkb/api_documents_router.py
+++ b/openkb/api_documents_router.py
@@ -8,7 +8,8 @@
from __future__ import annotations
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import FileResponse
from starlette.concurrency import run_in_threadpool
from openkb.api_helpers import _resolve_kb, require_bearer_token
@@ -17,6 +18,19 @@
documents_router = APIRouter()
+# Raster types the image extractor produces, mapped to explicit media types.
+# SVG is excluded (inline-script risk). We set the media type ourselves rather
+# than let FileResponse guess it: mimetypes has no ``.webp`` entry on some
+# Python versions and would fall back to ``text/plain``, which a blob-loaded
+# ``
`` then refuses to render.
+_IMAGE_MEDIA_TYPES = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+}
+
@documents_router.post("/api/v1/document/source", response_model=DocumentSourceResponse)
async def document_source_endpoint(
@@ -33,3 +47,29 @@ async def document_source_endpoint(
if result is None:
raise HTTPException(status_code=404, detail="Document source not found.")
return DocumentSourceResponse(**result)
+
+
+@documents_router.get("/api/v1/document/image")
+async def document_image_endpoint(
+ kb: str = Query(...),
+ path: str = Query(..., min_length=1),
+ _: None = Depends(require_bearer_token),
+) -> FileResponse:
+ """Serve an extracted document image (``wiki/sources/images/**``).
+
+ ``path`` is the image reference from the source text, resolved relative to
+ the KB's ``wiki/`` dir (source text stores ``sources/images//...``).
+ Narrowed to the images dir + raster suffixes with a traversal guard, so this
+ read-only sink can never serve wiki pages, skills, or arbitrary files.
+ """
+ kb_dir = _resolve_kb(kb)
+ images_root = (kb_dir / "wiki" / "sources" / "images").resolve()
+ full = (kb_dir / "wiki" / path).resolve()
+ if not full.is_relative_to(images_root):
+ raise HTTPException(status_code=400, detail="Invalid image path.")
+ media_type = _IMAGE_MEDIA_TYPES.get(full.suffix.lower())
+ if media_type is None:
+ raise HTTPException(status_code=400, detail="Only extracted images are served.")
+ if not full.is_file():
+ raise HTTPException(status_code=404, detail="Image not found.")
+ return FileResponse(full, media_type=media_type)
diff --git a/tests/test_api_documents.py b/tests/test_api_documents.py
index 861a6edf..dd8b90c3 100644
--- a/tests/test_api_documents.py
+++ b/tests/test_api_documents.py
@@ -186,3 +186,97 @@ def test_document_source_requires_auth(monkeypatch, kb_dir):
resp = client.post("/api/v1/document/source", json={"kb": "test-kb", "hash": "h1"})
assert resp.status_code == 401
+
+
+def test_document_image_serves_extracted_image(monkeypatch, kb_dir):
+ client = _client(monkeypatch)
+ kb = _use_named_kb(monkeypatch, kb_dir)
+ img_dir = kb_dir / "wiki" / "sources" / "images" / "doc"
+ img_dir.mkdir(parents=True)
+ (img_dir / "p1_img1.png").write_bytes(b"\x89PNG\r\n\x1a\nfake-bytes")
+
+ resp = client.get(
+ "/api/v1/document/image",
+ params={"kb": kb, "path": "sources/images/doc/p1_img1.png"},
+ headers=_auth(),
+ )
+
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("image/")
+ assert resp.content == b"\x89PNG\r\n\x1a\nfake-bytes"
+
+
+def test_document_image_sets_explicit_media_type(monkeypatch, kb_dir):
+ """Media type is set from the suffix (not guessed), so a .webp is served as
+ image/webp even on Pythons whose mimetypes lacks a webp entry (which would
+ otherwise degrade to text/plain and break a blob-loaded
)."""
+ client = _client(monkeypatch)
+ kb = _use_named_kb(monkeypatch, kb_dir)
+ img_dir = kb_dir / "wiki" / "sources" / "images" / "doc"
+ img_dir.mkdir(parents=True)
+ (img_dir / "p1_img1.webp").write_bytes(b"RIFFfake")
+
+ resp = client.get(
+ "/api/v1/document/image",
+ params={"kb": kb, "path": "sources/images/doc/p1_img1.webp"},
+ headers=_auth(),
+ )
+
+ assert resp.status_code == 200
+ assert resp.headers["content-type"] == "image/webp"
+
+
+def test_document_image_rejects_traversal(monkeypatch, kb_dir):
+ """A path escaping wiki/sources/images (even to another .png) is rejected by
+ the containment guard before the suffix check."""
+ client = _client(monkeypatch)
+ kb = _use_named_kb(monkeypatch, kb_dir)
+ (kb_dir / "wiki" / "sources" / "evil.png").write_bytes(b"x") # inside sources/, outside images/
+
+ resp = client.get(
+ "/api/v1/document/image",
+ params={"kb": kb, "path": "sources/images/../evil.png"},
+ headers=_auth(),
+ )
+
+ assert resp.status_code == 400
+
+
+def test_document_image_rejects_non_image_suffix(monkeypatch, kb_dir):
+ client = _client(monkeypatch)
+ kb = _use_named_kb(monkeypatch, kb_dir)
+ d = kb_dir / "wiki" / "sources" / "images" / "doc"
+ d.mkdir(parents=True)
+ (d / "notes.md").write_text("secret", encoding="utf-8")
+
+ resp = client.get(
+ "/api/v1/document/image",
+ params={"kb": kb, "path": "sources/images/doc/notes.md"},
+ headers=_auth(),
+ )
+
+ assert resp.status_code == 400
+
+
+def test_document_image_missing_404(monkeypatch, kb_dir):
+ client = _client(monkeypatch)
+ kb = _use_named_kb(monkeypatch, kb_dir)
+
+ resp = client.get(
+ "/api/v1/document/image",
+ params={"kb": kb, "path": "sources/images/doc/nope.png"},
+ headers=_auth(),
+ )
+
+ assert resp.status_code == 404
+
+
+def test_document_image_requires_auth(monkeypatch, kb_dir):
+ client = _client(monkeypatch)
+ _use_named_kb(monkeypatch, kb_dir)
+
+ resp = client.get(
+ "/api/v1/document/image", params={"kb": "test-kb", "path": "sources/images/doc/p.png"}
+ )
+
+ assert resp.status_code == 401