feat: add /gitraw endpoint for ref+path raw file serving

Quentin Gliech and Claude Opus 4.6 (1M context) created

add a new HTTP handler at /gitraw/{repo}/{ref}/{path} that resolves
a git ref and file path to raw blob content, using BlobAtPath.

this enables the web UI to render images from markdown READMEs
without needing to know blob hashes upfront — the markdown renderer
rewrites relative image URLs to /gitraw/{repo}/{ref}/{resolved-path}
while relative links go to the code browser blob view.

- add BlobAtPath passthrough on RepoCache
- add gitRawHandler serving raw content with Content-Type detection
- register /gitraw route in webui server
- add /gitraw to Vite dev proxy
- simplify Markdown component: use urlTransform to rewrite URLs
  based on file extension (images → /gitraw, links → blob viewer)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

api/http/git_raw_handler.go                | 66 ++++++++++++++++++++++++
cache/repo_cache_common.go                 |  7 ++
commands/webui.go                          |  1 
webui2/src/components/content/Markdown.tsx | 41 +++++++++++---
webui2/vite.config.ts                      |  1 
5 files changed, 107 insertions(+), 9 deletions(-)

Detailed changes

api/http/git_raw_handler.go 🔗

@@ -0,0 +1,66 @@
+package http
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/gorilla/mux"
+
+	"github.com/git-bug/git-bug/cache"
+)
+
+// Serves raw blob content resolved by ref and path, e.g.
+// /gitraw/{repo}/{ref}/{path:.*}
+//
+// This is used by the web UI to render images referenced in markdown
+// files (READMEs etc.) without needing to know the blob hash upfront.
+type gitRawHandler struct {
+	mrc *cache.MultiRepoCache
+}
+
+func NewGitRawHandler(mrc *cache.MultiRepoCache) http.Handler {
+	return &gitRawHandler{mrc: mrc}
+}
+
+func (h *gitRawHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	var repo *cache.RepoCache
+	var err error
+
+	repoVar := mux.Vars(r)["repo"]
+	if repoVar == "_" {
+		repo, err = h.mrc.DefaultRepo()
+	} else {
+		repo, err = h.mrc.ResolveRepo(repoVar)
+	}
+	if err != nil {
+		http.Error(rw, "invalid repo reference", http.StatusBadRequest)
+		return
+	}
+
+	ref := mux.Vars(r)["ref"]
+	path := mux.Vars(r)["path"]
+
+	if ref == "" || path == "" {
+		http.Error(rw, "ref and path are required", http.StatusBadRequest)
+		return
+	}
+
+	rc, _, _, err := repo.BlobAtPath(ref, path)
+	if err != nil {
+		http.Error(rw, "file not found", http.StatusNotFound)
+		return
+	}
+	defer rc.Close()
+
+	data, err := io.ReadAll(rc)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// ServeContent handles Content-Type detection from the file extension,
+	// Range requests, and caching headers.
+	http.ServeContent(rw, r, path, time.Now(), bytes.NewReader(data))
+}

cache/repo_cache_common.go 🔗

@@ -1,6 +1,7 @@
 package cache
 
 import (
+	"io"
 	"strings"
 	"sync"
 
@@ -81,6 +82,12 @@ func (c *RepoCache) ReadData(hash repository.Hash) ([]byte, error) {
 	return c.repo.ReadData(hash)
 }
 
+// BlobAtPath returns the raw content, byte size, and git object hash of the
+// file at the given path within the tree of the given ref.
+func (c *RepoCache) BlobAtPath(ref, path string) (io.ReadCloser, int64, repository.Hash, error) {
+	return c.repo.BlobAtPath(ref, path)
+}
+
 
 // StoreData will store arbitrary data and return the corresponding hash
 func (c *RepoCache) StoreData(data []byte) (repository.Hash, error) {

commands/webui.go 🔗

@@ -153,6 +153,7 @@ func setupRoutes(env *execenv.Env, opts webUIOptions, baseURL string) (*mux.Rout
 
 	// File and upload routes for bug attachments.
 	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
+	router.PathPrefix("/gitraw/{repo}/{ref}/{path:.*}").Handler(httpapi.NewGitRawHandler(mrc))
 	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
 
 	router.PathPrefix("/").Handler(webui2.NewHandler())

webui2/src/components/content/Markdown.tsx 🔗

@@ -22,20 +22,21 @@ const sanitizeSchema = {
   },
 };
 
+export interface RepoContext {
+  repo: string;
+  ref: string;
+  /** Directory containing the markdown file (e.g. "doc" for doc/README.md). */
+  basePath: string;
+}
+
 interface MarkdownProps {
   content: string;
   className?: string;
-  /** When set, relative links/images are resolved against the code browser. */
-  repoContext?: {
-    repo: string;
-    ref: string;
-    /** Directory containing the markdown file (e.g. "doc" for doc/README.md). */
-    basePath: string;
-  };
+  /** When set, relative links/images are resolved against the repo. */
+  repoContext?: RepoContext;
 }
 
 function isRelativeUrl(url: string): boolean {
-  // Absolute URLs, protocol-relative, anchors, and data URIs are not relative
   return !/^(?:[a-z][a-z0-9+.-]*:|\/\/|#|data:)/i.test(url);
 }
 
@@ -51,16 +52,38 @@ function resolveRelativePath(basePath: string, relativePath: string): string {
   return parts.join("/");
 }
 
+const IMAGE_EXTENSIONS = new Set([
+  "png",
+  "jpg",
+  "jpeg",
+  "gif",
+  "svg",
+  "webp",
+  "avif",
+  "ico",
+  "bmp",
+]);
+
+function isImagePath(path: string): boolean {
+  const ext = path.split(".").pop()?.toLowerCase() ?? "";
+  return IMAGE_EXTENSIONS.has(ext);
+}
+
 // Renders a Markdown string with GitHub-flavoured extensions (tables, task
 // lists, strikethrough). Used in Timeline comments and code browser READMEs.
 export function Markdown({ content, className, repoContext }: MarkdownProps) {
-  // Build a urlTransform that rewrites relative URLs to the code browser
+  // Rewrite relative URLs:
+  //   - images → /gitraw/{repo}/{ref}/{path} (serves raw bytes)
+  //   - links  → /{repo}/blob/{ref}/{path}   (code browser view)
   const urlTransform = useMemo(() => {
     if (!repoContext) return undefined;
     const { repo, ref, basePath } = repoContext;
     return (url: string) => {
       if (!isRelativeUrl(url)) return url;
       const resolved = resolveRelativePath(basePath, url);
+      if (isImagePath(resolved)) {
+        return `/gitraw/${repo}/${ref}/${resolved}`;
+      }
       return `/${repo}/blob/${ref}/${resolved}`;
     };
   }, [repoContext]);

webui2/vite.config.ts 🔗

@@ -19,6 +19,7 @@ export default defineConfig({
     proxy: {
       "/graphql": { target: API_URL, changeOrigin: true },
       "/gitfile": { target: API_URL, changeOrigin: true },
+      "/gitraw": { target: API_URL, changeOrigin: true },
       "/upload": { target: API_URL, changeOrigin: true },
       "/auth": { target: API_URL, changeOrigin: true },
     },