diff --git a/api/http/git_raw_handler.go b/api/http/git_raw_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..285f4bc6deac1e771b464ed0923d2da0957331bc --- /dev/null +++ b/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)) +} diff --git a/cache/repo_cache_common.go b/cache/repo_cache_common.go index 74eaef87ea7a1ede0fa1ee4db8498fd535aca7e9..89ffe3d453ad2c90e1c3ca60f8d6657956ff7c5a 100644 --- a/cache/repo_cache_common.go +++ b/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) { diff --git a/commands/webui.go b/commands/webui.go index 2a4ffb3725386c01c394555e4cd8bc830f547bb5..5d22907e8981d68e8eb49ed1f4ebae48646181b4 100644 --- a/commands/webui.go +++ b/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()) diff --git a/webui2/src/components/content/Markdown.tsx b/webui2/src/components/content/Markdown.tsx index 390412391681e92364b4b95ebce17ea47ca6fe61..5ca0e1119d359b1b28d9dc07c9dbed798c5eef77 100644 --- a/webui2/src/components/content/Markdown.tsx +++ b/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]); diff --git a/webui2/vite.config.ts b/webui2/vite.config.ts index 392c9917a48dfdf7c7d7a1a37a3cab0126d2de41..2db1a80180e0f1ca3c41d337d137f4beae16cb24 100644 --- a/webui2/vite.config.ts +++ b/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 }, },