Detailed changes
@@ -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))
+}
@@ -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) {
@@ -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())
@@ -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]);
@@ -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 },
},