diff --git a/frontend/src/components/MarkdownView.tsx b/frontend/src/components/MarkdownView.tsx index b323fe3f..0e01314f 100644 --- a/frontend/src/components/MarkdownView.tsx +++ b/frontend/src/components/MarkdownView.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useId, useRef, useState } from 'react' import katex from 'katex' import 'katex/dist/katex.min.css' +import { fetchAsBlobUrl } from '@/api/client' import { useTheme } from '@/lib/theme' /** Guard for [text](url) links: only render a real anchor for http(s) or @@ -31,11 +32,19 @@ const isSafeUrl = (u: string) => { * wikilink target is NOT recursed). Single `*`/`_` obey a minimal left/right * flanking rule so intraword runs (`a_b_c`, `2*3*4`) stay literal. */ -function inline(text: string, onWikiLink?: (target: string) => void): React.ReactNode[] { +function inline( + text: string, + onWikiLink?: (target: string) => void, + resolveImageSrc?: (rawSrc: string) => string | null, +): React.ReactNode[] { const parts: React.ReactNode[] = [] // Bold is non-greedy (`.+?`) so it tolerates an inner opposite-emphasis - // (`**a *b* c**`); italic keeps a bounded `[^*]+`/`[^_]+` class. - const re = /(\[\[[^\]]+\]\]|\\\([\s\S]+?\\\)|\*\*.+?\*\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g + // (`**a *b* c**`); italic keeps a bounded `[^*]+`/`[^_]+` class. The image + // alternative `![alt](url)` is included ONLY when a resolveImageSrc is given + // (the document reader), so chat/wiki rendering stays byte-for-byte unchanged. + const re = resolveImageSrc + ? /(\[\[[^\]]+\]\]|!\[[^\]]*\]\((?:[^()]|\([^()]*\))*\)|\\\([\s\S]+?\\\)|\*\*.+?\*\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g + : /(\[\[[^\]]+\]\]|\\\([\s\S]+?\\\)|\*\*.+?\*\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g let last = 0, m: RegExpExecArray | null, k = 0 while ((m = re.exec(text))) { if (m.index > last) parts.push(text.slice(last, m.index)) @@ -85,11 +94,28 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac ) } else if (tok.startsWith('**')) { // Recurse so nested markup (e.g. `**[docs](url)**`) resolves. slice is strictly shorter. - parts.push({inline(tok.slice(2, -2), onWikiLink)}) + parts.push({inline(tok.slice(2, -2), onWikiLink, resolveImageSrc)}) } else if (tok.startsWith('~~')) { - parts.push({inline(tok.slice(2, -2), onWikiLink)}) + parts.push({inline(tok.slice(2, -2), onWikiLink, resolveImageSrc)}) } else if (tok.startsWith('`')) { parts.push({tok.slice(1, -1)}) + } else if (tok.startsWith('![')) { + // Markdown image — only reached when resolveImageSrc is provided (the + // regex omits this token otherwise). resolveImageSrc maps a KB-relative + // source path to an authed API path, or null for external/data URLs + // (left as literal text). Cannot collide with the link/wikilink branches: + // those match tokens starting with '[', this one starts with '!['. + const im = /^!\[([^\]]*)\]\(((?:[^()]|\([^()]*\))*)\)$/.exec(tok) + const alt = im ? im[1] : '' + const raw = im ? im[2].trim() : '' + const apiPath = raw ? (resolveImageSrc?.(raw) ?? null) : null + parts.push( + apiPath ? ( + + ) : ( + {tok} + ), + ) } else if (tok.startsWith('[')) { // Inline link [text](url). `[[…]]` was already consumed above, so any // `[` reaching here is a genuine link. Only emit an anchor when the URL @@ -109,7 +135,7 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac rel="noopener noreferrer" className="text-accent-brand hover:underline" > - {inline(label, onWikiLink)} + {inline(label, onWikiLink, resolveImageSrc)} ) : ( {tok} @@ -135,7 +161,7 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac re.lastIndex = m.index + 1 continue } - parts.push({inline(innerEm, onWikiLink)}) + parts.push({inline(innerEm, onWikiLink, resolveImageSrc)}) } last = m.index + tok.length } @@ -216,14 +242,67 @@ function MermaidBlock({ code }: { code: string }) { ) } +/** Render a bearer-authed KB image. The API token can't ride on ``, + * so we fetch the API path as a blob (mirrors `artifacts.ts`), show it, and + * revoke the object URL on unmount / src change. While loading it shows a muted + * placeholder; on failure it shows a small dashed chip (the alt) so a missing + * image is visible, not silently dropped. */ +function AuthedImage({ apiPath, alt }: { apiPath: string; alt: string }) { + const [url, setUrl] = useState(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 ( + {alt} + ) +} + 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) => (
    1. - {inline(li, onWikiLink)} + {inline(li, onWikiLink, resolveImageSrc)}
    2. ))}
    , @@ -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